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:
Edouard Vanbelle
2026-09-11 13:02:33 +02:00
parent 5083eaeaba
commit 8d1fde2747
16 changed files with 303 additions and 134 deletions
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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)
# -----------------------------------------------------------------------------
+6 -2
View File
@@ -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;
+4 -1
View File
@@ -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;
+75 -61
View File
@@ -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,
+79 -17
View File
@@ -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
+8 -2
View File
@@ -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
View File
@@ -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
+10
View File
@@ -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
+40 -14
View File
@@ -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,
+27
View File
@@ -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
+1 -1
View File
@@ -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,
))