diff --git a/src/app/api/admin/settings/route.ts b/src/app/api/admin/settings/route.ts index 51ad16dcc..7cbb95b6c 100644 --- a/src/app/api/admin/settings/route.ts +++ b/src/app/api/admin/settings/route.ts @@ -51,6 +51,13 @@ export const GET = apiHandler(async () => { reminderLateMinutes: settings?.reminderLateMinutes ?? 120, reminderMissedMinutes: settings?.reminderMissedMinutes ?? 240, moodLogGlobal: settings?.moodLogGlobal ?? true, + // Document vault limits (per-file cap + per-user quota default). BigInt + // column → Number for the JSON envelope (values are far below 2^53). + documentMaxFileBytes: settings?.documentMaxFileBytes ?? 26_214_400, + documentQuotaBytes: + settings?.documentQuotaBytes !== undefined + ? Number(settings.documentQuotaBytes) + : 1_073_741_824, // v1.4.25 W7 — null means "fall back to Europe/Berlin in the // resolver"; surfacing the raw value lets the admin UI render // an empty picker placeholder until they opt in. @@ -176,6 +183,17 @@ export const PUT = apiHandler(async (request: NextRequest) => { auditDetails.reminderMissedMinutes = data.reminderMissedMinutes; } + // Document vault limits (schema already bounds both; the upload-time + // resolver clamps the cap to the hard ceiling again as defence-in-depth). + if (data.documentMaxFileBytes !== undefined) { + updates.documentMaxFileBytes = data.documentMaxFileBytes; + auditDetails.documentMaxFileBytes = data.documentMaxFileBytes; + } + if (data.documentQuotaBytes !== undefined) { + updates.documentQuotaBytes = BigInt(data.documentQuotaBytes); + auditDetails.documentQuotaBytes = data.documentQuotaBytes; + } + // v1.4.25 W7 — server-default timezone for new signups. // Empty string clears the override (resolver falls back to // Europe/Berlin); a non-empty string must pass Intl validation @@ -240,6 +258,8 @@ export const PUT = apiHandler(async (request: NextRequest) => { reminderLateMinutes: settings.reminderLateMinutes, reminderMissedMinutes: settings.reminderMissedMinutes, moodLogGlobal: settings.moodLogGlobal, + documentMaxFileBytes: settings.documentMaxFileBytes, + documentQuotaBytes: Number(settings.documentQuotaBytes), defaultUserTimezone: settings.defaultUserTimezone, }); }); diff --git a/src/app/api/admin/users/[id]/route.ts b/src/app/api/admin/users/[id]/route.ts index 7270e4b93..ea105fcc4 100644 --- a/src/app/api/admin/users/[id]/route.ts +++ b/src/app/api/admin/users/[id]/route.ts @@ -19,6 +19,16 @@ const updateUserSchema = z.object({ // v1.23 — per-user "require a second factor" override. Effective requirement // is the OR of this and the instance-wide AppSettings.mfaRequired policy. mfaEnforced: z.boolean().optional(), + // Document vault — per-user storage-quota override in bytes. Null clears + // the override (the instance default applies again). Same bounds as the + // instance-wide setting. + documentQuotaBytes: z + .number() + .int() + .min(10_485_760) + .max(1_099_511_627_776) + .nullable() + .optional(), }); export const PUT = apiHandler( @@ -70,6 +80,12 @@ export const PUT = apiHandler( ...(parsed.data.mfaEnforced !== undefined && { mfaEnforced: parsed.data.mfaEnforced, }), + ...(parsed.data.documentQuotaBytes !== undefined && { + documentQuotaBytes: + parsed.data.documentQuotaBytes === null + ? null + : BigInt(parsed.data.documentQuotaBytes), + }), }, select: { id: true, @@ -78,6 +94,7 @@ export const PUT = apiHandler( role: true, createdAt: true, mfaEnforced: true, + documentQuotaBytes: true, }, }); @@ -87,6 +104,13 @@ export const PUT = apiHandler( details: { targetUserId: id, changes: parsed.data }, }); - return apiSuccess(updatedUser); + return apiSuccess({ + ...updatedUser, + // BigInt → Number for the JSON envelope; null = no override. + documentQuotaBytes: + updatedUser.documentQuotaBytes === null + ? null + : Number(updatedUser.documentQuotaBytes), + }); }, ); diff --git a/src/lib/validations/admin.ts b/src/lib/validations/admin.ts index 441935479..889bdfe42 100644 --- a/src/lib/validations/admin.ts +++ b/src/lib/validations/admin.ts @@ -67,6 +67,23 @@ export const adminSettingsSchema = z reminderLateMinutes: z.number().int().min(15).max(480).optional(), reminderMissedMinutes: z.number().int().min(30).max(720).optional(), moodLogGlobal: z.boolean().optional(), + // Document vault — the two admin-tunable limits. The per-file cap is + // bounded by the hard 100 MiB ceiling (single-shot GCM + bounded + // in-memory reads are load-bearing past that; the runtime resolver + // clamps again as defence-in-depth). The quota accepts up to 1 TiB — + // BYTEA rides pg_dump, so anything larger is an operator error. + documentMaxFileBytes: z + .number() + .int() + .min(1_048_576) + .max(104_857_600) + .optional(), + documentQuotaBytes: z + .number() + .int() + .min(10_485_760) + .max(1_099_511_627_776) + .optional(), // v1.4.25 W7 — server-wide default timezone for new signups // that don't carry a browser-detected zone. The route validates // against Intl.supportedValuesOf at runtime; this schema just