From 6c94abc73b222a9fa700d534a84d3a398d1136d5 Mon Sep 17 00:00:00 2001 From: diced Date: Wed, 20 May 2026 10:11:10 -0700 Subject: [PATCH] feat: mimetype overhauls --- .../migration.sql | 3 ++ prisma/schema.prisma | 2 + .../pages/serverSettings/parts/Files.tsx | 42 +++++++++++-------- .../pages/serverSettings/settingsOnSubmit.tsx | 16 +++++++ src/lib/api/errors.ts | 1 + src/lib/config/read/db.ts | 2 + src/lib/config/read/env.ts | 2 + src/lib/config/validate.ts | 4 ++ .../routes/api/server/settings/index.ts | 5 ++- src/server/routes/api/upload/index.ts | 9 +++- 10 files changed, 66 insertions(+), 20 deletions(-) create mode 100644 prisma/migrations/20260520163136_files_disabled_types/migration.sql diff --git a/prisma/migrations/20260520163136_files_disabled_types/migration.sql b/prisma/migrations/20260520163136_files_disabled_types/migration.sql new file mode 100644 index 00000000..b4d5568b --- /dev/null +++ b/prisma/migrations/20260520163136_files_disabled_types/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "public"."Zipline" ADD COLUMN "filesDisabledTypes" TEXT[] DEFAULT ARRAY[]::TEXT[], +ADD COLUMN "filesDisabledTypesDefault" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 198a60da..fd155655 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -36,6 +36,8 @@ model Zipline { filesRoute String @default("/u") filesLength Int @default(6) filesDefaultFormat String @default("random") + filesDisabledTypes String[] @default([]) + filesDisabledTypesDefault String? filesDisabledExtensions String[] filesMaxFileSize String @default("100mb") filesDefaultExpiration String? diff --git a/src/components/pages/serverSettings/parts/Files.tsx b/src/components/pages/serverSettings/parts/Files.tsx index c1cbd6ee..3e1e34db 100644 --- a/src/components/pages/serverSettings/parts/Files.tsx +++ b/src/components/pages/serverSettings/parts/Files.tsx @@ -3,7 +3,7 @@ import { Button, LoadingOverlay, NumberInput, Select, Stack, Switch, TextInput } import { useForm } from '@mantine/form'; import { IconDeviceFloppy } from '@tabler/icons-react'; import { useNavigate } from 'react-router-dom'; -import { settingsOnSubmit } from '../settingsOnSubmit'; +import { checkCommaArray, settingsOnSubmit } from '../settingsOnSubmit'; import useServerSettings from '../useServerSettings'; export default function Files() { @@ -25,6 +25,8 @@ function Form({ data, isLoading }: { data: Response['/api/server/settings']; isL filesRoute: data.settings.filesRoute, filesLength: data.settings.filesLength, filesDefaultFormat: data.settings.filesDefaultFormat, + filesDisabledTypes: data.settings.filesDisabledTypes.join(', '), + filesDisabledTypesDefault: data.settings.filesDisabledTypesDefault, filesDisabledExtensions: data.settings.filesDisabledExtensions.join(', '), filesMaxFileSize: data.settings.filesMaxFileSize, filesDefaultExpiration: data.settings.filesDefaultExpiration, @@ -55,25 +57,17 @@ function Form({ data, isLoading }: { data: Response['/api/server/settings']; isL values.filesMaxExpiration = values.filesMaxExpiration.trim(); } - if (!values.filesDisabledExtensions) { - // @ts-ignore - values.filesDisabledExtensions = []; - } else if ( - values.filesDisabledExtensions && - typeof values.filesDisabledExtensions === 'string' && - values.filesDisabledExtensions.trim() === '' - ) { - // @ts-ignore - values.filesDisabledExtensions = []; + if (values.filesDisabledTypesDefault?.trim() === '' || !values.filesDisabledTypesDefault) { + values.filesDisabledTypesDefault = null; } else { - if (!Array.isArray(values.filesDisabledExtensions)) - // @ts-ignore - values.filesDisabledExtensions = values.filesDisabledExtensions - .split(',') - .map((ext) => ext.trim()) - .filter((ext) => ext !== ''); + values.filesDisabledTypesDefault = values.filesDisabledTypesDefault.trim(); } + // @ts-ignore + values.filesDisabledExtensions = checkCommaArray(values.filesDisabledExtensions); + // @ts-ignore + values.filesDisabledTypes = checkCommaArray(values.filesDisabledTypes); + return settingsOnSubmit(navigate, form)(values); }; @@ -86,6 +80,20 @@ function Form({ data, isLoading }: { data: Response['/api/server/settings']; isL {...form.getInputProps('filesAssumeMimetypes', { type: 'checkbox' })} /> + + + + x.trim()) + .filter((x) => x !== ''); + + if (Array.isArray(value)) return value.map((x) => String(x).trim()).filter((x) => x !== ''); + + return []; +} + export function settingsOnSubmit(navigate: NavigateFunction, form: ReturnType>) { return async (values: unknown) => { const { data, error } = await fetchApi( diff --git a/src/lib/api/errors.ts b/src/lib/api/errors.ts index e7445b9c..fd53fe97 100644 --- a/src/lib/api/errors.ts +++ b/src/lib/api/errors.ts @@ -63,6 +63,7 @@ export const API_ERRORS = { 1062: 'No files in multipart/form-data request', 1063: 'Already linked to this OAuth provider', 1064: 'Invalid OAuth state parameter', + 1065: 'Invalid MIME type', // 2xxx, session errors 2000: 'Invalid login session', diff --git a/src/lib/config/read/db.ts b/src/lib/config/read/db.ts index 62333fcf..e63e25bd 100644 --- a/src/lib/config/read/db.ts +++ b/src/lib/config/read/db.ts @@ -22,6 +22,8 @@ export const DATABASE_TO_PROP = { filesRoute: 'files.route', filesLength: 'files.length', filesDefaultFormat: 'files.defaultFormat', + filesDisabledTypes: 'files.disabledTypes', + filesDisabledTypesDefault: 'files.disabledTypesDefault', filesDisabledExtensions: 'files.disabledExtensions', filesMaxFileSize: 'files.maxFileSize', filesDefaultExpiration: 'files.defaultExpiration', diff --git a/src/lib/config/read/env.ts b/src/lib/config/read/env.ts index 4bbc362a..041a8531 100644 --- a/src/lib/config/read/env.ts +++ b/src/lib/config/read/env.ts @@ -56,6 +56,8 @@ export const ENVS = [ env('files.route', 'FILES_ROUTE', 'string', true), env('files.length', 'FILES_LENGTH', 'number', true), env('files.defaultFormat', 'FILES_DEFAULT_FORMAT', 'string', true), + env('files.disabledTypes', 'FILES_DISABLED_TYPES', 'string[]', true), + env('files.disabledTypesDefault', 'FILES_DISABLED_TYPES_DEFAULT', 'string', true), env('files.disabledExtensions', 'FILES_DISABLED_EXTENSIONS', 'string[]', true), env('files.maxFileSize', 'FILES_MAX_FILE_SIZE', 'string', true), env('files.defaultExpiration', 'FILES_DEFAULT_EXPIRATION', 'string', true), diff --git a/src/lib/config/validate.ts b/src/lib/config/validate.ts index 1605a1de..544e55a8 100644 --- a/src/lib/config/validate.ts +++ b/src/lib/config/validate.ts @@ -18,6 +18,8 @@ declare global { } } +export const MIME_REGEX = /^[a-zA-Z0-9!#$&^_\-\+.]+\/[a-zA-Z0-9!#$&^_\-\+.]+$/gi; + export const MAX_SAFE_TIMEOUT_MS = 2147483647; export function validateInterval(value: string): boolean { @@ -131,6 +133,8 @@ export const schema = z.object({ route: z.string().startsWith('/').min(1).trim().toLowerCase().default('/u'), length: z.number().default(6), defaultFormat: z.enum(['random', 'date', 'uuid', 'name', 'gfycat', 'random-words']).default('random'), + disabledTypes: z.array(z.string().regex(MIME_REGEX, 'Invalid MIME type format')).default([]), + disabledTypesDefault: z.string().nullable().default(null), disabledExtensions: z.array(z.string()).default([]), maxFileSize: z.string().default('100mb'), defaultExpiration: z.string().nullable().default(null), diff --git a/src/server/routes/api/server/settings/index.ts b/src/server/routes/api/server/settings/index.ts index e61c9e45..fa921ab7 100644 --- a/src/server/routes/api/server/settings/index.ts +++ b/src/server/routes/api/server/settings/index.ts @@ -4,7 +4,7 @@ import { checkOutput, COMPRESS_TYPES } from '@/lib/compress'; import { reloadSettings } from '@/lib/config'; import type { readDatabaseSettings } from '@/lib/config/read/db'; import { safeConfig } from '@/lib/config/safe'; -import { MAX_SAFE_TIMEOUT_MS } from '@/lib/config/validate'; +import { MAX_SAFE_TIMEOUT_MS, MIME_REGEX } from '@/lib/config/validate'; import { prisma } from '@/lib/db'; import { log } from '@/lib/logger'; import { secondlyRatelimit } from '@/lib/ratelimits'; @@ -181,6 +181,8 @@ export default typedPlugin( 'Provided route is reserved', ), filesLength: z.number().min(1).max(64), + filesDisabledTypes: z.array(z.string().regex(MIME_REGEX, 'Invalid MIME type')), + filesDisabledTypesDefault: z.string().regex(MIME_REGEX, 'Invalid MIME type').nullable(), filesDefaultFormat: z.enum(['random', 'date', 'uuid', 'name', 'gfycat']), filesDisabledExtensions: z .union([ @@ -191,7 +193,6 @@ export default typedPlugin( typeof value === 'string' ? value.split(',').map((ext) => ext.trim()) : value, ), filesMaxFileSize: zBytes, - filesDefaultExpiration: zMs.nullable(), filesMaxExpiration: zMs.nullable(), filesAssumeMimetypes: z.boolean(), diff --git a/src/server/routes/api/upload/index.ts b/src/server/routes/api/upload/index.ts index 3dc973c7..6e774729 100644 --- a/src/server/routes/api/upload/index.ts +++ b/src/server/routes/api/upload/index.ts @@ -155,7 +155,8 @@ export default typedPlugin( const { fileName } = nameResult; // determine mimetype - const { mimetype, assumed } = await getMimetype(file.mimetype, extension); + const { assumed, ...mimeRes } = await getMimetype(file.mimetype, extension); + let mimetype = mimeRes.mimetype; if (config.files.assumeMimetypes) { response.assumedMimetypes![i] = assumed; @@ -170,6 +171,12 @@ export default typedPlugin( } } + if (config.files.disabledTypes.includes(mimetype.trim().toLowerCase())) { + console.log(mimetype, config.files.disabledTypesDefault); + if (config.files.disabledTypesDefault) mimetype = config.files.disabledTypesDefault; + else throw new ApiError(1065, `file[${i}]: File type ${mimetype} is not allowed`); + } + // compress the image if requested let compressed; if (mimetype.startsWith('image/') && options.imageCompression) {