feat: mimetype overhauls

This commit is contained in:
diced
2026-05-20 10:11:10 -07:00
parent 8fb21988a7
commit 6c94abc73b
10 changed files with 66 additions and 20 deletions
@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "public"."Zipline" ADD COLUMN "filesDisabledTypes" TEXT[] DEFAULT ARRAY[]::TEXT[],
ADD COLUMN "filesDisabledTypesDefault" TEXT;
+2
View File
@@ -36,6 +36,8 @@ model Zipline {
filesRoute String @default("/u") filesRoute String @default("/u")
filesLength Int @default(6) filesLength Int @default(6)
filesDefaultFormat String @default("random") filesDefaultFormat String @default("random")
filesDisabledTypes String[] @default([])
filesDisabledTypesDefault String?
filesDisabledExtensions String[] filesDisabledExtensions String[]
filesMaxFileSize String @default("100mb") filesMaxFileSize String @default("100mb")
filesDefaultExpiration String? filesDefaultExpiration String?
@@ -3,7 +3,7 @@ import { Button, LoadingOverlay, NumberInput, Select, Stack, Switch, TextInput }
import { useForm } from '@mantine/form'; import { useForm } from '@mantine/form';
import { IconDeviceFloppy } from '@tabler/icons-react'; import { IconDeviceFloppy } from '@tabler/icons-react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { settingsOnSubmit } from '../settingsOnSubmit'; import { checkCommaArray, settingsOnSubmit } from '../settingsOnSubmit';
import useServerSettings from '../useServerSettings'; import useServerSettings from '../useServerSettings';
export default function Files() { export default function Files() {
@@ -25,6 +25,8 @@ function Form({ data, isLoading }: { data: Response['/api/server/settings']; isL
filesRoute: data.settings.filesRoute, filesRoute: data.settings.filesRoute,
filesLength: data.settings.filesLength, filesLength: data.settings.filesLength,
filesDefaultFormat: data.settings.filesDefaultFormat, filesDefaultFormat: data.settings.filesDefaultFormat,
filesDisabledTypes: data.settings.filesDisabledTypes.join(', '),
filesDisabledTypesDefault: data.settings.filesDisabledTypesDefault,
filesDisabledExtensions: data.settings.filesDisabledExtensions.join(', '), filesDisabledExtensions: data.settings.filesDisabledExtensions.join(', '),
filesMaxFileSize: data.settings.filesMaxFileSize, filesMaxFileSize: data.settings.filesMaxFileSize,
filesDefaultExpiration: data.settings.filesDefaultExpiration, filesDefaultExpiration: data.settings.filesDefaultExpiration,
@@ -55,25 +57,17 @@ function Form({ data, isLoading }: { data: Response['/api/server/settings']; isL
values.filesMaxExpiration = values.filesMaxExpiration.trim(); values.filesMaxExpiration = values.filesMaxExpiration.trim();
} }
if (!values.filesDisabledExtensions) { if (values.filesDisabledTypesDefault?.trim() === '' || !values.filesDisabledTypesDefault) {
// @ts-ignore values.filesDisabledTypesDefault = null;
values.filesDisabledExtensions = [];
} else if (
values.filesDisabledExtensions &&
typeof values.filesDisabledExtensions === 'string' &&
values.filesDisabledExtensions.trim() === ''
) {
// @ts-ignore
values.filesDisabledExtensions = [];
} else { } else {
if (!Array.isArray(values.filesDisabledExtensions)) values.filesDisabledTypesDefault = values.filesDisabledTypesDefault.trim();
// @ts-ignore
values.filesDisabledExtensions = values.filesDisabledExtensions
.split(',')
.map((ext) => ext.trim())
.filter((ext) => ext !== '');
} }
// @ts-ignore
values.filesDisabledExtensions = checkCommaArray(values.filesDisabledExtensions);
// @ts-ignore
values.filesDisabledTypes = checkCommaArray(values.filesDisabledTypes);
return settingsOnSubmit(navigate, form)(values); return settingsOnSubmit(navigate, form)(values);
}; };
@@ -86,6 +80,20 @@ function Form({ data, isLoading }: { data: Response['/api/server/settings']; isL
{...form.getInputProps('filesAssumeMimetypes', { type: 'checkbox' })} {...form.getInputProps('filesAssumeMimetypes', { type: 'checkbox' })}
/> />
<TextInput
label='Disabled Types'
description='Mimetypes to disable, separated by commas. It is recommended to have the Assume Mimetypes setting enabled if you are disabling mimetypes, as this will also block files with the corresponding extensions.'
placeholder='text/html, application/javascript'
{...form.getInputProps('filesDisabledTypes')}
/>
<TextInput
label='Default MIME for Disabled Types'
description='The default MIME type to use for disabled types. Leave blank to completely block disabled types.'
placeholder='application/octet-stream'
{...form.getInputProps('filesDisabledTypesDefault')}
/>
<Switch <Switch
label='Remove GPS Metadata' label='Remove GPS Metadata'
description='Remove GPS metadata from files.' description='Remove GPS metadata from files.'
@@ -6,6 +6,22 @@ import { IconDeviceFloppy } from '@tabler/icons-react';
import { useForm } from '@mantine/form'; import { useForm } from '@mantine/form';
import { NavigateFunction } from 'react-router-dom'; import { NavigateFunction } from 'react-router-dom';
export function checkCommaArray(value: unknown): string[] {
if (!value) return [];
if (value && typeof value === 'string' && value.trim() === '') return [];
if (!Array.isArray(value) && typeof value === 'string')
return value
.split(',')
.map((x) => 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<typeof useForm<any>>) { export function settingsOnSubmit(navigate: NavigateFunction, form: ReturnType<typeof useForm<any>>) {
return async (values: unknown) => { return async (values: unknown) => {
const { data, error } = await fetchApi<Response['/api/server/settings']>( const { data, error } = await fetchApi<Response['/api/server/settings']>(
+1
View File
@@ -63,6 +63,7 @@ export const API_ERRORS = {
1062: 'No files in multipart/form-data request', 1062: 'No files in multipart/form-data request',
1063: 'Already linked to this OAuth provider', 1063: 'Already linked to this OAuth provider',
1064: 'Invalid OAuth state parameter', 1064: 'Invalid OAuth state parameter',
1065: 'Invalid MIME type',
// 2xxx, session errors // 2xxx, session errors
2000: 'Invalid login session', 2000: 'Invalid login session',
+2
View File
@@ -22,6 +22,8 @@ export const DATABASE_TO_PROP = {
filesRoute: 'files.route', filesRoute: 'files.route',
filesLength: 'files.length', filesLength: 'files.length',
filesDefaultFormat: 'files.defaultFormat', filesDefaultFormat: 'files.defaultFormat',
filesDisabledTypes: 'files.disabledTypes',
filesDisabledTypesDefault: 'files.disabledTypesDefault',
filesDisabledExtensions: 'files.disabledExtensions', filesDisabledExtensions: 'files.disabledExtensions',
filesMaxFileSize: 'files.maxFileSize', filesMaxFileSize: 'files.maxFileSize',
filesDefaultExpiration: 'files.defaultExpiration', filesDefaultExpiration: 'files.defaultExpiration',
+2
View File
@@ -56,6 +56,8 @@ export const ENVS = [
env('files.route', 'FILES_ROUTE', 'string', true), env('files.route', 'FILES_ROUTE', 'string', true),
env('files.length', 'FILES_LENGTH', 'number', true), env('files.length', 'FILES_LENGTH', 'number', true),
env('files.defaultFormat', 'FILES_DEFAULT_FORMAT', 'string', 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.disabledExtensions', 'FILES_DISABLED_EXTENSIONS', 'string[]', true),
env('files.maxFileSize', 'FILES_MAX_FILE_SIZE', 'string', true), env('files.maxFileSize', 'FILES_MAX_FILE_SIZE', 'string', true),
env('files.defaultExpiration', 'FILES_DEFAULT_EXPIRATION', 'string', true), env('files.defaultExpiration', 'FILES_DEFAULT_EXPIRATION', 'string', true),
+4
View File
@@ -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 const MAX_SAFE_TIMEOUT_MS = 2147483647;
export function validateInterval(value: string): boolean { 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'), route: z.string().startsWith('/').min(1).trim().toLowerCase().default('/u'),
length: z.number().default(6), length: z.number().default(6),
defaultFormat: z.enum(['random', 'date', 'uuid', 'name', 'gfycat', 'random-words']).default('random'), 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([]), disabledExtensions: z.array(z.string()).default([]),
maxFileSize: z.string().default('100mb'), maxFileSize: z.string().default('100mb'),
defaultExpiration: z.string().nullable().default(null), defaultExpiration: z.string().nullable().default(null),
@@ -4,7 +4,7 @@ import { checkOutput, COMPRESS_TYPES } from '@/lib/compress';
import { reloadSettings } from '@/lib/config'; import { reloadSettings } from '@/lib/config';
import type { readDatabaseSettings } from '@/lib/config/read/db'; import type { readDatabaseSettings } from '@/lib/config/read/db';
import { safeConfig } from '@/lib/config/safe'; 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 { prisma } from '@/lib/db';
import { log } from '@/lib/logger'; import { log } from '@/lib/logger';
import { secondlyRatelimit } from '@/lib/ratelimits'; import { secondlyRatelimit } from '@/lib/ratelimits';
@@ -181,6 +181,8 @@ export default typedPlugin(
'Provided route is reserved', 'Provided route is reserved',
), ),
filesLength: z.number().min(1).max(64), 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']), filesDefaultFormat: z.enum(['random', 'date', 'uuid', 'name', 'gfycat']),
filesDisabledExtensions: z filesDisabledExtensions: z
.union([ .union([
@@ -191,7 +193,6 @@ export default typedPlugin(
typeof value === 'string' ? value.split(',').map((ext) => ext.trim()) : value, typeof value === 'string' ? value.split(',').map((ext) => ext.trim()) : value,
), ),
filesMaxFileSize: zBytes, filesMaxFileSize: zBytes,
filesDefaultExpiration: zMs.nullable(), filesDefaultExpiration: zMs.nullable(),
filesMaxExpiration: zMs.nullable(), filesMaxExpiration: zMs.nullable(),
filesAssumeMimetypes: z.boolean(), filesAssumeMimetypes: z.boolean(),
+8 -1
View File
@@ -155,7 +155,8 @@ export default typedPlugin(
const { fileName } = nameResult; const { fileName } = nameResult;
// determine mimetype // 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) { if (config.files.assumeMimetypes) {
response.assumedMimetypes![i] = assumed; 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 // compress the image if requested
let compressed; let compressed;
if (mimetype.startsWith('image/') && options.imageCompression) { if (mimetype.startsWith('image/') && options.imageCompression) {