From 8a7b401b6eedf454abd23756340d93bd0f370337 Mon Sep 17 00:00:00 2001 From: Zarox28 <41804375+Zarox28@users.noreply.github.com> Date: Thu, 18 Dec 2025 10:50:34 +0100 Subject: [PATCH] feat: add maximum expiration value (#934) * feat: add maximum expiration value * fix(settings) * fix: add missing migration * Update src/lib/import/version3/validateExport.ts * fix: x-zipline-deletes-at with maxExpiration config --------- Co-authored-by: dicedtomato <35403473+diced@users.noreply.github.com> --- .../migration.sql | 2 + prisma/schema.prisma | 37 +++---- .../pages/serverSettings/parts/Files.tsx | 24 ++++- .../pages/upload/UploadOptionsButton.tsx | 101 +++++++++++------- src/lib/config/read/db.ts | 1 + src/lib/config/validate.ts | 1 + src/lib/uploader/parseHeaders.ts | 12 +++ src/server/routes/api/server/public.ts | 2 + .../routes/api/server/settings/index.ts | 32 +++++- 9 files changed, 153 insertions(+), 59 deletions(-) create mode 100644 prisma/migrations/20251209070242_file_max_expiration/migration.sql diff --git a/prisma/migrations/20251209070242_file_max_expiration/migration.sql b/prisma/migrations/20251209070242_file_max_expiration/migration.sql new file mode 100644 index 00000000..c55b55da --- /dev/null +++ b/prisma/migrations/20251209070242_file_max_expiration/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "public"."Zipline" ADD COLUMN "filesMaxExpiration" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index dbfc719c..ef0312c1 100755 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1,7 +1,7 @@ generator client { provider = "prisma-client" - output = "../src/prisma" - moduleFormat = "cjs" + output = "../src/prisma" + moduleFormat = "cjs" previewFeatures = ["queryCompiler", "driverAdapters"] } @@ -32,18 +32,19 @@ model Zipline { tasksThumbnailsInterval String @default("30m") tasksMetricsInterval String @default("30m") - filesRoute String @default("/u") - filesLength Int @default(6) - filesDefaultFormat String @default("random") - filesDisabledExtensions String[] - filesMaxFileSize String @default("100mb") - filesDefaultExpiration String? - filesAssumeMimetypes Boolean @default(false) - filesDefaultDateFormat String @default("YYYY-MM-DD_HH:mm:ss") - filesRemoveGpsMetadata Boolean @default(false) - filesRandomWordsNumAdjectives Int @default(2) - filesRandomWordsSeparator String @default("-") - filesDefaultCompressionFormat String? @default("jpg") + filesRoute String @default("/u") + filesLength Int @default(6) + filesDefaultFormat String @default("random") + filesDisabledExtensions String[] + filesMaxFileSize String @default("100mb") + filesDefaultExpiration String? + filesMaxExpiration String? + filesAssumeMimetypes Boolean @default(false) + filesDefaultDateFormat String @default("YYYY-MM-DD_HH:mm:ss") + filesRemoveGpsMetadata Boolean @default(false) + filesRandomWordsNumAdjectives Int @default(2) + filesRandomWordsSeparator String @default("-") + filesDefaultCompressionFormat String? @default("jpg") urlsRoute String @default("/go") urlsLength Int @default(6) @@ -64,7 +65,7 @@ model Zipline { featuresMetricsShowUserSpecific Boolean @default(true) featuresVersionChecking Boolean @default(true) - featuresVersionAPI String @default("https://zipline-version.diced.sh") + featuresVersionAPI String @default("https://zipline-version.diced.sh") invitesEnabled Boolean @default(true) invitesLength Int @default(6) @@ -141,7 +142,7 @@ model Zipline { pwaThemeColor String @default("#000000") pwaBackgroundColor String @default("#000000") - domains String[] @default([]) + domains String[] @default([]) } model User { @@ -295,8 +296,8 @@ model Folder { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - name String - public Boolean @default(false) + name String + public Boolean @default(false) allowUploads Boolean @default(false) files File[] diff --git a/src/components/pages/serverSettings/parts/Files.tsx b/src/components/pages/serverSettings/parts/Files.tsx index a709cf3d..e8b2ce62 100644 --- a/src/components/pages/serverSettings/parts/Files.tsx +++ b/src/components/pages/serverSettings/parts/Files.tsx @@ -30,6 +30,7 @@ export default function Files({ filesDisabledExtensions: string; filesMaxFileSize: string; filesDefaultExpiration: string | null; + filesMaxExpiration: string | null; filesAssumeMimetypes: boolean; filesDefaultDateFormat: string; filesRemoveGpsMetadata: boolean; @@ -44,6 +45,7 @@ export default function Files({ filesDisabledExtensions: '', filesMaxFileSize: '100mb', filesDefaultExpiration: '', + filesMaxExpiration: '', filesAssumeMimetypes: false, filesDefaultDateFormat: 'YYYY-MM-DD_HH:mm:ss', filesRemoveGpsMetadata: false, @@ -63,6 +65,12 @@ export default function Files({ values.filesDefaultExpiration = values.filesDefaultExpiration.trim(); } + if (values.filesMaxExpiration?.trim() === '' || !values.filesMaxExpiration) { + values.filesMaxExpiration = null; + } else { + values.filesMaxExpiration = values.filesMaxExpiration.trim(); + } + if (!values.filesDisabledExtensions) { // @ts-ignore values.filesDisabledExtensions = []; @@ -95,6 +103,7 @@ export default function Files({ filesDisabledExtensions: data.settings.filesDisabledExtensions.join(', ') ?? '', filesMaxFileSize: data.settings.filesMaxFileSize ?? '100mb', filesDefaultExpiration: data.settings.filesDefaultExpiration ?? '', + filesMaxExpiration: data.settings.filesMaxExpiration ?? '', filesAssumeMimetypes: data.settings.filesAssumeMimetypes ?? false, filesDefaultDateFormat: data.settings.filesDefaultDateFormat ?? 'YYYY-MM-DD_HH:mm:ss', filesRemoveGpsMetadata: data.settings.filesRemoveGpsMetadata ?? false, @@ -161,6 +170,13 @@ export default function Files({ {...form.getInputProps('filesMaxFileSize')} /> + + { + // Build the full option list, then clamp by config.files.maxExpiration if provided. + const opts = [ + { value: 'default', label: `Default (${config.files.defaultExpiration ?? 'never'})` }, + { value: 'never', label: 'Never' }, + { value: '5min', label: '5 minutes' }, + { value: '10min', label: '10 minutes' }, + { value: '15min', label: '15 minutes' }, + { value: '30min', label: '30 minutes' }, + { value: '1h', label: '1 hour' }, + { value: '2h', label: '2 hours' }, + { value: '3h', label: '3 hours' }, + { value: '4h', label: '4 hours' }, + { value: '5h', label: '5 hours' }, + { value: '6h', label: '6 hours' }, + { value: '8h', label: '8 hours' }, + { value: '12h', label: '12 hours' }, + { value: '1d', label: '1 day' }, + { value: '3d', label: '3 days' }, + { value: '5d', label: '5 days' }, + { value: '7d', label: '7 days' }, + { value: '1w', label: '1 week' }, + { value: '1.5w', label: '1.5 weeks' }, + { value: '2w', label: '2 weeks' }, + { value: '3w', label: '3 weeks' }, + { value: '30d', label: '1 month (30 days)' }, + { value: '45.625d', label: '1.5 months (~45 days)' }, + { value: '60d', label: '2 months (60 days)' }, + { value: '90d', label: '3 months (90 days)' }, + { value: '120d', label: '4 months (120 days)' }, + { value: '0.5 year', label: '6 months (0.5 year)' }, + { value: '1y', label: '1 year' }, + { + value: '_', + label: 'Need more freedom? Set an exact date and time through the API.', + disabled: true, + }, + ]; + + try { + const maxExp = settingsData?.files?.maxExpiration ?? null; + if (!maxExp) return opts; + + const maxMs = ms(String(maxExp) as any); + if (!maxMs || isNaN(Number(maxMs))) return opts; + + // Keep 'default' and 'never' always visible; clamp other duration options. + return opts.filter((o) => { + if (o.value === 'default' || o.value === 'never' || o.value === '_') return true; + const val = String(o.value); + const parsed = (ms as unknown as (v: string) => number)(val); + // Some labels like '45.625d' or '0.5 year' may be parseable; if not parseable, keep them to avoid excessive hiding. + if (!parsed || isNaN(Number(parsed))) return true; + return parsed <= Number(maxMs); + }); + } catch { + return opts; + } + })()} label={ <> Deletes at{' '} @@ -162,6 +186,11 @@ export default function UploadOptionsButton({ folder, numFiles }: { folder?: str {'.'} > )} + {settingsData?.files?.maxExpiration ? ( + + Note: maximum allowed expiration is {settingsData.files.maxExpiration}. + + ) : null} > } leftSection={} diff --git a/src/lib/config/read/db.ts b/src/lib/config/read/db.ts index d43be631..3f96675b 100644 --- a/src/lib/config/read/db.ts +++ b/src/lib/config/read/db.ts @@ -24,6 +24,7 @@ export const DATABASE_TO_PROP = { filesDisabledExtensions: 'files.disabledExtensions', filesMaxFileSize: 'files.maxFileSize', filesDefaultExpiration: 'files.defaultExpiration', + filesMaxExpiration: 'files.maxExpiration', filesAssumeMimetypes: 'files.assumeMimetypes', filesDefaultDateFormat: 'files.defaultDateFormat', filesRemoveGpsMetadata: 'files.removeGpsMetadata', diff --git a/src/lib/config/validate.ts b/src/lib/config/validate.ts index 3aa2d250..c93c28f5 100755 --- a/src/lib/config/validate.ts +++ b/src/lib/config/validate.ts @@ -117,6 +117,7 @@ export const schema = z.object({ disabledExtensions: z.array(z.string()).default([]), maxFileSize: z.string().default('100mb'), defaultExpiration: z.string().nullable().default(null), + maxExpiration: z.string().nullable().default(null), assumeMimetypes: z.boolean().default(false), defaultDateFormat: z.string().default('YYYY-MM-DD_HH:mm:ss'), removeGpsMetadata: z.boolean().default(false), diff --git a/src/lib/uploader/parseHeaders.ts b/src/lib/uploader/parseHeaders.ts index 0bbb52a6..9a5b525b 100755 --- a/src/lib/uploader/parseHeaders.ts +++ b/src/lib/uploader/parseHeaders.ts @@ -165,6 +165,18 @@ export function parseHeaders(headers: UploadHeaders, fileConfig: Config['files'] const expiresAt = parseExpiry(headers['x-zipline-deletes-at']); if (!expiresAt) return headerError('x-zipline-deletes-at', 'Invalid expiry date'); + if (fileConfig.maxExpiration) { + const maxExpiryTime = ms(fileConfig.maxExpiration as StringValue); + const requestedExpiryTime = expiresAt.getTime() - Date.now(); + + if (requestedExpiryTime > maxExpiryTime) { + return headerError( + 'x-zipline-deletes-at', + `Expiry exceeds maximum allowed expiration of ${fileConfig.maxExpiration}`, + ); + } + } + response.deletesAt = expiresAt; } } else { diff --git a/src/server/routes/api/server/public.ts b/src/server/routes/api/server/public.ts index cda787b4..50bfdb16 100644 --- a/src/server/routes/api/server/public.ts +++ b/src/server/routes/api/server/public.ts @@ -37,6 +37,7 @@ export type ApiServerPublicResponse = { files: { maxFileSize: string; defaultFormat: Config['files']['defaultFormat']; + maxExpiration?: string | null; }; chunks: Config['chunks']; firstSetup: boolean; @@ -75,6 +76,7 @@ export default fastifyPlugin( files: { maxFileSize: config.files.maxFileSize, defaultFormat: config.files.defaultFormat, + maxExpiration: config.files.maxExpiration, }, chunks: config.chunks, firstSetup: zipline.firstSetup, diff --git a/src/server/routes/api/server/settings/index.ts b/src/server/routes/api/server/settings/index.ts index c8071da7..56ad9255 100644 --- a/src/server/routes/api/server/settings/index.ts +++ b/src/server/routes/api/server/settings/index.ts @@ -150,6 +150,7 @@ export default fastifyPlugin( filesMaxFileSize: zBytes, filesDefaultExpiration: zMs.nullable(), + filesMaxExpiration: zMs.nullable(), filesAssumeMimetypes: z.boolean(), filesDefaultDateFormat: z.string(), filesRemoveGpsMetadata: z.boolean(), @@ -397,13 +398,42 @@ export default fastifyPlugin( }); } + const parsed = { ...result.data }; + + try { + if (parsed.filesDefaultExpiration && parsed.filesMaxExpiration) { + const parsedDefault = ms(String(parsed.filesDefaultExpiration) as StringValue) as number; + const parsedMaxAfter = ms(String(parsed.filesMaxExpiration) as StringValue) as number; + if ( + !isNaN(Number(parsedDefault)) && + !isNaN(Number(parsedMaxAfter)) && + parsedDefault > parsedMaxAfter + ) { + // set default to the provided max value + parsed.filesDefaultExpiration = String(parsed.filesMaxExpiration); + } + } + } catch (e) { + // If normalization fails, log the error and proceed with original values. + logger.debug('error normalizing expiration settings', { err: e }); + } + + // Use the keys present in the Zod-parsed object as the allowed set. + const allowedFromSchema = new Set(Object.keys(parsed)); + const filteredData = Object.fromEntries( + Object.entries(parsed).filter(([k]) => allowedFromSchema.has(k)), + ); + + if (Object.keys(filteredData).length === 0) { + return res.status(400).send({ statusCode: 400, message: 'No valid fields to update' }); + } const newSettings = await prisma.zipline.update({ where: { id: settings.id, }, // @ts-ignore data: { - ...result.data, + ...filteredData, }, omit: { createdAt: true,