fix(quota): stop /me/quota leaking raw disk to guests
The per-user quota widget was shown to everyone and the /me/quota payload returned free_disk_bytes (raw server free space) and active_uploaders to any authenticated guest. Gate the widget to staff (host/admin) on the upload and account pages, and zero the server-wide telemetry fields for non-staff in the handler. Guests still get their own used/limit so enforcement stays transparent. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -14,7 +14,7 @@ use serde::Serialize;
|
|||||||
use crate::auth::middleware::AuthUser;
|
use crate::auth::middleware::AuthUser;
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
use crate::handlers::upload::compute_storage_quota;
|
use crate::handlers::upload::compute_storage_quota;
|
||||||
use crate::models::user::User;
|
use crate::models::user::{User, UserRole};
|
||||||
use crate::services::config;
|
use crate::services::config;
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
@@ -37,12 +37,26 @@ pub async fn get_quota(
|
|||||||
|
|
||||||
let estimate = compute_storage_quota(&state).await;
|
let estimate = compute_storage_quota(&state).await;
|
||||||
|
|
||||||
|
// Raw server telemetry (free disk, concurrent uploader count) is staff-only — it
|
||||||
|
// must never reach a guest, even though the guest upload UI no longer renders it.
|
||||||
|
// A guest still gets their own `used`/`limit` so enforcement stays transparent to
|
||||||
|
// the code paths that consume it; only the server-wide fields are zeroed.
|
||||||
|
let is_staff = matches!(auth.role, UserRole::Host | UserRole::Admin);
|
||||||
|
|
||||||
Ok(Json(QuotaDto {
|
Ok(Json(QuotaDto {
|
||||||
enabled: estimate.limit_bytes.is_some(),
|
enabled: estimate.limit_bytes.is_some(),
|
||||||
used_bytes: user.total_upload_bytes,
|
used_bytes: user.total_upload_bytes,
|
||||||
limit_bytes: estimate.limit_bytes,
|
limit_bytes: estimate.limit_bytes,
|
||||||
active_uploaders: estimate.active_uploaders,
|
active_uploaders: if is_staff {
|
||||||
free_disk_bytes: estimate.free_disk_bytes,
|
estimate.active_uploaders
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
},
|
||||||
|
free_disk_bytes: if is_staff {
|
||||||
|
estimate.free_disk_bytes
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
},
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -431,8 +431,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Per-user quota widget -->
|
<!-- Per-user quota widget — staff-only (host/admin); guests never see the
|
||||||
{#if $quotaStore.enabled && $quotaStore.limit_bytes != null}
|
server-derived storage figures. -->
|
||||||
|
{#if (role === 'host' || role === 'admin') && $quotaStore.enabled && $quotaStore.limit_bytes != null}
|
||||||
<div class="card p-5">
|
<div class="card p-5">
|
||||||
<h2
|
<h2
|
||||||
class="mb-2 text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400"
|
class="mb-2 text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { getToken } from '$lib/auth';
|
import { getToken, getRole } from '$lib/auth';
|
||||||
import { addToQueue, loadQueue } from '$lib/upload-queue';
|
import { addToQueue, loadQueue } from '$lib/upload-queue';
|
||||||
import { toast } from '$lib/toast-store';
|
import { toast } from '$lib/toast-store';
|
||||||
import { showBottomNav } from '$lib/ui-store';
|
import { showBottomNav } from '$lib/ui-store';
|
||||||
@@ -24,6 +24,11 @@
|
|||||||
|
|
||||||
const MAX_CAPTION_LENGTH = 2000;
|
const MAX_CAPTION_LENGTH = 2000;
|
||||||
|
|
||||||
|
// The storage widget is staff-only (host/admin). Guests never see server-derived
|
||||||
|
// storage figures — the backend also zeroes the raw-disk fields for non-staff, so
|
||||||
|
// this is UI-consistency on top of an API guarantee, not the security boundary.
|
||||||
|
const isStaff = getRole() === 'host' || getRole() === 'admin';
|
||||||
|
|
||||||
// Quick-tag chips derived from caption as the user types
|
// Quick-tag chips derived from caption as the user types
|
||||||
let captionTags = $derived.by(() => {
|
let captionTags = $derived.by(() => {
|
||||||
const matches = [...caption.matchAll(/#(\w+)/g)];
|
const matches = [...caption.matchAll(/#(\w+)/g)];
|
||||||
@@ -254,8 +259,9 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Per-user quota — hidden when admin disabled enforcement -->
|
<!-- Per-user quota — staff-only (never shown to guests), and also hidden when
|
||||||
{#if $quotaStore.enabled && $quotaStore.limit_bytes != null}
|
admin disabled enforcement. -->
|
||||||
|
{#if isStaff && $quotaStore.enabled && $quotaStore.limit_bytes != null}
|
||||||
<div class="px-4 pt-3 text-xs text-gray-500 dark:text-gray-400">
|
<div class="px-4 pt-3 text-xs text-gray-500 dark:text-gray-400">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<span
|
<span
|
||||||
|
|||||||
Reference in New Issue
Block a user