diff --git a/docs/config/deployment.md b/docs/config/deployment.md index ab287376..990136aa 100644 --- a/docs/config/deployment.md +++ b/docs/config/deployment.md @@ -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 | diff --git a/docs/config/env.md b/docs/config/env.md index 9e7df501..e7703a49 100644 --- a/docs/config/env.md +++ b/docs/config/env.md @@ -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. | diff --git a/example.env b/example.env index bffef96d..f8373e6a 100644 --- a/example.env +++ b/example.env @@ -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) # ----------------------------------------------------------------------------- diff --git a/frontend/src/lib/api/endpoints/admin.ts b/frontend/src/lib/api/endpoints/admin.ts index 9171616f..0c5c4807 100644 --- a/frontend/src/lib/api/endpoints/admin.ts +++ b/frontend/src/lib/api/endpoints/admin.ts @@ -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; diff --git a/frontend/src/lib/api/types.ts b/frontend/src/lib/api/types.ts index 9432e3df..0f928a27 100644 --- a/frontend/src/lib/api/types.ts +++ b/frontend/src/lib/api/types.ts @@ -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; diff --git a/frontend/src/lib/components/AppShell.svelte b/frontend/src/lib/components/AppShell.svelte index 74f21ad1..2e18ab21 100644 --- a/frontend/src/lib/components/AppShell.svelte +++ b/frontend/src/lib/components/AppShell.svelte @@ -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(() => { + 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'); diff --git a/frontend/src/lib/stores/serverConfig.svelte.ts b/frontend/src/lib/stores/serverConfig.svelte.ts index 9c71791a..29362bc2 100644 --- a/frontend/src/lib/stores/serverConfig.svelte.ts +++ b/frontend/src/lib/stores/serverConfig.svelte.ts @@ -36,7 +36,6 @@ const DEFAULT_FEATURES: ServerFeatures = { trash: true, search: true, sharing: true, - quotas: false, music: true, places: true, faces: false, diff --git a/frontend/src/routes/admin/[[tab]]/+page.svelte b/frontend/src/routes/admin/[[tab]]/+page.svelte index e461888c..445d14fb 100644 --- a/frontend/src/routes/admin/[[tab]]/+page.svelte +++ b/frontend/src/routes/admin/[[tab]]/+page.svelte @@ -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(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.` keeps translations discoverable. + */ + interface FeatureRow { + key: string; + label: string; + enabled: boolean; + } + const FEATURE_LABELS: Record = { + 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(() => { + const raw = serverConfig.features as unknown as Record; + 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 @@ {t('admin.online_sessions', 'Online sessions')} + + {#if serverConfig.features.message_bus} +
+ + + {dashboard.active_ws_sessions} + + {t('admin.active_ws_sessions', 'Live WS sessions')} +
+ {/if} - +

{t('admin.section_system', 'System')}

-
- - {dashboard.auth_enabled - ? t('admin.enabled', 'Enabled') - : t('admin.disabled', 'Disabled')} - - {t('admin.auth', 'Authentication')} -
{dashboard.oidc_configured ? t('admin.active', 'Active') : t('admin.off', 'Off')} {t('admin.oidc', 'OIDC / SSO')}
-
- - {dashboard.quotas_enabled - ? t('admin.enabled', 'Enabled') - : t('admin.disabled', 'Disabled')} - - {t('admin.quotas', 'Quotas')} -
+ {#each featureRows as row (row.key)} +
+ + {row.enabled ? t('admin.enabled', 'Enabled') : t('admin.disabled', 'Disabled')} + + {row.label} +
+ {/each} +
v{dashboard.server_version}{t('admin.version', 'Version')}
diff --git a/frontend/src/routes/admin/[[tab]]/page.test.ts b/frontend/src/routes/admin/[[tab]]/page.test.ts index 64ff709a..6b79d042 100644 --- a/frontend/src/routes/admin/[[tab]]/page.test.ts +++ b/frontend/src/routes/admin/[[tab]]/page.test.ts @@ -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 diff --git a/src/application/dtos/settings_dto.rs b/src/application/dtos/settings_dto.rs index 7917d519..cd881ff4 100644 --- a/src/application/dtos/settings_dto.rs +++ b/src/application/dtos/settings_dto.rs @@ -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 diff --git a/src/common/config.rs b/src/common/config.rs index 6d5c9105..c6b88781 100644 --- a/src/common/config.rs +++ b/src/common/config.rs @@ -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::()) - && 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::()) && let Ok(val) = enable_file_sharing diff --git a/src/common/di.rs b/src/common/di.rs index 11a24558..fec2e0ad 100644 --- a/src/common/di.rs +++ b/src/common/di.rs @@ -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, pub auth_service: Option, /// OPAQUE aPAKE substrate (RFC 9807). Populated only when /// [`OpaqueConfig::effective_mode`] is not `Off` — that method diff --git a/src/interfaces/api/handlers/admin_handler.rs b/src/interfaces/api/handlers/admin_handler.rs index 20b03f45..f516f291 100644 --- a/src/interfaces/api/handlers/admin_handler.rs +++ b/src/interfaces/api/handlers/admin_handler.rs @@ -53,18 +53,38 @@ struct AdminUsersPageResponse { } /// Admin API routes — all require admin role. -pub fn admin_routes() -> Router> { +/// +/// 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) -> Router> { 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> { .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"), diff --git a/src/interfaces/api/handlers/config_handler.rs b/src/interfaces/api/handlers/config_handler.rs index 56eafc6b..798f600e 100644 --- a/src/interfaces/api/handlers/config_handler.rs +++ b/src/interfaces/api/handlers/config_handler.rs @@ -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>) -> Json), } +/// 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); + +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) { + // 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 diff --git a/src/interfaces/api/routes.rs b/src/interfaces/api/routes.rs index fc092204..84a4c488 100644 --- a/src/interfaces/api/routes.rs +++ b/src/interfaces/api/routes.rs @@ -662,7 +662,7 @@ pub fn create_api_routes(app_state: &Arc) -> Router> { // 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, ))