feat(config + admin panel): handle features activated
- review admin dashboard to reflect features enabled/disabled - hide mount option if feature is disabled - remove QUOTA option as it is not wired
This commit is contained in:
@@ -104,7 +104,7 @@ kubectl logs statefulset/oxicloud -n oxicloud | grep "WOPI discovery loaded"
|
||||
| Trash | Yes | No | `OXICLOUD_ENABLE_TRASH` |
|
||||
| Search | Yes | No | `OXICLOUD_ENABLE_SEARCH` |
|
||||
| Favorites | Yes | Yes | Always on |
|
||||
| Storage quotas | Yes | Yes | `OXICLOUD_ENABLE_USER_STORAGE_QUOTAS` |
|
||||
| Storage quotas | Yes | Yes | Per-user via admin panel (no master switch) |
|
||||
| WebDAV | Yes | Optional | Always on |
|
||||
| CalDAV / CardDAV | Yes | Yes | Always on |
|
||||
| Deduplication | No | No | Always on |
|
||||
|
||||
+1
-1
@@ -109,7 +109,6 @@ rather than as a visible error.
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `OXICLOUD_ENABLE_AUTH` | `true` | Enable authentication |
|
||||
| `OXICLOUD_ENABLE_USER_STORAGE_QUOTAS` | `false` | Per-user storage quotas |
|
||||
| `OXICLOUD_ENABLE_FILE_SHARING` | `true` | File/folder sharing |
|
||||
| `OXICLOUD_ENABLE_TRASH` | `true` | Trash / recycle bin |
|
||||
| `OXICLOUD_ENABLE_SEARCH` | `true` | Full-text and metadata search |
|
||||
@@ -117,6 +116,7 @@ rather than as a visible error.
|
||||
| `OXICLOUD_ENABLE_VIDEO_THUMBNAILS` | `true` | Server-side single-frame thumbnail extraction from uploaded videos (one frame → WebP). Requires `ffmpeg` on `PATH` (override with `OXICLOUD_FFMPEG_PATH`). When true and ffmpeg is missing at boot, a WARN log is emitted and videos fall back to a placeholder icon. Set to `false` to skip the ffmpeg lookup entirely — useful on hosts where ffmpeg can't be installed, or when the client uploads video previews itself (some desktop/mobile clients generate thumbnails locally and POST them alongside the video). |
|
||||
| `OXICLOUD_FFMPEG_PATH` | `ffmpeg` (on PATH) | Absolute path to the ffmpeg binary. Ignored when `OXICLOUD_ENABLE_VIDEO_THUMBNAILS=false`. Useful for pinning a specific static build or when ffmpeg lives outside the default PATH. |
|
||||
| `OXICLOUD_EXPOSE_SYSTEM_USERS` | `true` | Expose other OxiCloud users as a read-only address book at `GET /api/address-books` |
|
||||
| `OXICLOUD_ENABLE_EXTERNAL_MOUNTS` | `false` | External file mounts — surface host-filesystem paths (or a future S3/WebDAV/SFTP backend) as folders inside a user's drive. Admins configure mount rows via the "External Mounts" admin panel; each row picks a target drive, mount-root name, provider kind, and provider config. **When `false`**: admin CRUD (`/api/admin/external-mounts`) is not registered (404); the "External Mounts" tab is hidden from the admin sidebar (FE gates on `serverConfig.features.external_mounts`, same discovery path as `message_bus`); `MountRegistry` stays empty at boot even if mount rows exist in the DB, so mount-root folders resolve as empty native folders. **When `true`**: CRUD exposed, existing DB rows load at boot via `MountRegistry::reload`, users can browse mount contents. **Opt-in per deployment** because external mounts expose host filesystems (or credentialed remote backends) inside user drives — an admin misconfiguration can leak state that isn't part of OxiCloud's normal storage substrate. Keep off unless you have a concrete need. |
|
||||
| `OXICLOUD_GRANT_CLEANUP_ENABLED` | `true` | Background daemon that deletes expired rows from `storage.role_grants`. The authorization engine already filters expired grants out of every permission check at read time (`expires_at IS NULL OR expires_at > NOW()`), so leaving expired rows in place is a hygiene issue — not a security one. This daemon garbage-collects them daily. Set to `false` to keep every expired grant row forever (uncommon; a fresh install rarely wants this). |
|
||||
| `OXICLOUD_GRANT_CLEANUP_GRACE_DAYS` | `15` | Days past a grant's `expires_at` before the row is eligible for deletion. The grace window preserves the audit / support answer to "what happened to my access?" for a couple of weeks past expiration. Values below 1 are legal but discouraged — the recommendation is **≥ 15 days**. Values above the actual grant TTL used by clients waste index space; a few weeks is the sweet spot. |
|
||||
| `OXICLOUD_GRANT_CLEANUP_INTERVAL_HOURS` | `24` | How often the grant-cleanup daemon fires. Clamped to a minimum of 1 hour. Adjusting this doesn't change what gets deleted — only how promptly. Daily is fine for any realistic grant volume. |
|
||||
|
||||
+45
-17
@@ -387,9 +387,6 @@ DATABASE_URL=postgres://postgres:postgres@localhost:5432/oxicloud
|
||||
# Enable/disable authentication system (default: true)
|
||||
#OXICLOUD_ENABLE_AUTH=true
|
||||
|
||||
# Enable per-user storage quotas (default: false)
|
||||
#OXICLOUD_ENABLE_USER_STORAGE_QUOTAS=false
|
||||
|
||||
# Enable file/folder sharing (default: true)
|
||||
#OXICLOUD_ENABLE_FILE_SHARING=true
|
||||
|
||||
@@ -464,6 +461,37 @@ DATABASE_URL=postgres://postgres:postgres@localhost:5432/oxicloud
|
||||
# Set to false to prevent users from browsing the user directory.
|
||||
#OXICLOUD_EXPOSE_SYSTEM_USERS=true
|
||||
|
||||
# External file mounts — surface host-filesystem paths (or a future
|
||||
# S3/WebDAV/SFTP backend) as folders inside a user's drive. Admins
|
||||
# configure mount rows via /api/admin/external-mounts (the "External
|
||||
# Mounts" tab in the admin panel); each row picks a target drive, a
|
||||
# name for the mount root, a provider kind, and a provider-specific
|
||||
# config (e.g. host path for local).
|
||||
#
|
||||
# When `false` (default):
|
||||
# - Admin CRUD routes (/api/admin/external-mounts) are NOT
|
||||
# registered — Axum returns 404. The admin panel's "External
|
||||
# Mounts" tab is hidden from the sidebar too (FE gates on
|
||||
# `serverConfig.features.external_mounts` — same discovery path
|
||||
# as `message_bus`).
|
||||
# - At boot, `MountRegistry` stays empty even if mount rows exist
|
||||
# in the DB — the runtime resolver has nothing to route to.
|
||||
# Users navigating into a mount-root folder see an empty native
|
||||
# folder (row exists, no children).
|
||||
#
|
||||
# When `true`:
|
||||
# - Admin panel exposes the CRUD; existing DB rows load at boot
|
||||
# via `MountRegistry::reload`; users can browse mount contents.
|
||||
#
|
||||
# Opt-in per deployment because external mounts expose host
|
||||
# filesystems (or credentialed remote backends) inside user drives —
|
||||
# an admin misconfiguration can leak state that isn't part of
|
||||
# OxiCloud's normal storage substrate. Keep off unless you have a
|
||||
# concrete need.
|
||||
#
|
||||
# Default: false.
|
||||
#OXICLOUD_ENABLE_EXTERNAL_MOUNTS=false
|
||||
|
||||
# ── People (face recognition) ────────────────────────────────────────────
|
||||
# Biometric data (GDPR Art. 9) — OFF by default, opt-in per deployment.
|
||||
# Detects faces and clusters them into people in the photo library.
|
||||
@@ -1100,20 +1128,6 @@ OXICLOUD_WOPI_ENABLED=false
|
||||
# collaborative editor. See docs/plan/message-bus.md for the JSON-RPC 2.0
|
||||
# wire protocol.
|
||||
|
||||
# Server-initiated protocol Ping interval (seconds). Prevents intermediate
|
||||
# proxies (Traefik, nginx, Cloudflare) and NAT boxes from reaping the TCP
|
||||
# session as idle. Read at each WS connect — a change takes effect on new
|
||||
# connections without restart. Set 0 (or any non-positive value) to fall
|
||||
# back to the default.
|
||||
#
|
||||
# Tuning: the interval should sit at most half the smallest hop's idle
|
||||
# timeout, so a single missed Ping doesn't kill the connection. Common
|
||||
# floors:
|
||||
# * nginx `proxy_read_timeout` default 60s → ping ≤ 30s
|
||||
# * Cloudflare hard limit 100s → ping ≤ 45s
|
||||
# * Traefik with idleTimeout bumped to 3600s → 30s is safely under
|
||||
#OXICLOUD_MESSAGEBUS_KEEPALIVE_SECONDS=30
|
||||
|
||||
# Message bus master switch. When `false`, /api/rt/ws and
|
||||
# POST /api/rt/ticket are NOT registered at boot — Axum returns 404
|
||||
# for both, keeping monitoring dashboards free of 5xx noise. Clients
|
||||
@@ -1135,6 +1149,20 @@ OXICLOUD_WOPI_ENABLED=false
|
||||
# Default: true.
|
||||
#OXICLOUD_MESSAGEBUS_ENABLE=true
|
||||
|
||||
# Server-initiated protocol Ping interval (seconds). Prevents intermediate
|
||||
# proxies (Traefik, nginx, Cloudflare) and NAT boxes from reaping the TCP
|
||||
# session as idle. Read at each WS connect — a change takes effect on new
|
||||
# connections without restart. Set 0 (or any non-positive value) to fall
|
||||
# back to the default.
|
||||
#
|
||||
# Tuning: the interval should sit at most half the smallest hop's idle
|
||||
# timeout, so a single missed Ping doesn't kill the connection. Common
|
||||
# floors:
|
||||
# * nginx `proxy_read_timeout` default 60s → ping ≤ 30s
|
||||
# * Cloudflare hard limit 100s → ping ≤ 45s
|
||||
# * Traefik with idleTimeout bumped to 3600s → 30s is safely under
|
||||
#OXICLOUD_MESSAGEBUS_KEEPALIVE_SECONDS=30
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# MEMORY ALLOCATOR TUNING (IMPORTANT FOR RAM USAGE)
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
@@ -416,11 +416,15 @@ export interface AdminDashboard {
|
||||
* `online_sessions / online_users` is the multi-device factor
|
||||
* (browser + desktop + phone). */
|
||||
online_sessions: number;
|
||||
/** Currently-connected message-bus WebSocket sessions — one per
|
||||
* open browser tab that reached a folder view. Reported as `0`
|
||||
* when `OXICLOUD_MESSAGEBUS_ENABLE=false` (no live sessions
|
||||
* possible); the dashboard hides the card in that case since
|
||||
* the value would be misleading. */
|
||||
active_ws_sessions: number;
|
||||
server_version: string;
|
||||
drive_usage: DriveKindUsage[];
|
||||
auth_enabled: boolean;
|
||||
oidc_configured: boolean;
|
||||
quotas_enabled: boolean;
|
||||
registration_enabled?: boolean;
|
||||
users_over_80_percent: number;
|
||||
users_over_quota: number;
|
||||
|
||||
@@ -893,7 +893,10 @@ export interface ServerFeatures {
|
||||
trash: boolean;
|
||||
search: boolean;
|
||||
sharing: boolean;
|
||||
quotas: boolean;
|
||||
// NOTE: `quotas` was intentionally NOT exposed — see the Rust
|
||||
// `FeaturesDto` doc for why (dormant server flag with zero
|
||||
// consumers). Add it back once it actually gates FE-visible
|
||||
// behavior.
|
||||
music: boolean;
|
||||
places: boolean;
|
||||
faces: boolean;
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
import { dateTimeFormatFor, iconNameFromClass } from '$lib/utils/display';
|
||||
import { userInitials, avatarColorIndex } from '$lib/utils/avatar';
|
||||
import { i18n, LANGUAGES, setLocale, t, type Locale } from '$lib/i18n/index.svelte';
|
||||
import { serverConfig } from '$lib/stores/serverConfig.svelte';
|
||||
import { serverStatus } from '$lib/stores/serverStatus.svelte';
|
||||
import { apiFetch } from '$lib/api/client';
|
||||
import { dialogs } from '$lib/stores/dialogs.svelte';
|
||||
@@ -74,68 +75,81 @@
|
||||
// strip; that was displaced here so the section navigation
|
||||
// scales past ~7 items and matches deep-link URLs from the
|
||||
// address bar.
|
||||
const ADMIN_LINKS: NavLink[] = [
|
||||
{
|
||||
href: '/admin',
|
||||
label: t('admin.dashboard', 'Dashboard'),
|
||||
icon: 'chart-pie',
|
||||
section: 'admin-dashboard'
|
||||
},
|
||||
{
|
||||
href: '/admin/users',
|
||||
label: t('admin.users', 'Users'),
|
||||
icon: 'users',
|
||||
section: 'admin-users'
|
||||
},
|
||||
{
|
||||
href: '/admin/sessions',
|
||||
label: t('admin.sessions', 'Sessions'),
|
||||
icon: 'key',
|
||||
section: 'admin-sessions'
|
||||
},
|
||||
{
|
||||
href: '/admin/drives',
|
||||
label: t('admin.drives', 'Drives'),
|
||||
icon: 'hdd',
|
||||
section: 'admin-drives'
|
||||
},
|
||||
{
|
||||
href: '/admin/mounts',
|
||||
label: t('admin.mounts', 'External Mounts'),
|
||||
icon: 'folder',
|
||||
section: 'admin-mounts'
|
||||
},
|
||||
{
|
||||
href: '/admin/oidc',
|
||||
label: t('admin.oidc', 'OIDC / SSO'),
|
||||
icon: 'building-shield',
|
||||
section: 'admin-oidc'
|
||||
},
|
||||
{
|
||||
href: '/admin/storage',
|
||||
label: t('admin.storage_tab', 'Storage'),
|
||||
icon: 'database',
|
||||
section: 'admin-storage'
|
||||
},
|
||||
{
|
||||
href: '/admin/smtp',
|
||||
label: t('admin.smtp', 'Email (SMTP)'),
|
||||
icon: 'envelope',
|
||||
section: 'admin-smtp'
|
||||
},
|
||||
{
|
||||
href: '/admin/plugins',
|
||||
label: t('admin.plugins', 'Plugins'),
|
||||
icon: 'layer-group',
|
||||
section: 'admin-plugins'
|
||||
},
|
||||
{
|
||||
href: '/admin/jobs',
|
||||
label: t('admin.jobs.tab', 'Background tasks'),
|
||||
icon: 'cogs',
|
||||
section: 'admin-jobs'
|
||||
// `$derived` so feature-flag gating drops entries when a feature is
|
||||
// disabled server-side. Server-side the admin CRUD routes are also
|
||||
// gated (matching the message-bus pattern) — hiding the link here
|
||||
// keeps the sidebar consistent with what the backend actually
|
||||
// serves; a stale link would land on a 404. See
|
||||
// `$lib/stores/serverConfig.svelte.ts`.
|
||||
const ADMIN_LINKS = $derived.by<NavLink[]>(() => {
|
||||
const links: NavLink[] = [
|
||||
{
|
||||
href: '/admin',
|
||||
label: t('admin.dashboard', 'Dashboard'),
|
||||
icon: 'chart-pie',
|
||||
section: 'admin-dashboard'
|
||||
},
|
||||
{
|
||||
href: '/admin/users',
|
||||
label: t('admin.users', 'Users'),
|
||||
icon: 'users',
|
||||
section: 'admin-users'
|
||||
},
|
||||
{
|
||||
href: '/admin/sessions',
|
||||
label: t('admin.sessions', 'Sessions'),
|
||||
icon: 'key',
|
||||
section: 'admin-sessions'
|
||||
},
|
||||
{
|
||||
href: '/admin/drives',
|
||||
label: t('admin.drives', 'Drives'),
|
||||
icon: 'hdd',
|
||||
section: 'admin-drives'
|
||||
}
|
||||
];
|
||||
if (serverConfig.features.external_mounts) {
|
||||
links.push({
|
||||
href: '/admin/mounts',
|
||||
label: t('admin.mounts', 'External Mounts'),
|
||||
icon: 'folder',
|
||||
section: 'admin-mounts'
|
||||
});
|
||||
}
|
||||
];
|
||||
links.push(
|
||||
{
|
||||
href: '/admin/oidc',
|
||||
label: t('admin.oidc', 'OIDC / SSO'),
|
||||
icon: 'building-shield',
|
||||
section: 'admin-oidc'
|
||||
},
|
||||
{
|
||||
href: '/admin/storage',
|
||||
label: t('admin.storage_tab', 'Storage'),
|
||||
icon: 'database',
|
||||
section: 'admin-storage'
|
||||
},
|
||||
{
|
||||
href: '/admin/smtp',
|
||||
label: t('admin.smtp', 'Email (SMTP)'),
|
||||
icon: 'envelope',
|
||||
section: 'admin-smtp'
|
||||
},
|
||||
{
|
||||
href: '/admin/plugins',
|
||||
label: t('admin.plugins', 'Plugins'),
|
||||
icon: 'layer-group',
|
||||
section: 'admin-plugins'
|
||||
},
|
||||
{
|
||||
href: '/admin/jobs',
|
||||
label: t('admin.jobs.tab', 'Background tasks'),
|
||||
icon: 'cogs',
|
||||
section: 'admin-jobs'
|
||||
}
|
||||
);
|
||||
return links;
|
||||
});
|
||||
|
||||
const isAdmin = $derived(session.user?.role === 'admin');
|
||||
|
||||
|
||||
@@ -36,7 +36,6 @@ const DEFAULT_FEATURES: ServerFeatures = {
|
||||
trash: true,
|
||||
search: true,
|
||||
sharing: true,
|
||||
quotas: false,
|
||||
music: true,
|
||||
places: true,
|
||||
faces: false,
|
||||
|
||||
@@ -83,6 +83,7 @@
|
||||
} from '$lib/api/types';
|
||||
import { shortUserAgent } from '$lib/utils/userAgent';
|
||||
import { triggerJob } from '$lib/api/endpoints/adminJobs';
|
||||
import { serverConfig } from '$lib/stores/serverConfig.svelte';
|
||||
import { serverStatus } from '$lib/stores/serverStatus.svelte';
|
||||
import AdminJobsPanel from '$lib/components/AdminJobsPanel.svelte';
|
||||
import Icon from '$lib/icons/Icon.svelte';
|
||||
@@ -224,6 +225,44 @@
|
||||
// `$effect` loop is even possible.
|
||||
const tab = $derived<Tab>(parseTab(page.params.tab));
|
||||
|
||||
/**
|
||||
* Feature-flag matrix for the dashboard "System" section.
|
||||
* Data-driven from `serverConfig.features` (populated at boot from
|
||||
* `GET /api/config`). Each entry becomes one card; adding a
|
||||
* feature server-side flows through this list automatically —
|
||||
* label lookup falls back to the raw key so a missing translation
|
||||
* won't hide the card.
|
||||
*
|
||||
* Uses `unknown` bracket-key reads (rather than a rigid mapping
|
||||
* over hard-coded keys) so the FE doesn't need a code change when
|
||||
* the backend adds a new feature flag. The i18n key namespace
|
||||
* `admin.features.<key>` keeps translations discoverable.
|
||||
*/
|
||||
interface FeatureRow {
|
||||
key: string;
|
||||
label: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
const FEATURE_LABELS: Record<string, string> = {
|
||||
message_bus: 'Message bus',
|
||||
trash: 'Trash',
|
||||
search: 'Search',
|
||||
sharing: 'Sharing',
|
||||
music: 'Music',
|
||||
places: 'Places (photo map)',
|
||||
faces: 'People (faces)',
|
||||
video_thumbnails: 'Video thumbnails',
|
||||
external_mounts: 'External mounts'
|
||||
};
|
||||
const featureRows = $derived.by<FeatureRow[]>(() => {
|
||||
const raw = serverConfig.features as unknown as Record<string, boolean>;
|
||||
return Object.entries(raw).map(([key, enabled]) => ({
|
||||
key,
|
||||
label: t(`admin.features.${key}`, FEATURE_LABELS[key] ?? key),
|
||||
enabled
|
||||
}));
|
||||
});
|
||||
|
||||
/**
|
||||
* Human-readable label for the current section — feeds the
|
||||
* page title (`Admin › Jobs · OxiCloud`) and the h1. Kept in
|
||||
@@ -1905,33 +1944,56 @@
|
||||
</span>
|
||||
{t('admin.online_sessions', 'Online sessions')}
|
||||
</div>
|
||||
<!-- WS-sessions card only rendered when the message bus is
|
||||
enabled server-side. With `OXICLOUD_MESSAGEBUS_ENABLE=false`
|
||||
the count is unconditionally 0, and showing "0 Live
|
||||
WS sessions" reads like a bug when the feature simply
|
||||
isn't running. `serverConfig.features.message_bus`
|
||||
comes from `/api/config` at boot. -->
|
||||
{#if serverConfig.features.message_bus}
|
||||
<div
|
||||
class="ds-card"
|
||||
title={t(
|
||||
'admin.active_ws_sessions_tooltip',
|
||||
'Currently-connected message-bus WebSocket sessions — one per open browser tab reaching a folder view'
|
||||
)}
|
||||
>
|
||||
<span class="ds-num ds-num--live">
|
||||
<span class="presence-dot presence-dot--online" aria-hidden="true"></span>
|
||||
{dashboard.active_ws_sessions}
|
||||
</span>
|
||||
{t('admin.active_ws_sessions', 'Live WS sessions')}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Section 3: System — deployment flags + version. -->
|
||||
<!-- Section 3: System — deployment flags + version.
|
||||
Feature cards are data-driven from `/api/config`
|
||||
(see `serverConfig.features` + `featureRows` derived).
|
||||
Auth / OIDC / version still come from the dashboard
|
||||
endpoint since those are per-deployment "system"
|
||||
concerns not exposed on the public config surface.
|
||||
Adding a new feature server-side flows into this grid
|
||||
automatically — no template change needed. -->
|
||||
<h2 class="ds-section-title">{t('admin.section_system', 'System')}</h2>
|
||||
<div class="ds-grid">
|
||||
<div class="ds-card">
|
||||
<span class="ds-flag" class:ds-flag--on={dashboard.auth_enabled}>
|
||||
{dashboard.auth_enabled
|
||||
? t('admin.enabled', 'Enabled')
|
||||
: t('admin.disabled', 'Disabled')}
|
||||
</span>
|
||||
{t('admin.auth', 'Authentication')}
|
||||
</div>
|
||||
<div class="ds-card">
|
||||
<span class="ds-flag" class:ds-flag--on={dashboard.oidc_configured}>
|
||||
{dashboard.oidc_configured ? t('admin.active', 'Active') : t('admin.off', 'Off')}
|
||||
</span>
|
||||
{t('admin.oidc', 'OIDC / SSO')}
|
||||
</div>
|
||||
<div class="ds-card">
|
||||
<span class="ds-flag" class:ds-flag--on={dashboard.quotas_enabled}>
|
||||
{dashboard.quotas_enabled
|
||||
? t('admin.enabled', 'Enabled')
|
||||
: t('admin.disabled', 'Disabled')}
|
||||
</span>
|
||||
{t('admin.quotas', 'Quotas')}
|
||||
</div>
|
||||
{#each featureRows as row (row.key)}
|
||||
<div class="ds-card" data-testid="admin-feature-card-{row.key}">
|
||||
<span class="ds-flag" class:ds-flag--on={row.enabled}>
|
||||
{row.enabled ? t('admin.enabled', 'Enabled') : t('admin.disabled', 'Disabled')}
|
||||
</span>
|
||||
{row.label}
|
||||
</div>
|
||||
{/each}
|
||||
<!-- Version card intentionally last — tertiary build
|
||||
metadata (like a footer), least useful at a glance
|
||||
compared to the feature-toggle cards above. -->
|
||||
<div class="ds-card">
|
||||
<span class="ds-num">v{dashboard.server_version}</span>{t('admin.version', 'Version')}
|
||||
</div>
|
||||
|
||||
@@ -89,9 +89,8 @@ const dashboard = {
|
||||
total_used_bytes: 100,
|
||||
total_quota_bytes: 1000,
|
||||
storage_usage_percent: 10,
|
||||
auth_enabled: true,
|
||||
oidc_configured: false,
|
||||
quotas_enabled: true,
|
||||
active_ws_sessions: 0,
|
||||
registration_enabled: true,
|
||||
users_over_80_percent: 0,
|
||||
users_over_quota: 0
|
||||
|
||||
@@ -161,9 +161,15 @@ pub struct DriveKindUsageDto {
|
||||
pub struct DashboardStatsDto {
|
||||
// System info
|
||||
pub server_version: String,
|
||||
pub auth_enabled: bool,
|
||||
pub oidc_configured: bool,
|
||||
pub quotas_enabled: bool,
|
||||
/// Currently-connected message-bus WebSocket sessions. One per
|
||||
/// browser tab that reached a folder view and hasn't closed the
|
||||
/// tab yet. Zero when `OXICLOUD_MESSAGEBUS_ENABLE=false`.
|
||||
/// Snapshot value — a subsequent request can see a different
|
||||
/// number if a connection opened/closed in between. Renders on
|
||||
/// the admin dashboard's "Live activity" section next to
|
||||
/// `online_sessions` (HTTP-driven distinct-user count).
|
||||
pub active_ws_sessions: u64,
|
||||
// ── User accounts (static breakdown of auth.users) ──
|
||||
// All four are counts of the SAME table under different
|
||||
// predicates. `active`, `admin`, `external` are all subsets of
|
||||
|
||||
+1
-10
@@ -2243,7 +2243,6 @@ impl MagicLinkConfig {
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FeaturesConfig {
|
||||
pub enable_auth: bool,
|
||||
pub enable_user_storage_quotas: bool,
|
||||
pub enable_file_sharing: bool,
|
||||
pub enable_trash: bool,
|
||||
pub enable_search: bool,
|
||||
@@ -2483,8 +2482,7 @@ impl Default for GrantCleanupConfig {
|
||||
impl Default for FeaturesConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enable_auth: true, // Enable authentication by default
|
||||
enable_user_storage_quotas: false,
|
||||
enable_auth: true, // Enable authentication by default
|
||||
enable_file_sharing: true, // Enable file sharing by default
|
||||
enable_trash: true, // Enable trash feature
|
||||
enable_search: true, // Enable search feature
|
||||
@@ -3353,13 +3351,6 @@ impl AppConfig {
|
||||
config.features.enable_auth = val;
|
||||
}
|
||||
|
||||
if let Ok(enable_user_storage_quotas) =
|
||||
env::var("OXICLOUD_ENABLE_USER_STORAGE_QUOTAS").map(|v| v.parse::<bool>())
|
||||
&& let Ok(val) = enable_user_storage_quotas
|
||||
{
|
||||
config.features.enable_user_storage_quotas = val;
|
||||
}
|
||||
|
||||
if let Ok(enable_file_sharing) =
|
||||
env::var("OXICLOUD_ENABLE_FILE_SHARING").map(|v| v.parse::<bool>())
|
||||
&& let Ok(val) = enable_file_sharing
|
||||
|
||||
@@ -2333,6 +2333,7 @@ impl AppServiceFactory {
|
||||
mount_router,
|
||||
bus,
|
||||
rt_ticket_store,
|
||||
active_ws_sessions: Arc::new(std::sync::atomic::AtomicUsize::new(0)),
|
||||
auth_service: auth_services,
|
||||
opaque_service,
|
||||
opaque_repo,
|
||||
@@ -3250,6 +3251,15 @@ pub struct AppState {
|
||||
pub rt_ticket_store: Arc<
|
||||
crate::infrastructure::services::rt_ticket_store::RtTicketStore,
|
||||
>,
|
||||
/// Live count of currently-connected message-bus WS sessions.
|
||||
/// Incremented on entry to `rt_ws::handle_session`, decremented
|
||||
/// via a `Drop` guard on ANY exit (normal close, error, panic
|
||||
/// unwind). Surfaced on the admin dashboard's "Live activity"
|
||||
/// section so operators can gauge WS pressure at a glance — one
|
||||
/// connection per open browser tab that reaches a folder view.
|
||||
/// Zero-cost when idle: `Relaxed` atomic load/store on the fd
|
||||
/// path, no allocation.
|
||||
pub active_ws_sessions: Arc<std::sync::atomic::AtomicUsize>,
|
||||
pub auth_service: Option<AuthServices>,
|
||||
/// OPAQUE aPAKE substrate (RFC 9807). Populated only when
|
||||
/// [`OpaqueConfig::effective_mode`] is not `Off` — that method
|
||||
|
||||
@@ -53,18 +53,38 @@ struct AdminUsersPageResponse {
|
||||
}
|
||||
|
||||
/// Admin API routes — all require admin role.
|
||||
pub fn admin_routes() -> Router<Arc<AppState>> {
|
||||
///
|
||||
/// Takes an `AppState` reference so feature-flag gating at route-
|
||||
/// registration time is possible (external-mounts admin surface
|
||||
/// mirrors the `OXICLOUD_ENABLE_EXTERNAL_MOUNTS` flag; when the flag
|
||||
/// is off the runtime `MountRegistry` isn't loaded, so exposing the
|
||||
/// CRUD would let admins configure mounts that silently don't work).
|
||||
pub fn admin_routes(app_state: &Arc<AppState>) -> Router<Arc<AppState>> {
|
||||
use super::admin_external_mounts as ext_mounts;
|
||||
Router::new()
|
||||
// External file mounts
|
||||
.route(
|
||||
"/external-mounts",
|
||||
get(ext_mounts::list_external_mounts).post(ext_mounts::create_external_mount),
|
||||
)
|
||||
.route(
|
||||
"/external-mounts/{id}",
|
||||
delete(ext_mounts::delete_external_mount),
|
||||
)
|
||||
let mut router = Router::new();
|
||||
|
||||
// External file mounts — CRUD registered only when the feature
|
||||
// is enabled server-side. Matches the pattern used for the
|
||||
// message bus (`/api/rt/ws` unmounted when
|
||||
// `OXICLOUD_MESSAGEBUS_ENABLE=false`): a disabled feature stays
|
||||
// fully hidden from the admin panel too. Without this guard the
|
||||
// admin panel would load, editor would save DB rows, but the
|
||||
// runtime `MountRegistry` (gated by the same flag in
|
||||
// `common/di.rs`) wouldn't load them — a silently-broken UX.
|
||||
// FE mirrors via `serverConfig.features.external_mounts`.
|
||||
if app_state.core.config.features.enable_external_mounts {
|
||||
router = router
|
||||
.route(
|
||||
"/external-mounts",
|
||||
get(ext_mounts::list_external_mounts).post(ext_mounts::create_external_mount),
|
||||
)
|
||||
.route(
|
||||
"/external-mounts/{id}",
|
||||
delete(ext_mounts::delete_external_mount),
|
||||
);
|
||||
}
|
||||
|
||||
router = router
|
||||
// OIDC settings
|
||||
.route("/settings/oidc", get(get_oidc_settings))
|
||||
.route("/settings/oidc", put(save_oidc_settings))
|
||||
@@ -207,7 +227,9 @@ pub fn admin_routes() -> Router<Arc<AppState>> {
|
||||
.route(
|
||||
"/drives/{id}/members/{kind}/{sid}",
|
||||
axum::routing::patch(update_drive_member_admin).delete(remove_drive_member_admin),
|
||||
)
|
||||
);
|
||||
|
||||
router
|
||||
}
|
||||
|
||||
// Every route under `/api/admin/*` is gated by the
|
||||
@@ -1054,9 +1076,13 @@ pub async fn get_dashboard_stats(
|
||||
|
||||
let stats = DashboardStatsDto {
|
||||
server_version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
auth_enabled: true,
|
||||
oidc_configured: auth_app.oidc_enabled(),
|
||||
quotas_enabled: true, // Feature flag could be checked here
|
||||
// Snapshot the current live-WS-session count. `Relaxed` because
|
||||
// the counter itself uses `Relaxed`; slight staleness on the
|
||||
// dashboard is fine — it's a UI gauge, not a control input.
|
||||
active_ws_sessions: state
|
||||
.active_ws_sessions
|
||||
.load(std::sync::atomic::Ordering::Relaxed) as u64,
|
||||
total_users: stats_row.get("total_users"),
|
||||
active_users: stats_row.get("active_users"),
|
||||
admin_users: stats_row.get("admin_users"),
|
||||
|
||||
@@ -82,9 +82,10 @@ pub struct FeaturesDto {
|
||||
/// File sharing (public share links + user-to-user grants). See
|
||||
/// `FeaturesConfig::enable_file_sharing`.
|
||||
pub sharing: bool,
|
||||
/// Per-user storage-quota enforcement on the upload path. See
|
||||
/// `FeaturesConfig::enable_user_storage_quotas`.
|
||||
pub quotas: bool,
|
||||
// NOTE: no `quotas` field. The former `enable_user_storage_quotas`
|
||||
// flag was removed (dead config with zero consumers). Actual
|
||||
// per-user quotas are set via the admin panel and resolved by
|
||||
// `StorageUsageService` unconditionally.
|
||||
/// Music player + playlists. See `FeaturesConfig::enable_music`.
|
||||
pub music: bool,
|
||||
/// Photo-map ("Places") tab. See `FeaturesConfig::enable_places`.
|
||||
@@ -122,7 +123,6 @@ pub async fn get_config(State(state): State<Arc<AppState>>) -> Json<ServerConfig
|
||||
trash: f.enable_trash,
|
||||
search: f.enable_search,
|
||||
sharing: f.enable_file_sharing,
|
||||
quotas: f.enable_user_storage_quotas,
|
||||
music: f.enable_music,
|
||||
places: f.enable_places,
|
||||
faces: f.enable_faces,
|
||||
|
||||
@@ -310,7 +310,34 @@ enum SessionOut {
|
||||
EvictFolders(Vec<Uuid>),
|
||||
}
|
||||
|
||||
/// RAII guard that decrements the live-session counter on ANY exit
|
||||
/// path from `handle_session` — clean close, protocol error, panic
|
||||
/// unwind, tokio task cancellation. Keeping the decrement in `Drop`
|
||||
/// (not scattered inline before every `break;` / `return;`) means we
|
||||
/// physically cannot leak a live count when a new exit branch is
|
||||
/// added. `Arc` so it stays valid even if the task is aborted from
|
||||
/// outside.
|
||||
struct SessionCountGuard(Arc<std::sync::atomic::AtomicUsize>);
|
||||
|
||||
impl Drop for SessionCountGuard {
|
||||
fn drop(&mut self) {
|
||||
self.0.fetch_sub(1, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_session(mut socket: WebSocket, caller_id: Uuid, state: Arc<AppState>) {
|
||||
// Live-session counter — incremented here, decremented on ANY
|
||||
// exit path via the `Drop` guard below (clean close, error,
|
||||
// panic unwind, task abort). Feeds the admin dashboard's
|
||||
// "Live activity" section. `Relaxed` because the counter is
|
||||
// approximate-by-design — a slightly stale read on the
|
||||
// dashboard is fine, and the atomic hop stays sub-nanosecond
|
||||
// on the hot path (session open / close).
|
||||
state
|
||||
.active_ws_sessions
|
||||
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
let _session_count_guard = SessionCountGuard(Arc::clone(&state.active_ws_sessions));
|
||||
|
||||
// Outbound queue — every path that produces a client-bound frame
|
||||
// enqueues here; the writer half of the select drains. Also
|
||||
// carries internal `EvictFolders` control signals from the
|
||||
|
||||
@@ -662,7 +662,7 @@ pub fn create_api_routes(app_state: &Arc<AppState>) -> Router<Arc<AppState>> {
|
||||
// gate automatically — implementors no longer have to remember
|
||||
// to call `require_admin(&state, &headers).await?` inline, and a
|
||||
// forgotten call can't silently expose a non-admin surface.
|
||||
let admin_router = admin_handler::admin_routes()
|
||||
let admin_router = admin_handler::admin_routes(app_state)
|
||||
.layer(axum::middleware::from_fn(
|
||||
crate::interfaces::middleware::auth::require_admin,
|
||||
))
|
||||
|
||||
Reference in New Issue
Block a user