From 493843cc42cb9cbbdee5109f6844f41a6f95ec32 Mon Sep 17 00:00:00 2001 From: nobswebdev Date: Wed, 7 Oct 2026 00:36:10 +0200 Subject: [PATCH 1/9] Move upload caps into validation config env vars. Replace MAX_IMAGES_PER_UPLOAD with VALIDATION_UPLOAD_* limits mirrored on the frontend VITE_* names. --- .env.example | 7 +++++-- backend/src/config/index.ts | 4 ++++ backend/src/config/validate.spec.ts | 3 ++- backend/src/config/validate.ts | 18 ++++++++++++------ backend/src/types/AppConfig.ts | 2 ++ backend/src/types/ValidationConfig.ts | 4 ++++ 6 files changed, 29 insertions(+), 9 deletions(-) create mode 100644 backend/src/types/ValidationConfig.ts diff --git a/.env.example b/.env.example index c42577c..076d4f6 100644 --- a/.env.example +++ b/.env.example @@ -18,7 +18,6 @@ NODE_ENV=development CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173 -MAX_IMAGES_PER_UPLOAD=10 MULTER_MAX_FILE_BYTES=10485760 ALTCHA_ENABLED=true @@ -32,7 +31,11 @@ THROTTLE_LIMIT=1000 THROTTLE_UPLOAD_TTL_MS=60000 THROTTLE_UPLOAD_LIMIT=20 +VALIDATION_UPLOAD_MAX_IMAGES_PER_UPLOAD=10 +VALIDATION_UPLOAD_MAX_VIEWS_MAX=1000000 + VITE_API_BASE_URL=http://localhost:3000/api VITE_CLEARNET_DOMAIN=localhost:5173 VITE_ONION_HOSTNAME= -VITE_MAX_IMAGES_PER_UPLOAD=10 +VITE_VALIDATION_UPLOAD_MAX_IMAGES_PER_UPLOAD=10 +VITE_VALIDATION_UPLOAD_MAX_VIEWS_MAX=1000000 diff --git a/backend/src/config/index.ts b/backend/src/config/index.ts index e544bbd..397701e 100644 --- a/backend/src/config/index.ts +++ b/backend/src/config/index.ts @@ -49,6 +49,10 @@ export const getAppConfig = (): AppConfig => { uploadThrottle: { ttlMs: envInt('THROTTLE_UPLOAD_TTL_MS'), limit: envInt('THROTTLE_UPLOAD_LIMIT') + }, + validation: { + maxImagesPerUpload: envInt('VALIDATION_UPLOAD_MAX_IMAGES_PER_UPLOAD'), + uploadMaxViewsMax: envInt('VALIDATION_UPLOAD_MAX_VIEWS_MAX') } }; }; diff --git a/backend/src/config/validate.spec.ts b/backend/src/config/validate.spec.ts index e81a0f5..eda9226 100644 --- a/backend/src/config/validate.spec.ts +++ b/backend/src/config/validate.spec.ts @@ -11,7 +11,7 @@ const validConfig = (overrides: Record = {}): Record = {}): Record) => { diff --git a/backend/src/types/AppConfig.ts b/backend/src/types/AppConfig.ts index 808eb2f..40a557a 100644 --- a/backend/src/types/AppConfig.ts +++ b/backend/src/types/AppConfig.ts @@ -1,5 +1,6 @@ import { NodeEnv } from './NodeEnv'; import { ThrottleConfig } from './ThrottleConfig'; +import { ValidationConfig } from './ValidationConfig'; export type AppConfig = { port: number; @@ -7,4 +8,5 @@ export type AppConfig = { corsOrigins: string[]; throttle: ThrottleConfig; uploadThrottle: ThrottleConfig; + validation: ValidationConfig; }; diff --git a/backend/src/types/ValidationConfig.ts b/backend/src/types/ValidationConfig.ts new file mode 100644 index 0000000..83ff1de --- /dev/null +++ b/backend/src/types/ValidationConfig.ts @@ -0,0 +1,4 @@ +export type ValidationConfig = { + maxImagesPerUpload: number; + uploadMaxViewsMax: number; +}; From fa36b27d1bc4c61ef9a94f9bdd3a1f4b3fa02d8b Mon Sep 17 00:00:00 2001 From: nobswebdev Date: Wed, 7 Oct 2026 00:36:13 +0200 Subject: [PATCH 2/9] Add isBase64 and NullOr validators for upload DTOs. Include toBase64 helper and focused specs for base64 and null-or field rules. --- .../src/types/validation/IsBase64Options.ts | 3 + .../src/types/validation/NullOrIntOptions.ts | 4 + backend/src/utils/toBase64.spec.ts | 39 +++ backend/src/utils/toBase64.ts | 2 + .../validation/decorators/isBase64.spec.ts | 253 ++++++++++++++++++ backend/src/validation/decorators/isBase64.ts | 48 ++++ .../src/validation/decorators/nullOr.spec.ts | 57 ++++ backend/src/validation/decorators/nullOr.ts | 77 ++++++ 8 files changed, 483 insertions(+) create mode 100644 backend/src/types/validation/IsBase64Options.ts create mode 100644 backend/src/types/validation/NullOrIntOptions.ts create mode 100644 backend/src/utils/toBase64.spec.ts create mode 100644 backend/src/utils/toBase64.ts create mode 100644 backend/src/validation/decorators/isBase64.spec.ts create mode 100644 backend/src/validation/decorators/isBase64.ts create mode 100644 backend/src/validation/decorators/nullOr.spec.ts create mode 100644 backend/src/validation/decorators/nullOr.ts diff --git a/backend/src/types/validation/IsBase64Options.ts b/backend/src/types/validation/IsBase64Options.ts new file mode 100644 index 0000000..e008f27 --- /dev/null +++ b/backend/src/types/validation/IsBase64Options.ts @@ -0,0 +1,3 @@ +export interface IsBase64Options { + byteLength?: number; +} diff --git a/backend/src/types/validation/NullOrIntOptions.ts b/backend/src/types/validation/NullOrIntOptions.ts new file mode 100644 index 0000000..baded07 --- /dev/null +++ b/backend/src/types/validation/NullOrIntOptions.ts @@ -0,0 +1,4 @@ +export type NullOrIntOptions = { + min?: number; + max?: number; +}; diff --git a/backend/src/utils/toBase64.spec.ts b/backend/src/utils/toBase64.spec.ts new file mode 100644 index 0000000..73684bf --- /dev/null +++ b/backend/src/utils/toBase64.spec.ts @@ -0,0 +1,39 @@ +import { toBase64 } from './toBase64'; + +describe('toBase64', () => { + it('encodes utf8 strings', () => { + expect(toBase64('hello')).toBe('aGVsbG8='); + }); + + it('encodes empty strings', () => { + expect(toBase64('')).toBe(''); + }); + + it('encodes buffers', () => { + const bytes = Buffer.from([0x01, 0x02, 0x03]); + + expect(toBase64(bytes)).toBe(bytes.toString('base64')); + }); + + it('encodes empty buffers', () => { + expect(toBase64(Buffer.alloc(0))).toBe(''); + }); + + it('encodes binary buffers without utf8 interpretation', () => { + const bytes = Buffer.from([0xff, 0xfe, 0xfd]); + + expect(toBase64(bytes)).toBe('//79'); + }); + + it('round-trips utf8 strings through base64 decode', () => { + const original = 'upload-test-🔒'; + + expect(Buffer.from(toBase64(original), 'base64').toString('utf8')).toBe(original); + }); + + it('round-trips buffers through base64 decode', () => { + const original = Buffer.alloc(12, 0xab); + + expect(Buffer.from(toBase64(original), 'base64')).toEqual(original); + }); +}); diff --git a/backend/src/utils/toBase64.ts b/backend/src/utils/toBase64.ts new file mode 100644 index 0000000..697154b --- /dev/null +++ b/backend/src/utils/toBase64.ts @@ -0,0 +1,2 @@ +export const toBase64 = (input: Buffer | string): string => + typeof input === 'string' ? Buffer.from(input, 'utf8').toString('base64') : input.toString('base64'); diff --git a/backend/src/validation/decorators/isBase64.spec.ts b/backend/src/validation/decorators/isBase64.spec.ts new file mode 100644 index 0000000..cdc6b6b --- /dev/null +++ b/backend/src/validation/decorators/isBase64.spec.ts @@ -0,0 +1,253 @@ +import 'reflect-metadata'; +import { validateSync, type ValidationError } from 'class-validator'; +import { toBase64 } from '../../utils/toBase64'; +import { IsBase64 } from './isBase64'; + +class UnboundedBase64Dto { + @IsBase64() + payload: string; +} + +class TwelveByteBase64Dto { + @IsBase64({ byteLength: 12 }) + iv: string; +} + +class ThirtyTwoByteBase64Dto { + @IsBase64({ byteLength: 32 }) + secretKey: string; +} + +class DualFieldDto { + @IsBase64({ byteLength: 12 }) + iv: string; + + @IsBase64() + ciphertext: string; +} + +const validateField = (DtoClass: new () => T, property: keyof T & string, value: unknown) => { + const dto = Object.assign(new DtoClass(), { [property]: value }); + + return validateSync(dto); +}; + +const constraintMessage = (errors: ValidationError[], property: string): string | undefined => { + const error = errors.find(e => e.property === property); + + return error?.constraints?.isBase64; +}; + +const accepts = (DtoClass: new () => T, property: keyof T & string, value: unknown) => { + expect(validateField(DtoClass, property, value)).toHaveLength(0); +}; + +const rejects = (DtoClass: new () => T, property: keyof T & string, value: unknown) => { + expect(validateField(DtoClass, property, value).length).toBeGreaterThan(0); +}; + +describe('IsBase64', () => { + describe('without byteLength', () => { + it('accepts standard base64 for short utf8 payloads', () => { + accepts(UnboundedBase64Dto, 'payload', toBase64('hello')); + }); + + it('accepts base64 without padding when length allows', () => { + accepts(UnboundedBase64Dto, 'payload', 'Zm9v'); + }); + + it('accepts base64 with single padding character', () => { + accepts(UnboundedBase64Dto, 'payload', 'YQ=='); + }); + + it('accepts base64 with double padding', () => { + accepts(UnboundedBase64Dto, 'payload', 'YWI='); + }); + + it('accepts uppercase and lowercase alphabet characters', () => { + accepts(UnboundedBase64Dto, 'payload', toBase64('Ab+/Zz09')); + }); + + it('accepts plus and slash in the alphabet portion', () => { + const raw = Buffer.from([0xff, 0xfe, 0xfd, 0xfc, 0xfb, 0xfa]); + + accepts(UnboundedBase64Dto, 'payload', raw.toString('base64')); + }); + + it('accepts large binary payloads', () => { + accepts(UnboundedBase64Dto, 'payload', toBase64(Buffer.alloc(4096, 0xab))); + }); + + it('accepts a one-byte decoded payload', () => { + accepts(UnboundedBase64Dto, 'payload', Buffer.from([0x00]).toString('base64')); + }); + + it.each([null, undefined, 0, 1, false, true, {}, [], Symbol('x')])( + 'rejects non-string value %p', + value => { + rejects(UnboundedBase64Dto, 'payload', value); + } + ); + + it('rejects empty string', () => { + rejects(UnboundedBase64Dto, 'payload', ''); + }); + + it.each(['@', '#', '$', '%', '-', '_', '.', ',', '!', '?', '\\', '"', "'"])( + 'rejects disallowed character %s', + char => { + rejects(UnboundedBase64Dto, 'payload', `YQ==${char}`); + } + ); + + it('rejects whitespace before valid base64', () => { + rejects(UnboundedBase64Dto, 'payload', ` ${toBase64('a')}`); + }); + + it('rejects whitespace after valid base64', () => { + rejects(UnboundedBase64Dto, 'payload', `${toBase64('a')} `); + }); + + it('rejects embedded newline', () => { + rejects(UnboundedBase64Dto, 'payload', 'YQ==\n'); + }); + + it('rejects embedded tab', () => { + rejects(UnboundedBase64Dto, 'payload', 'YQ\t=='); + }); + + it('rejects unicode outside base64 alphabet', () => { + rejects(UnboundedBase64Dto, 'payload', 'YQé=='); + }); + + it('rejects more than two padding characters', () => { + rejects(UnboundedBase64Dto, 'payload', 'YQ==='); + }); + + it('rejects padding characters in the middle of the string', () => { + rejects(UnboundedBase64Dto, 'payload', 'Y=Q='); + }); + + it('rejects strings that are only padding', () => { + rejects(UnboundedBase64Dto, 'payload', '=='); + }); + + it('returns the generic error message', () => { + const errors = validateField(UnboundedBase64Dto, 'payload', 'not!!!valid'); + + expect(constraintMessage(errors, 'payload')).toBe('payload must be a valid base64 string'); + }); + }); + + describe('with byteLength', () => { + const twelveBytes = Buffer.alloc(12, 0x11); + const twelveBytesB64 = twelveBytes.toString('base64'); + + it('accepts base64 that decodes to exactly 12 bytes', () => { + accepts(TwelveByteBase64Dto, 'iv', twelveBytesB64); + }); + + it('accepts 12 random bytes', () => { + const iv = Buffer.from([ + 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c + ]); + + accepts(TwelveByteBase64Dto, 'iv', iv.toString('base64')); + }); + + it('accepts 32-byte secrets when byteLength is 32', () => { + accepts(ThirtyTwoByteBase64Dto, 'secretKey', Buffer.alloc(32, 0x42).toString('base64')); + }); + + it('rejects valid base64 that decodes to 11 bytes when 12 are required', () => { + rejects(TwelveByteBase64Dto, 'iv', Buffer.alloc(11, 0x01).toString('base64')); + }); + + it('rejects valid base64 that decodes to 13 bytes when 12 are required', () => { + rejects(TwelveByteBase64Dto, 'iv', Buffer.alloc(13, 0x01).toString('base64')); + }); + + it('rejects valid base64 that decodes to 1 byte when 32 are required', () => { + rejects(ThirtyTwoByteBase64Dto, 'secretKey', toBase64('a')); + }); + + it('rejects valid base64 that decodes to 31 bytes when 32 are required', () => { + rejects(ThirtyTwoByteBase64Dto, 'secretKey', Buffer.alloc(31, 0x01).toString('base64')); + }); + + it('rejects invalid base64 even when length would have matched', () => { + rejects(TwelveByteBase64Dto, 'iv', `${twelveBytesB64}!`); + }); + + it('rejects empty string when byteLength is set', () => { + rejects(TwelveByteBase64Dto, 'iv', ''); + }); + + it('returns byteLength-specific error message', () => { + const errors = validateField(TwelveByteBase64Dto, 'iv', toBase64('short')); + + expect(constraintMessage(errors, 'iv')).toBe('iv must be a base64-encoded 12-byte value'); + }); + + it('returns byteLength-specific error message for 32-byte constraint', () => { + const errors = validateField(ThirtyTwoByteBase64Dto, 'secretKey', toBase64('x')); + + expect(constraintMessage(errors, 'secretKey')).toBe( + 'secretKey must be a base64-encoded 32-byte value' + ); + }); + }); + + describe('multiple decorated properties on one DTO', () => { + const iv = Buffer.alloc(12, 0x99).toString('base64'); + const ciphertext = toBase64(Buffer.alloc(64, 0xcd)); + + it('accepts when both iv and ciphertext are valid', () => { + const dto = Object.assign(new DualFieldDto(), { iv, ciphertext }); + + expect(validateSync(dto)).toHaveLength(0); + }); + + it('rejects when only iv is invalid', () => { + const dto = Object.assign(new DualFieldDto(), { iv: 'bad', ciphertext }); + + const errors = validateSync(dto); + + expect(errors).toHaveLength(1); + expect(errors[0].property).toBe('iv'); + }); + + it('rejects when only ciphertext is invalid', () => { + const dto = Object.assign(new DualFieldDto(), { iv, ciphertext: '###' }); + + const errors = validateSync(dto); + + expect(errors).toHaveLength(1); + expect(errors[0].property).toBe('ciphertext'); + }); + + it('reports both properties when both are invalid', () => { + const dto = Object.assign(new DualFieldDto(), { iv: '', ciphertext: '' }); + + const errors = validateSync(dto); + + expect(errors.map(e => e.property).sort()).toEqual(['ciphertext', 'iv']); + }); + }); + + describe('default options object', () => { + class DefaultOptionsDto { + @IsBase64({}) + payload: string; + } + + it('behaves like unbounded base64 when options is an empty object', () => { + accepts(DefaultOptionsDto, 'payload', toBase64('defaults')); + }); + + it('rejects wrong byte length only when byteLength is explicitly set', () => { + rejects(TwelveByteBase64Dto, 'iv', toBase64('x')); + accepts(UnboundedBase64Dto, 'payload', toBase64('x')); + }); + }); +}); diff --git a/backend/src/validation/decorators/isBase64.ts b/backend/src/validation/decorators/isBase64.ts new file mode 100644 index 0000000..b2cdecf --- /dev/null +++ b/backend/src/validation/decorators/isBase64.ts @@ -0,0 +1,48 @@ +import { + Validate, + ValidatorConstraint, + type ValidationArguments, + type ValidatorConstraintInterface +} from 'class-validator'; +import type { IsBase64Options } from '../../types/validation/IsBase64Options'; + +const BASE64_PATTERN = /^[A-Za-z0-9+/]+={0,2}$/; + +@ValidatorConstraint({ name: 'isBase64' }) +class IsBase64Constraint implements ValidatorConstraintInterface { + validate(value: unknown, args: ValidationArguments): boolean { + if (typeof value !== 'string' || !value) { + return false; + } + + if (!BASE64_PATTERN.test(value)) { + return false; + } + + const decoded = Buffer.from(value, 'base64'); + + if (decoded.length === 0) { + return false; + } + + const [{ byteLength }] = args.constraints as [IsBase64Options]; + + if (byteLength !== undefined) { + return decoded.length === byteLength; + } + + return true; + } + + defaultMessage(args: ValidationArguments): string { + const [{ byteLength }] = args.constraints as [IsBase64Options]; + + if (byteLength !== undefined) { + return `$property must be a base64-encoded ${byteLength}-byte value`; + } + + return '$property must be a valid base64 string'; + } +} + +export const IsBase64 = (options: IsBase64Options = {}) => Validate(IsBase64Constraint, [options]); diff --git a/backend/src/validation/decorators/nullOr.spec.ts b/backend/src/validation/decorators/nullOr.spec.ts new file mode 100644 index 0000000..31037df --- /dev/null +++ b/backend/src/validation/decorators/nullOr.spec.ts @@ -0,0 +1,57 @@ +import 'reflect-metadata'; +import { plainToInstance } from 'class-transformer'; +import { validateSync } from 'class-validator'; +import { NullOrDate, NullOrInt } from './nullOr'; + +class DateFieldDto { + @NullOrDate() + expiresAt: Date | null; +} + +class IntFieldDto { + @NullOrInt({ min: 1, max: 100 }) + maxViews: number | null; +} + +describe('NullOrDate', () => { + it('accepts null', () => { + const dto = plainToInstance(DateFieldDto, { expiresAt: null }); + + expect(validateSync(dto)).toHaveLength(0); + }); + + it('accepts an ISO string transformed to Date', () => { + const dto = plainToInstance(DateFieldDto, { expiresAt: '2026-06-01T00:00:00.000Z' }); + + expect(validateSync(dto)).toHaveLength(0); + expect(dto.expiresAt).toBeInstanceOf(Date); + }); + + it('rejects missing property', () => { + expect(validateSync(new DateFieldDto()).length).toBeGreaterThan(0); + }); +}); + +describe('NullOrInt', () => { + it('accepts null', () => { + const dto = Object.assign(new IntFieldDto(), { maxViews: null }); + + expect(validateSync(dto)).toHaveLength(0); + }); + + it('accepts an integer within bounds', () => { + const dto = Object.assign(new IntFieldDto(), { maxViews: 50 }); + + expect(validateSync(dto)).toHaveLength(0); + }); + + it('rejects out-of-range integers', () => { + const dto = Object.assign(new IntFieldDto(), { maxViews: 0 }); + + expect(validateSync(dto).length).toBeGreaterThan(0); + }); + + it('rejects missing property', () => { + expect(validateSync(new IntFieldDto()).length).toBeGreaterThan(0); + }); +}); diff --git a/backend/src/validation/decorators/nullOr.ts b/backend/src/validation/decorators/nullOr.ts new file mode 100644 index 0000000..d544290 --- /dev/null +++ b/backend/src/validation/decorators/nullOr.ts @@ -0,0 +1,77 @@ +import { applyDecorators } from '@nestjs/common'; +import { Type } from 'class-transformer'; +import { + Validate, + ValidatorConstraint, + type ValidationArguments, + type ValidationOptions, + type ValidatorConstraintInterface +} from 'class-validator'; +import type { NullOrIntOptions } from '../../types/validation/NullOrIntOptions'; + +@ValidatorConstraint({ name: 'nullOrDate' }) +class NullOrDateConstraint implements ValidatorConstraintInterface { + validate(value: unknown): boolean { + if (value === null) { + return true; + } + + return value instanceof Date && !Number.isNaN(value.getTime()); + } + + defaultMessage(): string { + return '$property must be null or a valid date'; + } +} + +@ValidatorConstraint({ name: 'nullOrInt' }) +class NullOrIntConstraint implements ValidatorConstraintInterface { + validate(value: unknown, args: ValidationArguments): boolean { + if (value === null) { + return true; + } + + const [{ min, max }] = args.constraints as [NullOrIntOptions]; + + if (typeof value !== 'number' || !Number.isInteger(value)) { + return false; + } + + if (min !== undefined && value < min) { + return false; + } + + if (max !== undefined && value > max) { + return false; + } + + return true; + } + + defaultMessage(args: ValidationArguments): string { + const [{ min, max }] = args.constraints as [NullOrIntOptions]; + + if (min !== undefined && max !== undefined) { + return `$property must be null or an integer between ${min} and ${max}`; + } + + if (min !== undefined) { + return `$property must be null or an integer >= ${min}`; + } + + if (max !== undefined) { + return `$property must be null or an integer <= ${max}`; + } + + return '$property must be null or an integer'; + } +} + +export const NullOrDate = (validationOptions?: ValidationOptions) => + applyDecorators( + Type(() => Date), + Validate(NullOrDateConstraint, validationOptions) + ); + +export const NullOrInt = (options?: NullOrIntOptions, validationOptions?: ValidationOptions) => + applyDecorators(Validate(NullOrIntConstraint, [options ?? {}], validationOptions)); From c762223200d5e746c4ca7b6265a855e9376de7f3 Mon Sep 17 00:00:00 2001 From: nobswebdev Date: Wed, 7 Oct 2026 00:36:16 +0200 Subject: [PATCH 3/9] Add zk and hotlink upload create DTOs with batch options. Split item shapes, array caps from config, and password-protected zk item transform. --- .../upload/dto/CreateHotlinkUploadDto.ts | 19 +++++++++ .../upload/dto/CreateHotlinkUploadItemDto.ts | 9 +++++ .../dto/CreateUploadCiphertextItemDto.ts | 12 ++++++ .../dto/CreateZkPasswordUploadItemDto.ts | 17 ++++++++ .../modules/upload/dto/CreateZkUploadDto.ts | 40 +++++++++++++++++++ .../upload/dto/UploadBatchOptionsDto.ts | 14 +++++++ 6 files changed, 111 insertions(+) create mode 100644 backend/src/modules/upload/dto/CreateHotlinkUploadDto.ts create mode 100644 backend/src/modules/upload/dto/CreateHotlinkUploadItemDto.ts create mode 100644 backend/src/modules/upload/dto/CreateUploadCiphertextItemDto.ts create mode 100644 backend/src/modules/upload/dto/CreateZkPasswordUploadItemDto.ts create mode 100644 backend/src/modules/upload/dto/CreateZkUploadDto.ts create mode 100644 backend/src/modules/upload/dto/UploadBatchOptionsDto.ts diff --git a/backend/src/modules/upload/dto/CreateHotlinkUploadDto.ts b/backend/src/modules/upload/dto/CreateHotlinkUploadDto.ts new file mode 100644 index 0000000..9d851bd --- /dev/null +++ b/backend/src/modules/upload/dto/CreateHotlinkUploadDto.ts @@ -0,0 +1,19 @@ +import { Type } from 'class-transformer'; +import { ArrayMaxSize, ArrayMinSize, IsArray, IsNotEmpty, ValidateNested } from 'class-validator'; +import { getAppConfig } from '../../../config'; +import { CreateHotlinkUploadItemDto } from './CreateHotlinkUploadItemDto'; +import { UploadBatchOptionsDto } from './UploadBatchOptionsDto'; + +const { + validation: { maxImagesPerUpload } +} = getAppConfig(); + +export class CreateHotlinkUploadDto extends UploadBatchOptionsDto { + @IsNotEmpty() + @IsArray() + @ArrayMinSize(1) + @ArrayMaxSize(maxImagesPerUpload) + @ValidateNested({ each: true }) + @Type(() => CreateHotlinkUploadItemDto) + items: CreateHotlinkUploadItemDto[]; +} diff --git a/backend/src/modules/upload/dto/CreateHotlinkUploadItemDto.ts b/backend/src/modules/upload/dto/CreateHotlinkUploadItemDto.ts new file mode 100644 index 0000000..b14b8cf --- /dev/null +++ b/backend/src/modules/upload/dto/CreateHotlinkUploadItemDto.ts @@ -0,0 +1,9 @@ +import { IsMimeType, IsNotEmpty, IsString } from 'class-validator'; +import { CreateUploadCiphertextItemDto } from './CreateUploadCiphertextItemDto'; + +export class CreateHotlinkUploadItemDto extends CreateUploadCiphertextItemDto { + @IsNotEmpty() + @IsString() + @IsMimeType() + mimeType: string; +} diff --git a/backend/src/modules/upload/dto/CreateUploadCiphertextItemDto.ts b/backend/src/modules/upload/dto/CreateUploadCiphertextItemDto.ts new file mode 100644 index 0000000..cba342c --- /dev/null +++ b/backend/src/modules/upload/dto/CreateUploadCiphertextItemDto.ts @@ -0,0 +1,12 @@ +import { IsNotEmpty } from 'class-validator'; +import { IsBase64 } from '../../../validation/decorators/isBase64'; + +export class CreateUploadCiphertextItemDto { + @IsNotEmpty() + @IsBase64({ byteLength: 12 }) + iv: string; + + @IsNotEmpty() + @IsBase64() + ciphertext: string; +} diff --git a/backend/src/modules/upload/dto/CreateZkPasswordUploadItemDto.ts b/backend/src/modules/upload/dto/CreateZkPasswordUploadItemDto.ts new file mode 100644 index 0000000..fc3a5b1 --- /dev/null +++ b/backend/src/modules/upload/dto/CreateZkPasswordUploadItemDto.ts @@ -0,0 +1,17 @@ +import { IsNotEmpty } from 'class-validator'; +import { IsBase64 } from '../../../validation/decorators/isBase64'; +import { CreateUploadCiphertextItemDto } from './CreateUploadCiphertextItemDto'; + +export class CreateZkPasswordUploadItemDto extends CreateUploadCiphertextItemDto { + @IsNotEmpty() + @IsBase64() + wrappedDek: string; + + @IsNotEmpty() + @IsBase64({ byteLength: 12 }) + wrapIv: string; + + @IsNotEmpty() + @IsBase64() + salt: string; +} diff --git a/backend/src/modules/upload/dto/CreateZkUploadDto.ts b/backend/src/modules/upload/dto/CreateZkUploadDto.ts new file mode 100644 index 0000000..ccab0b6 --- /dev/null +++ b/backend/src/modules/upload/dto/CreateZkUploadDto.ts @@ -0,0 +1,40 @@ +import { plainToInstance, Transform } from 'class-transformer'; +import { + ArrayMaxSize, + ArrayMinSize, + IsArray, + IsBoolean, + IsNotEmpty, + ValidateNested +} from 'class-validator'; +import { getAppConfig } from '../../../config'; +import { CreateUploadCiphertextItemDto } from './CreateUploadCiphertextItemDto'; +import { CreateZkPasswordUploadItemDto } from './CreateZkPasswordUploadItemDto'; +import { UploadBatchOptionsDto } from './UploadBatchOptionsDto'; + +const { + validation: { maxImagesPerUpload } +} = getAppConfig(); + +export class CreateZkUploadDto extends UploadBatchOptionsDto { + @IsNotEmpty() + @IsBoolean() + passwordProtected: boolean; + + @IsNotEmpty() + @IsArray() + @ArrayMinSize(1) + @ArrayMaxSize(maxImagesPerUpload) + @ValidateNested({ each: true }) + @Transform(({ value, obj }: { value: unknown; obj: CreateZkUploadDto }) => { + if (!Array.isArray(value)) { + return value; + } + + const ItemClass = + obj.passwordProtected === true ? CreateZkPasswordUploadItemDto : CreateUploadCiphertextItemDto; + + return value.map(item => plainToInstance(ItemClass, item)); + }) + items: Array; +} diff --git a/backend/src/modules/upload/dto/UploadBatchOptionsDto.ts b/backend/src/modules/upload/dto/UploadBatchOptionsDto.ts new file mode 100644 index 0000000..4070d34 --- /dev/null +++ b/backend/src/modules/upload/dto/UploadBatchOptionsDto.ts @@ -0,0 +1,14 @@ +import { getAppConfig } from '../../../config'; +import { NullOrDate, NullOrInt } from '../../../validation/decorators/nullOr'; + +const { + validation: { uploadMaxViewsMax } +} = getAppConfig(); + +export class UploadBatchOptionsDto { + @NullOrDate() + expiresAt: Date | null; + + @NullOrInt({ min: 1, max: uploadMaxViewsMax }) + maxViews: number | null; +} From 4b7f8459cbc13ccf96e9048d0894ce86475d0e80 Mon Sep 17 00:00:00 2001 From: nobswebdev Date: Wed, 7 Oct 2026 00:36:16 +0200 Subject: [PATCH 4/9] Align frontend env with validation limits and axios plugin. Flatten share/API config keys, nest validation limits only, and move the HTTP client to plugins/axios. --- frontend/src/config/index.ts | 26 +++++++++----------------- frontend/src/lib/api.ts | 10 ---------- frontend/src/plugins/axios.ts | 6 ++++++ frontend/src/vite-env.d.ts | 3 ++- 4 files changed, 17 insertions(+), 28 deletions(-) delete mode 100644 frontend/src/lib/api.ts create mode 100644 frontend/src/plugins/axios.ts diff --git a/frontend/src/config/index.ts b/frontend/src/config/index.ts index 8e34123..76d2b02 100644 --- a/frontend/src/config/index.ts +++ b/frontend/src/config/index.ts @@ -1,29 +1,21 @@ const env = (key: string): string => { const value = import.meta.env[key]; + if (value === undefined || value === null) { return ''; } + return String(value); }; -const envInt = (key: string, fallback: number): number => { - const raw = env(key); - if (!raw) { - return fallback; - } - const parsed = parseInt(raw, 10); - return Number.isFinite(parsed) ? parsed : fallback; -}; +const envInt = (key: string): number => parseInt(env(key), 10); export const config = { - api: { - baseUrl: env('VITE_API_BASE_URL') - }, - share: { - clearnetDomain: env('VITE_CLEARNET_DOMAIN'), - onionHostname: env('VITE_ONION_HOSTNAME') - }, - limits: { - maxImagesPerUpload: envInt('VITE_MAX_IMAGES_PER_UPLOAD', 10) + baseUrl: env('VITE_API_BASE_URL'), + clearnetDomain: env('VITE_CLEARNET_DOMAIN'), + onionHostname: env('VITE_ONION_HOSTNAME'), + validation: { + maxImagesPerUpload: envInt('VITE_VALIDATION_UPLOAD_MAX_IMAGES_PER_UPLOAD'), + uploadMaxViewsMax: envInt('VITE_VALIDATION_UPLOAD_MAX_VIEWS_MAX') } }; diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts deleted file mode 100644 index f8f9726..0000000 --- a/frontend/src/lib/api.ts +++ /dev/null @@ -1,10 +0,0 @@ -import axios from 'axios'; -import { config } from '@/config'; - -export const api = axios.create({ - baseURL: config.api.baseUrl, - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json' - } -}); diff --git a/frontend/src/plugins/axios.ts b/frontend/src/plugins/axios.ts new file mode 100644 index 0000000..9d25e1e --- /dev/null +++ b/frontend/src/plugins/axios.ts @@ -0,0 +1,6 @@ +import axios from 'axios'; +import { config } from '@/config'; + +export const api = axios.create({ + baseURL: config.baseUrl +}); diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts index 4ac8126..dab841d 100644 --- a/frontend/src/vite-env.d.ts +++ b/frontend/src/vite-env.d.ts @@ -4,7 +4,8 @@ interface ImportMetaEnv { readonly VITE_API_BASE_URL: string; readonly VITE_CLEARNET_DOMAIN: string; readonly VITE_ONION_HOSTNAME: string; - readonly VITE_MAX_IMAGES_PER_UPLOAD: string; + readonly VITE_VALIDATION_UPLOAD_MAX_IMAGES_PER_UPLOAD: string; + readonly VITE_VALIDATION_UPLOAD_MAX_VIEWS_MAX: string; } interface ImportMeta { From 96383a742690d093e56425355a9c1316eaf8673f Mon Sep 17 00:00:00 2001 From: nobswebdev Date: Mon, 12 Oct 2026 20:18:22 +0200 Subject: [PATCH 5/9] Restructure upload validation env and colocate config types. Move app config shapes under types/config, replace batch upload env vars with per-image bytes and JSON body padding, and extend IsBase64 for decoded limits. --- .env.example | 9 ++++--- backend/src/config/index.ts | 25 +++++++++++++----- backend/src/config/validate.spec.ts | 5 ++-- backend/src/config/validate.ts | 26 ++++++++++++------- backend/src/types/ValidationConfig.ts | 4 --- .../src/types/{ => config}/AltchaConfig.ts | 1 + backend/src/types/{ => config}/AppConfig.ts | 2 +- backend/src/types/{ => config}/Config.ts | 0 .../src/types/{ => config}/PostgresConfig.ts | 0 .../src/types/{ => config}/ThrottleConfig.ts | 0 .../types/config/UploadValidationConfig.ts | 6 +++++ backend/src/types/config/ValidationConfig.ts | 5 ++++ .../src/types/validation/IsBase64Options.ts | 1 + .../validation/decorators/isBase64.spec.ts | 23 ++++++++++++++++ backend/src/validation/decorators/isBase64.ts | 12 +++++++-- 15 files changed, 90 insertions(+), 29 deletions(-) delete mode 100644 backend/src/types/ValidationConfig.ts rename backend/src/types/{ => config}/AltchaConfig.ts (77%) rename backend/src/types/{ => config}/AppConfig.ts (88%) rename backend/src/types/{ => config}/Config.ts (100%) rename backend/src/types/{ => config}/PostgresConfig.ts (100%) rename backend/src/types/{ => config}/ThrottleConfig.ts (100%) create mode 100644 backend/src/types/config/UploadValidationConfig.ts create mode 100644 backend/src/types/config/ValidationConfig.ts diff --git a/.env.example b/.env.example index 076d4f6..8d0072e 100644 --- a/.env.example +++ b/.env.example @@ -18,11 +18,10 @@ NODE_ENV=development CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173 -MULTER_MAX_FILE_BYTES=10485760 - ALTCHA_ENABLED=true ALTCHA_HMAC_KEY=change-me-use-long-random-secret ALTCHA_MAX_NUMBER=50000 +ALTCHA_CHALLENGE_TTL_MS=600000 NEVER_MODE_INACTIVITY_DAYS=365 @@ -31,11 +30,13 @@ THROTTLE_LIMIT=1000 THROTTLE_UPLOAD_TTL_MS=60000 THROTTLE_UPLOAD_LIMIT=20 -VALIDATION_UPLOAD_MAX_IMAGES_PER_UPLOAD=10 VALIDATION_UPLOAD_MAX_VIEWS_MAX=1000000 +VALIDATION_UPLOAD_MAX_IMAGE_BYTES=10485760 # 10MiB +VALIDATION_UPLOAD_JSON_BODY_PADDING_BYTES=2097152 # 2MiB VITE_API_BASE_URL=http://localhost:3000/api VITE_CLEARNET_DOMAIN=localhost:5173 VITE_ONION_HOSTNAME= -VITE_VALIDATION_UPLOAD_MAX_IMAGES_PER_UPLOAD=10 +VITE_UPLOAD_MAX_QUEUE_SIZE=10 VITE_VALIDATION_UPLOAD_MAX_VIEWS_MAX=1000000 +VITE_VALIDATION_UPLOAD_MAX_IMAGE_BYTES=10485760 # 10MiB diff --git a/backend/src/config/index.ts b/backend/src/config/index.ts index 397701e..32c8a9c 100644 --- a/backend/src/config/index.ts +++ b/backend/src/config/index.ts @@ -1,7 +1,7 @@ -import { AppConfig } from '../types/AppConfig'; -import { AltchaConfig } from '../types/AltchaConfig'; -import { PostgresConfig } from '../types/PostgresConfig'; +import { AltchaConfig } from '../types/config/AltchaConfig'; +import { AppConfig } from '../types/config/AppConfig'; import { NodeEnv } from '../types/NodeEnv'; +import { PostgresConfig } from '../types/config/PostgresConfig'; const env = (key: string): string => process.env[key] || ''; @@ -28,12 +28,19 @@ export const getAltchaConfig = (): AltchaConfig => { return { enabled: isEnabled('ALTCHA_ENABLED'), hmacKey: env('ALTCHA_HMAC_KEY'), - maxNumber: envInt('ALTCHA_MAX_NUMBER') + maxNumber: envInt('ALTCHA_MAX_NUMBER'), + challengeTtlMs: envInt('ALTCHA_CHALLENGE_TTL_MS') }; }; export const getAppConfig = (): AppConfig => { + const aesGcmCiphertextOverheadBytes = 16; + const jsonBase64ByteRatioNumerator = 4; + const jsonBase64ByteRatioDenominator = 3; + const rawOrigins = env('CORS_ORIGINS'); + const maxImageBytes = envInt('VALIDATION_UPLOAD_MAX_IMAGE_BYTES'); + const jsonBodyPaddingBytes = envInt('VALIDATION_UPLOAD_JSON_BODY_PADDING_BYTES'); return { port: envInt('BACKEND_PORT'), @@ -51,8 +58,14 @@ export const getAppConfig = (): AppConfig => { limit: envInt('THROTTLE_UPLOAD_LIMIT') }, validation: { - maxImagesPerUpload: envInt('VALIDATION_UPLOAD_MAX_IMAGES_PER_UPLOAD'), - uploadMaxViewsMax: envInt('VALIDATION_UPLOAD_MAX_VIEWS_MAX') + upload: { + maxViewsMax: envInt('VALIDATION_UPLOAD_MAX_VIEWS_MAX'), + maxImageBytes, + maxCiphertextBytes: maxImageBytes + aesGcmCiphertextOverheadBytes, + maxRequestBodyBytes: + Math.ceil((maxImageBytes * jsonBase64ByteRatioNumerator) / jsonBase64ByteRatioDenominator) + + jsonBodyPaddingBytes + } } }; }; diff --git a/backend/src/config/validate.spec.ts b/backend/src/config/validate.spec.ts index eda9226..17c168c 100644 --- a/backend/src/config/validate.spec.ts +++ b/backend/src/config/validate.spec.ts @@ -11,11 +11,12 @@ const validConfig = (overrides: Record = {}): Record) => { diff --git a/backend/src/types/ValidationConfig.ts b/backend/src/types/ValidationConfig.ts deleted file mode 100644 index 83ff1de..0000000 --- a/backend/src/types/ValidationConfig.ts +++ /dev/null @@ -1,4 +0,0 @@ -export type ValidationConfig = { - maxImagesPerUpload: number; - uploadMaxViewsMax: number; -}; diff --git a/backend/src/types/AltchaConfig.ts b/backend/src/types/config/AltchaConfig.ts similarity index 77% rename from backend/src/types/AltchaConfig.ts rename to backend/src/types/config/AltchaConfig.ts index ef224f8..65c8753 100644 --- a/backend/src/types/AltchaConfig.ts +++ b/backend/src/types/config/AltchaConfig.ts @@ -2,4 +2,5 @@ export type AltchaConfig = { enabled: boolean; hmacKey: string; maxNumber: number; + challengeTtlMs: number; }; diff --git a/backend/src/types/AppConfig.ts b/backend/src/types/config/AppConfig.ts similarity index 88% rename from backend/src/types/AppConfig.ts rename to backend/src/types/config/AppConfig.ts index 40a557a..67390d8 100644 --- a/backend/src/types/AppConfig.ts +++ b/backend/src/types/config/AppConfig.ts @@ -1,4 +1,4 @@ -import { NodeEnv } from './NodeEnv'; +import { NodeEnv } from '../NodeEnv'; import { ThrottleConfig } from './ThrottleConfig'; import { ValidationConfig } from './ValidationConfig'; diff --git a/backend/src/types/Config.ts b/backend/src/types/config/Config.ts similarity index 100% rename from backend/src/types/Config.ts rename to backend/src/types/config/Config.ts diff --git a/backend/src/types/PostgresConfig.ts b/backend/src/types/config/PostgresConfig.ts similarity index 100% rename from backend/src/types/PostgresConfig.ts rename to backend/src/types/config/PostgresConfig.ts diff --git a/backend/src/types/ThrottleConfig.ts b/backend/src/types/config/ThrottleConfig.ts similarity index 100% rename from backend/src/types/ThrottleConfig.ts rename to backend/src/types/config/ThrottleConfig.ts diff --git a/backend/src/types/config/UploadValidationConfig.ts b/backend/src/types/config/UploadValidationConfig.ts new file mode 100644 index 0000000..2c58eb1 --- /dev/null +++ b/backend/src/types/config/UploadValidationConfig.ts @@ -0,0 +1,6 @@ +export type UploadValidationConfig = { + maxViewsMax: number; + maxImageBytes: number; + maxCiphertextBytes: number; + maxRequestBodyBytes: number; +}; diff --git a/backend/src/types/config/ValidationConfig.ts b/backend/src/types/config/ValidationConfig.ts new file mode 100644 index 0000000..f117c63 --- /dev/null +++ b/backend/src/types/config/ValidationConfig.ts @@ -0,0 +1,5 @@ +import { UploadValidationConfig } from './UploadValidationConfig'; + +export type ValidationConfig = { + upload: UploadValidationConfig; +}; diff --git a/backend/src/types/validation/IsBase64Options.ts b/backend/src/types/validation/IsBase64Options.ts index e008f27..6b11519 100644 --- a/backend/src/types/validation/IsBase64Options.ts +++ b/backend/src/types/validation/IsBase64Options.ts @@ -1,3 +1,4 @@ export interface IsBase64Options { byteLength?: number; + maxByteLength?: number; } diff --git a/backend/src/validation/decorators/isBase64.spec.ts b/backend/src/validation/decorators/isBase64.spec.ts index cdc6b6b..7b9e4f3 100644 --- a/backend/src/validation/decorators/isBase64.spec.ts +++ b/backend/src/validation/decorators/isBase64.spec.ts @@ -235,6 +235,29 @@ describe('IsBase64', () => { }); }); + describe('with maxByteLength', () => { + class MaxByteLengthDto { + @IsBase64({ maxByteLength: 64 }) + payload: string; + } + + it('accepts base64 up to the decoded byte limit', () => { + accepts(MaxByteLengthDto, 'payload', toBase64(Buffer.alloc(64, 0x01))); + }); + + it('rejects base64 that decodes above the limit', () => { + rejects(MaxByteLengthDto, 'payload', toBase64(Buffer.alloc(65, 0x01))); + }); + + it('returns maxByteLength-specific error message', () => { + const errors = validateField(MaxByteLengthDto, 'payload', toBase64(Buffer.alloc(128, 0x01))); + + expect(constraintMessage(errors, 'payload')).toBe( + 'payload must be a base64-encoded value of at most 64 bytes' + ); + }); + }); + describe('default options object', () => { class DefaultOptionsDto { @IsBase64({}) diff --git a/backend/src/validation/decorators/isBase64.ts b/backend/src/validation/decorators/isBase64.ts index b2cdecf..3d537f4 100644 --- a/backend/src/validation/decorators/isBase64.ts +++ b/backend/src/validation/decorators/isBase64.ts @@ -25,22 +25,30 @@ class IsBase64Constraint implements ValidatorConstraintInterface { return false; } - const [{ byteLength }] = args.constraints as [IsBase64Options]; + const [{ byteLength, maxByteLength }] = args.constraints as [IsBase64Options]; if (byteLength !== undefined) { return decoded.length === byteLength; } + if (maxByteLength !== undefined) { + return decoded.length <= maxByteLength; + } + return true; } defaultMessage(args: ValidationArguments): string { - const [{ byteLength }] = args.constraints as [IsBase64Options]; + const [{ byteLength, maxByteLength }] = args.constraints as [IsBase64Options]; if (byteLength !== undefined) { return `$property must be a base64-encoded ${byteLength}-byte value`; } + if (maxByteLength !== undefined) { + return `$property must be a base64-encoded value of at most ${maxByteLength} bytes`; + } + return '$property must be a valid base64 string'; } } From 7f99256863058330c92b745a04d1dae43ebab57c Mon Sep 17 00:00:00 2001 From: nobswebdev Date: Mon, 12 Oct 2026 20:18:36 +0200 Subject: [PATCH 6/9] Verify ALTCHA before upload body parse with single-use replay store. Run challenge TTL and replay tracking in middleware, remove the guard, and wire ScheduleModule for pruning used solutions. --- backend/package-lock.json | 80 +++++++++- backend/package.json | 3 + backend/src/AppModule.ts | 4 +- backend/src/consts/altchaHeader.ts | 1 + backend/src/main.ts | 41 ++++- backend/src/middlewares/altchaMiddleware.ts | 29 ++++ backend/src/modules/altcha/AltchaModule.ts | 5 +- .../altcha/controllers/AltchaController.ts | 2 +- .../src/modules/altcha/guards/AltchaGuard.ts | 36 ----- .../altcha/services/AltchaService.spec.ts | 147 +++++++++++++++--- .../modules/altcha/services/AltchaService.ts | 41 ++++- .../services/UsedAltchaStoreService.spec.ts | 88 +++++++++++ .../altcha/services/UsedAltchaStoreService.ts | 38 +++++ .../altcha/types/AltchaVerificationStatus.ts | 6 + backend/src/types/HttpMethod.ts | 9 ++ 15 files changed, 446 insertions(+), 84 deletions(-) create mode 100644 backend/src/consts/altchaHeader.ts create mode 100644 backend/src/middlewares/altchaMiddleware.ts delete mode 100644 backend/src/modules/altcha/guards/AltchaGuard.ts create mode 100644 backend/src/modules/altcha/services/UsedAltchaStoreService.spec.ts create mode 100644 backend/src/modules/altcha/services/UsedAltchaStoreService.ts create mode 100644 backend/src/modules/altcha/types/AltchaVerificationStatus.ts create mode 100644 backend/src/types/HttpMethod.ts diff --git a/backend/package-lock.json b/backend/package-lock.json index 0ac44d3..97ffb63 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -13,11 +13,14 @@ "@nestjs/config": "^4.0.3", "@nestjs/core": "^11.0.1", "@nestjs/platform-express": "^11.0.1", + "@nestjs/schedule": "^6.1.3", "@nestjs/throttler": "^6.7.1", "@nestjs/typeorm": "^12.0.2", "altcha-lib": "^2.6.0", "class-transformer": "^0.5.1", "class-validator": "^0.15.1", + "dayjs": "^1.11.23", + "file-type": "^22.1.1", "pg": "^8.23.1", "reflect-metadata": "^0.2.2", "typeorm": "^0.3.28" @@ -1841,6 +1844,24 @@ } } }, + "node_modules/@nestjs/common/node_modules/file-type": { + "version": "21.3.4", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-21.3.4.tgz", + "integrity": "sha512-Ievi/yy8DS3ygGvT47PjSfdFoX+2isQueoYP1cntFW1JLYAuS4GD7NUPGg4zv2iZfV52uDyk5w5Z0TdpRS6Q1g==", + "license": "MIT", + "dependencies": { + "@tokenizer/inflate": "^0.4.1", + "strtok3": "^10.3.4", + "token-types": "^6.1.1", + "uint8array-extras": "^1.4.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sindresorhus/file-type?sponsor=1" + } + }, "node_modules/@nestjs/config": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/@nestjs/config/-/config-4.0.4.tgz", @@ -1916,6 +1937,19 @@ "@nestjs/core": "^11.0.0" } }, + "node_modules/@nestjs/schedule": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/@nestjs/schedule/-/schedule-6.1.3.tgz", + "integrity": "sha512-RflMFOpR16Dwd1jAUbeB4mfGTCh65fvEdL4mSjQPJChpkRGRjIXjb+6YQcK2faQrVT60c9DmLmoVR7/ONCtuYQ==", + "license": "MIT", + "dependencies": { + "cron": "4.4.0" + }, + "peerDependencies": { + "@nestjs/common": "^10.0.0 || ^11.0.0", + "@nestjs/core": "^10.0.0 || ^11.0.0" + } + }, "node_modules/@nestjs/schematics": { "version": "11.1.0", "resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-11.1.0.tgz", @@ -2603,6 +2637,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/luxon": { + "version": "3.7.6", + "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.7.6.tgz", + "integrity": "sha512-6KSjliQAXK8ZLgFQO4B7iE4Rpf/B3rItzCFuXezBY/XIAGxOdLd7vRNK9/StRPwiS/yJsVbB7HKpW46B8tcEuQ==", + "license": "MIT" + }, "node_modules/@types/node": { "version": "22.20.4", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.4.tgz", @@ -4251,6 +4291,23 @@ "devOptional": true, "license": "MIT" }, + "node_modules/cron": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/cron/-/cron-4.4.0.tgz", + "integrity": "sha512-fkdfq+b+AHI4cKdhZlppHveI/mgz2qpiYxcm+t5E5TsxX7QrLS1VE0+7GENEk9z0EeGPcpSciGv6ez24duWhwQ==", + "license": "MIT", + "dependencies": { + "@types/luxon": "~3.7.0", + "luxon": "~3.7.0" + }, + "engines": { + "node": ">=18.x" + }, + "funding": { + "type": "ko-fi", + "url": "https://ko-fi.com/intcreator" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -4815,18 +4872,18 @@ } }, "node_modules/file-type": { - "version": "21.3.4", - "resolved": "https://registry.npmjs.org/file-type/-/file-type-21.3.4.tgz", - "integrity": "sha512-Ievi/yy8DS3ygGvT47PjSfdFoX+2isQueoYP1cntFW1JLYAuS4GD7NUPGg4zv2iZfV52uDyk5w5Z0TdpRS6Q1g==", + "version": "22.1.1", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-22.1.1.tgz", + "integrity": "sha512-sEB0oStmeLGtpmSAD88RDNuLsvhv4iC3u0lE25CUvfCULvj49xoQ3l3K525Vn+ZzsZ6DOLajQCV6RmhwwhqiJg==", "license": "MIT", "dependencies": { "@tokenizer/inflate": "^0.4.1", - "strtok3": "^10.3.4", - "token-types": "^6.1.1", - "uint8array-extras": "^1.4.0" + "strtok3": "^10.3.5", + "token-types": "^6.1.2", + "uint8array-extras": "^1.5.0" }, "engines": { - "node": ">=20" + "node": ">=22" }, "funding": { "url": "https://github.com/sindresorhus/file-type?sponsor=1" @@ -6417,6 +6474,15 @@ "node": "20 || >=22" } }, + "node_modules/luxon": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", + "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/magic-string": { "version": "0.30.17", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", diff --git a/backend/package.json b/backend/package.json index de9e26b..5c83a44 100644 --- a/backend/package.json +++ b/backend/package.json @@ -24,11 +24,14 @@ "@nestjs/config": "^4.0.3", "@nestjs/core": "^11.0.1", "@nestjs/platform-express": "^11.0.1", + "@nestjs/schedule": "^6.1.3", "@nestjs/throttler": "^6.7.1", "@nestjs/typeorm": "^12.0.2", "altcha-lib": "^2.6.0", "class-transformer": "^0.5.1", "class-validator": "^0.15.1", + "dayjs": "^1.11.23", + "file-type": "^22.1.1", "pg": "^8.23.1", "reflect-metadata": "^0.2.2", "typeorm": "^0.3.28" diff --git a/backend/src/AppModule.ts b/backend/src/AppModule.ts index 762e603..1fe4ad0 100644 --- a/backend/src/AppModule.ts +++ b/backend/src/AppModule.ts @@ -1,5 +1,6 @@ import { Module } from '@nestjs/common'; import { ConfigModule, ConfigService, registerAs } from '@nestjs/config'; +import { ScheduleModule } from '@nestjs/schedule'; import { APP_GUARD } from '@nestjs/core'; import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler'; import { TypeOrmModule } from '@nestjs/typeorm'; @@ -9,10 +10,11 @@ import { AltchaModule } from './modules/altcha/AltchaModule'; import { HealthCheckModule } from './modules/healthCheck/HealthCheckModule'; import { StorageModule } from './modules/storage/StorageModule'; import { UploadModule } from './modules/upload/UploadModule'; -import { Config } from './types/Config'; +import { Config } from './types/config/Config'; @Module({ imports: [ + ScheduleModule.forRoot(), ConfigModule.forRoot({ isGlobal: true, validate, diff --git a/backend/src/consts/altchaHeader.ts b/backend/src/consts/altchaHeader.ts new file mode 100644 index 0000000..1dbde6e --- /dev/null +++ b/backend/src/consts/altchaHeader.ts @@ -0,0 +1 @@ +export const ALTCHA_REQUEST_HEADER = 'x-altcha'; diff --git a/backend/src/main.ts b/backend/src/main.ts index dc1cc2b..6223018 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -1,17 +1,48 @@ import { ValidationPipe } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { NestFactory } from '@nestjs/core'; +import { NestExpressApplication } from '@nestjs/platform-express'; +import express, { type NextFunction, type Request, type Response } from 'express'; import { AppModule } from './AppModule'; -import { Config } from './types/Config'; +import { altchaMiddleware } from './middlewares/altchaMiddleware'; +import { AltchaService } from './modules/altcha/services/AltchaService'; +import { Config } from './types/config/Config'; +import { HttpMethod } from './types/HttpMethod'; async function bootstrap() { - const app = await NestFactory.create(AppModule); - - const configService = app.get(ConfigService); - const { port, corsOrigins } = configService.get('app') as Config['app']; + const app = await NestFactory.create(AppModule, { bodyParser: false }); app.setGlobalPrefix('api'); + const configService = app.get(ConfigService); + const altchaService = app.get(AltchaService); + + const { + port, + corsOrigins, + validation: { + upload: { maxRequestBodyBytes } + } + } = configService.get('app') as Config['app']; + + const defaultJsonParser = express.json(); + const uploadJsonParser = express.json({ limit: maxRequestBodyBytes }); + const uploadAltchaMiddleware = altchaMiddleware(altchaService); + + app.use((req: Request, res: Response, next: NextFunction) => { + if (req.method === HttpMethod.Post && req.path.startsWith('/api/uploads')) { + return uploadAltchaMiddleware(req, res, (err: unknown) => { + if (err) { + return next(err); + } + + return uploadJsonParser(req, res, next); + }); + } + + return defaultJsonParser(req, res, next); + }); + app.useGlobalPipes( new ValidationPipe({ transform: true, diff --git a/backend/src/middlewares/altchaMiddleware.ts b/backend/src/middlewares/altchaMiddleware.ts new file mode 100644 index 0000000..dcaa066 --- /dev/null +++ b/backend/src/middlewares/altchaMiddleware.ts @@ -0,0 +1,29 @@ +import { HttpStatus } from '@nestjs/common'; +import type { RequestHandler } from 'express'; +import { ALTCHA_REQUEST_HEADER } from '../consts/altchaHeader'; +import type { AltchaService } from '../modules/altcha/services/AltchaService'; +import { AltchaVerificationStatus } from '../modules/altcha/types/AltchaVerificationStatus'; + +const altchaVerificationMessage: Record, string> = { + [AltchaVerificationStatus.Missing]: 'ALTCHA verification required', + [AltchaVerificationStatus.Invalid]: 'ALTCHA verification failed', + [AltchaVerificationStatus.AlreadyUsed]: 'ALTCHA already used' +}; + +export const altchaMiddleware = (altchaService: AltchaService): RequestHandler => { + return async (req, res, next) => { + try { + const altchaHeader = req.get(ALTCHA_REQUEST_HEADER); + + const status = await altchaService.verifySolution(altchaHeader); + + if (status === AltchaVerificationStatus.Ok) { + return next(); + } + + return res.status(HttpStatus.BAD_REQUEST).send({ message: altchaVerificationMessage[status] }); + } catch (error) { + return next(error); + } + }; +}; diff --git a/backend/src/modules/altcha/AltchaModule.ts b/backend/src/modules/altcha/AltchaModule.ts index 0037b45..e52076e 100644 --- a/backend/src/modules/altcha/AltchaModule.ts +++ b/backend/src/modules/altcha/AltchaModule.ts @@ -1,11 +1,10 @@ import { Module } from '@nestjs/common'; import { AltchaController } from './controllers/AltchaController'; -import { AltchaGuard } from './guards/AltchaGuard'; import { AltchaService } from './services/AltchaService'; +import { UsedAltchaStoreService } from './services/UsedAltchaStoreService'; @Module({ controllers: [AltchaController], - providers: [AltchaService, AltchaGuard], - exports: [AltchaGuard] + providers: [AltchaService, UsedAltchaStoreService] }) export class AltchaModule {} diff --git a/backend/src/modules/altcha/controllers/AltchaController.ts b/backend/src/modules/altcha/controllers/AltchaController.ts index 0ae75e0..617d01b 100644 --- a/backend/src/modules/altcha/controllers/AltchaController.ts +++ b/backend/src/modules/altcha/controllers/AltchaController.ts @@ -1,7 +1,7 @@ import { Controller, Get, NotFoundException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; +import { Config } from '../../../types/config/Config'; import { AltchaService } from '../services/AltchaService'; -import { Config } from '../../../types/Config'; @Controller('altcha') export class AltchaController { diff --git a/backend/src/modules/altcha/guards/AltchaGuard.ts b/backend/src/modules/altcha/guards/AltchaGuard.ts deleted file mode 100644 index d213756..0000000 --- a/backend/src/modules/altcha/guards/AltchaGuard.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { BadRequestException, CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { Request } from 'express'; -import { AltchaService } from '../services/AltchaService'; -import { Config } from '../../../types/Config'; - -@Injectable() -export class AltchaGuard implements CanActivate { - constructor( - private readonly altchaService: AltchaService, - private readonly configService: ConfigService - ) {} - - async canActivate(context: ExecutionContext): Promise { - const { enabled } = this.configService.get('altcha') as Config['altcha']; - - if (!enabled) { - return true; - } - - const request = context.switchToHttp().getRequest(); - const altcha = request.body?.altcha; - - if (typeof altcha !== 'string' || altcha.length === 0) { - throw new BadRequestException('ALTCHA verification required'); - } - - const verified = await this.altchaService.verifyPayload(altcha); - - if (!verified) { - throw new BadRequestException('ALTCHA verification failed'); - } - - return true; - } -} diff --git a/backend/src/modules/altcha/services/AltchaService.spec.ts b/backend/src/modules/altcha/services/AltchaService.spec.ts index de6c458..019f598 100644 --- a/backend/src/modules/altcha/services/AltchaService.spec.ts +++ b/backend/src/modules/altcha/services/AltchaService.spec.ts @@ -1,39 +1,138 @@ import { ConfigService } from '@nestjs/config'; -import { createChallenge, solveChallenge } from 'altcha-lib/v1'; -import type { Payload } from 'altcha-lib/v1/types'; +import { createChallenge, extractParams, solveChallenge } from 'altcha-lib/v1'; +import type { Challenge, Payload } from 'altcha-lib/v1/types'; +import { AltchaVerificationStatus } from '../types/AltchaVerificationStatus'; import { AltchaService } from './AltchaService'; +import { UsedAltchaStoreService } from './UsedAltchaStoreService'; const hmacKey = 'test-hmac-key'; const encodePayload = (payload: Payload): string => Buffer.from(JSON.stringify(payload), 'utf8').toString('base64'); +const encodeSolvedChallenge = async (challenge: Challenge): Promise => { + const { promise } = solveChallenge(challenge.challenge, challenge.salt, challenge.algorithm, 100_000); + const solution = await promise; + + expect(solution).not.toBeNull(); + + return encodePayload({ + algorithm: challenge.algorithm, + challenge: challenge.challenge, + number: solution!.number, + salt: challenge.salt, + signature: challenge.signature + }); +}; + +const solveChallengePayload = async (): Promise => + encodeSolvedChallenge(await createChallenge({ hmacKey, number: 42, maxNumber: 100_000 })); + +const createConfigService = (enabled: boolean): ConfigService => + ({ + get: jest.fn((key: string) => { + if (key === 'altcha') { + return { + enabled, + hmacKey, + maxNumber: 100_000, + challengeTtlMs: 600_000 + }; + } + + return undefined; + }) + }) as unknown as ConfigService; + +const createAltchaService = (enabled: boolean): AltchaService => { + const configService = createConfigService(enabled); + + return new AltchaService(configService, new UsedAltchaStoreService(configService)); +}; + describe('AltchaService', () => { - const configService = { - get: jest.fn(() => ({ enabled: true, hmacKey, maxNumber: 100_000 })) - } as unknown as ConfigService; + it('verifies and consumes a valid ALTCHA payload', async () => { + const service = createAltchaService(true); + const altcha = await solveChallengePayload(); - const service = new AltchaService(configService); - - it('verifies a valid ALTCHA payload', async () => { - const challenge = await createChallenge({ hmacKey, number: 42, maxNumber: 100_000 }); - const { promise } = solveChallenge(challenge.challenge, challenge.salt, challenge.algorithm, 100_000); - const solution = await promise; - - expect(solution).not.toBeNull(); - - const payload: Payload = { - algorithm: challenge.algorithm, - challenge: challenge.challenge, - number: solution!.number, - salt: challenge.salt, - signature: challenge.signature - }; - - await expect(service.verifyPayload(encodePayload(payload))).resolves.toBe(true); + await expect(service.verifySolution(altcha)).resolves.toBe(AltchaVerificationStatus.Ok); }); it('rejects an invalid ALTCHA payload', async () => { - await expect(service.verifyPayload('not-valid-base64-json')).resolves.toBe(false); + const service = createAltchaService(true); + + await expect(service.verifySolution('not-valid-base64-json')).resolves.toBe( + AltchaVerificationStatus.Invalid + ); + }); + + it('rejects an expired ALTCHA payload', async () => { + const service = createAltchaService(true); + const altcha = await encodeSolvedChallenge( + await createChallenge({ + hmacKey, + number: 42, + maxNumber: 100_000, + expires: new Date(Date.now() - 60_000) + }) + ); + + await expect(service.verifySolution(altcha)).resolves.toBe(AltchaVerificationStatus.Invalid); + }); + + it('rejects a payload signed with a different hmac key', async () => { + const service = createAltchaService(true); + const altcha = await encodeSolvedChallenge( + await createChallenge({ hmacKey: 'other-key', number: 42, maxNumber: 100_000 }) + ); + + await expect(service.verifySolution(altcha)).resolves.toBe(AltchaVerificationStatus.Invalid); + }); + + it('rejects a missing ALTCHA payload when enabled', async () => { + const service = createAltchaService(true); + + await expect(service.verifySolution(undefined)).resolves.toBe(AltchaVerificationStatus.Missing); + await expect(service.verifySolution('')).resolves.toBe(AltchaVerificationStatus.Missing); + }); + + it('rejects an immediate replay', async () => { + const service = createAltchaService(true); + const altcha = await solveChallengePayload(); + + await expect(service.verifySolution(altcha)).resolves.toBe(AltchaVerificationStatus.Ok); + await expect(service.verifySolution(altcha)).resolves.toBe(AltchaVerificationStatus.AlreadyUsed); + }); + + it('skips verification when ALTCHA is disabled', async () => { + const service = createAltchaService(false); + + await expect(service.verifySolution(undefined)).resolves.toBe(AltchaVerificationStatus.Ok); + }); + + it('does not enforce replay protection when ALTCHA is disabled', async () => { + const service = createAltchaService(false); + const altcha = await solveChallengePayload(); + + await expect(service.verifySolution(altcha)).resolves.toBe(AltchaVerificationStatus.Ok); + await expect(service.verifySolution(altcha)).resolves.toBe(AltchaVerificationStatus.Ok); + }); + + describe('createChallenge', () => { + it('applies maxNumber and challenge TTL from config', async () => { + const service = createAltchaService(true); + const before = Date.now(); + + const challenge = await service.createChallenge(); + + expect(challenge.maxnumber).toBe(100_000); + expect(challenge.signature).toBeTruthy(); + + const { expires } = extractParams(challenge); + const expiresAtMs = Number(expires) * 1000; + + expect(expiresAtMs).toBeGreaterThanOrEqual(before + 600_000 - 1000); + expect(expiresAtMs).toBeLessThanOrEqual(before + 600_000 + 5000); + }); }); }); diff --git a/backend/src/modules/altcha/services/AltchaService.ts b/backend/src/modules/altcha/services/AltchaService.ts index 137cafb..d49d2f4 100644 --- a/backend/src/modules/altcha/services/AltchaService.ts +++ b/backend/src/modules/altcha/services/AltchaService.ts @@ -1,25 +1,52 @@ import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { createChallenge, verifySolution } from 'altcha-lib/v1'; +import dayjs from 'dayjs'; import type { Challenge } from 'altcha-lib/v1/types'; -import { Config } from '../../../types/Config'; +import { Config } from '../../../types/config/Config'; +import { AltchaVerificationStatus } from '../types/AltchaVerificationStatus'; +import { UsedAltchaStoreService } from './UsedAltchaStoreService'; @Injectable() export class AltchaService { - constructor(private readonly configService: ConfigService) {} + constructor( + private readonly configService: ConfigService, + private readonly usedAltchaStore: UsedAltchaStoreService + ) {} async createChallenge(): Promise { - const { hmacKey, maxNumber } = this.configService.get('altcha') as Config['altcha']; + const { hmacKey, maxNumber, challengeTtlMs } = this.configService.get('altcha') as Config['altcha']; return createChallenge({ hmacKey, - maxNumber + maxNumber, + expires: dayjs().add(challengeTtlMs, 'millisecond').toDate() }); } - async verifyPayload(altcha: string): Promise { - const { hmacKey } = this.configService.get('altcha') as Config['altcha']; + async verifySolution(altcha: string | undefined): Promise { + const { enabled, hmacKey } = this.configService.get('altcha') as Config['altcha']; - return verifySolution(altcha, hmacKey); + if (!enabled) { + return AltchaVerificationStatus.Ok; + } + + if (!altcha) { + return AltchaVerificationStatus.Missing; + } + + const verified = await verifySolution(altcha, hmacKey); + + if (!verified) { + return AltchaVerificationStatus.Invalid; + } + + const consumed = this.usedAltchaStore.tryConsume(altcha); + + if (!consumed) { + return AltchaVerificationStatus.AlreadyUsed; + } + + return AltchaVerificationStatus.Ok; } } diff --git a/backend/src/modules/altcha/services/UsedAltchaStoreService.spec.ts b/backend/src/modules/altcha/services/UsedAltchaStoreService.spec.ts new file mode 100644 index 0000000..a50f138 --- /dev/null +++ b/backend/src/modules/altcha/services/UsedAltchaStoreService.spec.ts @@ -0,0 +1,88 @@ +import { ConfigService } from '@nestjs/config'; +import { UsedAltchaStoreService } from './UsedAltchaStoreService'; + +const createConfigService = (challengeTtlMs: number): ConfigService => + ({ + get: jest.fn((key: string) => { + if (key === 'altcha') { + return { challengeTtlMs }; + } + + return undefined; + }) + }) as unknown as ConfigService; + +describe('UsedAltchaStoreService', () => { + it('allows the first consume and rejects an immediate replay', () => { + const store = new UsedAltchaStoreService(createConfigService(60_000)); + const altcha = 'valid-solution-payload'; + + expect(store.tryConsume(altcha)).toBe(true); + expect(store.tryConsume(altcha)).toBe(false); + }); + + it('rejects replay until prune removes the entry after expiry', () => { + const store = new UsedAltchaStoreService(createConfigService(1)); + const altcha = 'expires-quickly'; + + expect(store.tryConsume(altcha)).toBe(true); + + return new Promise(resolve => { + setTimeout(() => { + expect(store.tryConsume(altcha)).toBe(false); + store.pruneExpired(); + expect(store.tryConsume(altcha)).toBe(true); + resolve(); + }, 5); + }); + }); + + it('pruneExpired removes expired entries', () => { + const store = new UsedAltchaStoreService(createConfigService(1)); + const altcha = 'prune-me'; + + expect(store.tryConsume(altcha)).toBe(true); + + return new Promise(resolve => { + setTimeout(() => { + store.pruneExpired(); + expect(store.tryConsume(altcha)).toBe(true); + resolve(); + }, 5); + }); + }); + + it('tracks different payloads independently', () => { + const store = new UsedAltchaStoreService(createConfigService(60_000)); + + expect(store.tryConsume('payload-a')).toBe(true); + expect(store.tryConsume('payload-b')).toBe(true); + expect(store.tryConsume('payload-a')).toBe(false); + expect(store.tryConsume('payload-b')).toBe(false); + }); + + it('pruneExpired does not remove entries before their expiry', () => { + const store = new UsedAltchaStoreService(createConfigService(60_000)); + + expect(store.tryConsume('still-active')).toBe(true); + store.pruneExpired(); + + expect(store.tryConsume('still-active')).toBe(false); + }); + + it('pruneExpired clears every expired entry in one pass', () => { + const store = new UsedAltchaStoreService(createConfigService(1)); + + expect(store.tryConsume('first')).toBe(true); + expect(store.tryConsume('second')).toBe(true); + + return new Promise(resolve => { + setTimeout(() => { + store.pruneExpired(); + expect(store.tryConsume('first')).toBe(true); + expect(store.tryConsume('second')).toBe(true); + resolve(); + }, 5); + }); + }); +}); diff --git a/backend/src/modules/altcha/services/UsedAltchaStoreService.ts b/backend/src/modules/altcha/services/UsedAltchaStoreService.ts new file mode 100644 index 0000000..0dcb068 --- /dev/null +++ b/backend/src/modules/altcha/services/UsedAltchaStoreService.ts @@ -0,0 +1,38 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { Cron, CronExpression } from '@nestjs/schedule'; +import dayjs from 'dayjs'; +import { createHash } from 'node:crypto'; +import { Config } from '../../../types/config/Config'; + +@Injectable() +export class UsedAltchaStoreService { + private readonly entries = new Map(); + + constructor(private readonly configService: ConfigService) {} + + tryConsume(altcha: string): boolean { + const key = createHash('sha256').update(altcha, 'utf8').digest('hex'); + + if (this.entries.has(key)) { + return false; + } + + const { challengeTtlMs } = this.configService.get('altcha') as Config['altcha']; + + this.entries.set(key, dayjs().add(challengeTtlMs, 'millisecond').valueOf()); + + return true; + } + + @Cron(CronExpression.EVERY_MINUTE) + pruneExpired(): void { + const now = dayjs(); + + for (const [key, expiresAt] of this.entries) { + if (now.isAfter(expiresAt)) { + this.entries.delete(key); + } + } + } +} diff --git a/backend/src/modules/altcha/types/AltchaVerificationStatus.ts b/backend/src/modules/altcha/types/AltchaVerificationStatus.ts new file mode 100644 index 0000000..05e73ba --- /dev/null +++ b/backend/src/modules/altcha/types/AltchaVerificationStatus.ts @@ -0,0 +1,6 @@ +export enum AltchaVerificationStatus { + Ok = 'ok', + Missing = 'missing', + Invalid = 'invalid', + AlreadyUsed = 'already_used' +} diff --git a/backend/src/types/HttpMethod.ts b/backend/src/types/HttpMethod.ts new file mode 100644 index 0000000..ed3a6b1 --- /dev/null +++ b/backend/src/types/HttpMethod.ts @@ -0,0 +1,9 @@ +export enum HttpMethod { + Get = 'GET', + Head = 'HEAD', + Post = 'POST', + Put = 'PUT', + Patch = 'PATCH', + Delete = 'DELETE', + Options = 'OPTIONS' +} From d4529376c69cd573df9481407740a520a275f033 Mon Sep 17 00:00:00 2001 From: nobswebdev Date: Mon, 12 Oct 2026 20:18:40 +0200 Subject: [PATCH 7/9] Add single-image zk and hotlink upload API with service tests. Replace batch DTOs with per-mode endpoints, persist ciphertext with file-type checks, and return a hashed delete token for future revocation. --- backend/src/modules/storage/StorageModule.ts | 3 +- backend/src/modules/upload/UploadModule.ts | 7 +- .../upload/controllers/UploadController.ts | 23 ++ .../upload/dto/CreateHotlinkUploadDto.ts | 22 +- .../upload/dto/CreateHotlinkUploadItemDto.ts | 9 - .../dto/CreateUploadCiphertextItemDto.ts | 12 -- .../dto/CreateZkPasswordUploadItemDto.ts | 17 -- .../modules/upload/dto/CreateZkUploadDto.ts | 46 ++-- .../upload/dto/UploadCiphertextOptionsDto.ts | 20 ++ ...BatchOptionsDto.ts => UploadOptionsDto.ts} | 8 +- backend/src/modules/upload/entities/Upload.ts | 4 +- .../upload/services/UploadService.spec.ts | 202 ++++++++++++++++++ .../modules/upload/services/UploadService.ts | 123 +++++++++++ .../upload/types/CreatedUploadResult.ts | 7 + backend/src/utils/fromBase64.ts | 1 + 15 files changed, 412 insertions(+), 92 deletions(-) create mode 100644 backend/src/modules/upload/controllers/UploadController.ts delete mode 100644 backend/src/modules/upload/dto/CreateHotlinkUploadItemDto.ts delete mode 100644 backend/src/modules/upload/dto/CreateUploadCiphertextItemDto.ts delete mode 100644 backend/src/modules/upload/dto/CreateZkPasswordUploadItemDto.ts create mode 100644 backend/src/modules/upload/dto/UploadCiphertextOptionsDto.ts rename backend/src/modules/upload/dto/{UploadBatchOptionsDto.ts => UploadOptionsDto.ts} (63%) create mode 100644 backend/src/modules/upload/services/UploadService.spec.ts create mode 100644 backend/src/modules/upload/services/UploadService.ts create mode 100644 backend/src/modules/upload/types/CreatedUploadResult.ts create mode 100644 backend/src/utils/fromBase64.ts diff --git a/backend/src/modules/storage/StorageModule.ts b/backend/src/modules/storage/StorageModule.ts index 9497dcd..1ea11c8 100644 --- a/backend/src/modules/storage/StorageModule.ts +++ b/backend/src/modules/storage/StorageModule.ts @@ -2,6 +2,7 @@ import { Module } from '@nestjs/common'; import { StorageService } from './services/StorageService'; @Module({ - providers: [StorageService] + providers: [StorageService], + exports: [StorageService] }) export class StorageModule {} diff --git a/backend/src/modules/upload/UploadModule.ts b/backend/src/modules/upload/UploadModule.ts index 788ff16..29c42bb 100644 --- a/backend/src/modules/upload/UploadModule.ts +++ b/backend/src/modules/upload/UploadModule.ts @@ -1,10 +1,15 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { StorageModule } from '../storage/StorageModule'; +import { UploadController } from './controllers/UploadController'; import { Upload } from './entities/Upload'; import { UploadHotlink } from './entities/UploadHotlink'; import { UploadZk } from './entities/UploadZk'; +import { UploadService } from './services/UploadService'; @Module({ - imports: [TypeOrmModule.forFeature([Upload, UploadZk, UploadHotlink])] + imports: [TypeOrmModule.forFeature([Upload, UploadZk, UploadHotlink]), StorageModule], + controllers: [UploadController], + providers: [UploadService] }) export class UploadModule {} diff --git a/backend/src/modules/upload/controllers/UploadController.ts b/backend/src/modules/upload/controllers/UploadController.ts new file mode 100644 index 0000000..04d6754 --- /dev/null +++ b/backend/src/modules/upload/controllers/UploadController.ts @@ -0,0 +1,23 @@ +import { Body, Controller, Post } from '@nestjs/common'; +import { Throttle } from '@nestjs/throttler'; +import { throttleProfiles } from '../../../config/throttleProfiles'; +import { CreateHotlinkUploadDto } from '../dto/CreateHotlinkUploadDto'; +import { CreateZkUploadDto } from '../dto/CreateZkUploadDto'; +import { UploadService } from '../services/UploadService'; + +@Controller('uploads') +export class UploadController { + constructor(private readonly uploadService: UploadService) {} + + @Post('zk') + @Throttle(throttleProfiles.upload) + createZk(@Body() dto: CreateZkUploadDto) { + return this.uploadService.createZkUpload(dto); + } + + @Post('hotlink') + @Throttle(throttleProfiles.upload) + createHotlink(@Body() dto: CreateHotlinkUploadDto) { + return this.uploadService.createHotlinkUpload(dto); + } +} diff --git a/backend/src/modules/upload/dto/CreateHotlinkUploadDto.ts b/backend/src/modules/upload/dto/CreateHotlinkUploadDto.ts index 9d851bd..9bc16de 100644 --- a/backend/src/modules/upload/dto/CreateHotlinkUploadDto.ts +++ b/backend/src/modules/upload/dto/CreateHotlinkUploadDto.ts @@ -1,19 +1,9 @@ -import { Type } from 'class-transformer'; -import { ArrayMaxSize, ArrayMinSize, IsArray, IsNotEmpty, ValidateNested } from 'class-validator'; -import { getAppConfig } from '../../../config'; -import { CreateHotlinkUploadItemDto } from './CreateHotlinkUploadItemDto'; -import { UploadBatchOptionsDto } from './UploadBatchOptionsDto'; +import { IsMimeType, IsNotEmpty, IsString } from 'class-validator'; +import { UploadCiphertextOptionsDto } from './UploadCiphertextOptionsDto'; -const { - validation: { maxImagesPerUpload } -} = getAppConfig(); - -export class CreateHotlinkUploadDto extends UploadBatchOptionsDto { +export class CreateHotlinkUploadDto extends UploadCiphertextOptionsDto { @IsNotEmpty() - @IsArray() - @ArrayMinSize(1) - @ArrayMaxSize(maxImagesPerUpload) - @ValidateNested({ each: true }) - @Type(() => CreateHotlinkUploadItemDto) - items: CreateHotlinkUploadItemDto[]; + @IsString() + @IsMimeType() + mimeType: string; } diff --git a/backend/src/modules/upload/dto/CreateHotlinkUploadItemDto.ts b/backend/src/modules/upload/dto/CreateHotlinkUploadItemDto.ts deleted file mode 100644 index b14b8cf..0000000 --- a/backend/src/modules/upload/dto/CreateHotlinkUploadItemDto.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { IsMimeType, IsNotEmpty, IsString } from 'class-validator'; -import { CreateUploadCiphertextItemDto } from './CreateUploadCiphertextItemDto'; - -export class CreateHotlinkUploadItemDto extends CreateUploadCiphertextItemDto { - @IsNotEmpty() - @IsString() - @IsMimeType() - mimeType: string; -} diff --git a/backend/src/modules/upload/dto/CreateUploadCiphertextItemDto.ts b/backend/src/modules/upload/dto/CreateUploadCiphertextItemDto.ts deleted file mode 100644 index cba342c..0000000 --- a/backend/src/modules/upload/dto/CreateUploadCiphertextItemDto.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { IsNotEmpty } from 'class-validator'; -import { IsBase64 } from '../../../validation/decorators/isBase64'; - -export class CreateUploadCiphertextItemDto { - @IsNotEmpty() - @IsBase64({ byteLength: 12 }) - iv: string; - - @IsNotEmpty() - @IsBase64() - ciphertext: string; -} diff --git a/backend/src/modules/upload/dto/CreateZkPasswordUploadItemDto.ts b/backend/src/modules/upload/dto/CreateZkPasswordUploadItemDto.ts deleted file mode 100644 index fc3a5b1..0000000 --- a/backend/src/modules/upload/dto/CreateZkPasswordUploadItemDto.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { IsNotEmpty } from 'class-validator'; -import { IsBase64 } from '../../../validation/decorators/isBase64'; -import { CreateUploadCiphertextItemDto } from './CreateUploadCiphertextItemDto'; - -export class CreateZkPasswordUploadItemDto extends CreateUploadCiphertextItemDto { - @IsNotEmpty() - @IsBase64() - wrappedDek: string; - - @IsNotEmpty() - @IsBase64({ byteLength: 12 }) - wrapIv: string; - - @IsNotEmpty() - @IsBase64() - salt: string; -} diff --git a/backend/src/modules/upload/dto/CreateZkUploadDto.ts b/backend/src/modules/upload/dto/CreateZkUploadDto.ts index ccab0b6..9e5ca8f 100644 --- a/backend/src/modules/upload/dto/CreateZkUploadDto.ts +++ b/backend/src/modules/upload/dto/CreateZkUploadDto.ts @@ -1,40 +1,24 @@ -import { plainToInstance, Transform } from 'class-transformer'; -import { - ArrayMaxSize, - ArrayMinSize, - IsArray, - IsBoolean, - IsNotEmpty, - ValidateNested -} from 'class-validator'; -import { getAppConfig } from '../../../config'; -import { CreateUploadCiphertextItemDto } from './CreateUploadCiphertextItemDto'; -import { CreateZkPasswordUploadItemDto } from './CreateZkPasswordUploadItemDto'; -import { UploadBatchOptionsDto } from './UploadBatchOptionsDto'; +import { IsBoolean, IsNotEmpty, ValidateIf } from 'class-validator'; +import { IsBase64 } from '../../../validation/decorators/isBase64'; +import { UploadCiphertextOptionsDto } from './UploadCiphertextOptionsDto'; -const { - validation: { maxImagesPerUpload } -} = getAppConfig(); - -export class CreateZkUploadDto extends UploadBatchOptionsDto { +export class CreateZkUploadDto extends UploadCiphertextOptionsDto { @IsNotEmpty() @IsBoolean() passwordProtected: boolean; + @ValidateIf((dto: CreateZkUploadDto) => dto.passwordProtected === true) @IsNotEmpty() - @IsArray() - @ArrayMinSize(1) - @ArrayMaxSize(maxImagesPerUpload) - @ValidateNested({ each: true }) - @Transform(({ value, obj }: { value: unknown; obj: CreateZkUploadDto }) => { - if (!Array.isArray(value)) { - return value; - } + @IsBase64({ maxByteLength: 128 }) + wrappedDek?: string; - const ItemClass = - obj.passwordProtected === true ? CreateZkPasswordUploadItemDto : CreateUploadCiphertextItemDto; + @ValidateIf((dto: CreateZkUploadDto) => dto.passwordProtected === true) + @IsNotEmpty() + @IsBase64({ byteLength: 12 }) + wrapIv?: string; - return value.map(item => plainToInstance(ItemClass, item)); - }) - items: Array; + @ValidateIf((dto: CreateZkUploadDto) => dto.passwordProtected === true) + @IsNotEmpty() + @IsBase64({ maxByteLength: 64 }) + salt?: string; } diff --git a/backend/src/modules/upload/dto/UploadCiphertextOptionsDto.ts b/backend/src/modules/upload/dto/UploadCiphertextOptionsDto.ts new file mode 100644 index 0000000..5414cf9 --- /dev/null +++ b/backend/src/modules/upload/dto/UploadCiphertextOptionsDto.ts @@ -0,0 +1,20 @@ +import { IsNotEmpty } from 'class-validator'; +import { getAppConfig } from '../../../config'; +import { IsBase64 } from '../../../validation/decorators/isBase64'; +import { UploadOptionsDto } from './UploadOptionsDto'; + +const { + validation: { + upload: { maxCiphertextBytes } + } +} = getAppConfig(); + +export class UploadCiphertextOptionsDto extends UploadOptionsDto { + @IsNotEmpty() + @IsBase64({ byteLength: 12 }) + iv: string; + + @IsNotEmpty() + @IsBase64({ maxByteLength: maxCiphertextBytes }) + ciphertext: string; +} diff --git a/backend/src/modules/upload/dto/UploadBatchOptionsDto.ts b/backend/src/modules/upload/dto/UploadOptionsDto.ts similarity index 63% rename from backend/src/modules/upload/dto/UploadBatchOptionsDto.ts rename to backend/src/modules/upload/dto/UploadOptionsDto.ts index 4070d34..764adbd 100644 --- a/backend/src/modules/upload/dto/UploadBatchOptionsDto.ts +++ b/backend/src/modules/upload/dto/UploadOptionsDto.ts @@ -2,13 +2,15 @@ import { getAppConfig } from '../../../config'; import { NullOrDate, NullOrInt } from '../../../validation/decorators/nullOr'; const { - validation: { uploadMaxViewsMax } + validation: { + upload: { maxViewsMax } + } } = getAppConfig(); -export class UploadBatchOptionsDto { +export class UploadOptionsDto { @NullOrDate() expiresAt: Date | null; - @NullOrInt({ min: 1, max: uploadMaxViewsMax }) + @NullOrInt({ min: 1, max: maxViewsMax }) maxViews: number | null; } diff --git a/backend/src/modules/upload/entities/Upload.ts b/backend/src/modules/upload/entities/Upload.ts index 3d45923..45807e1 100644 --- a/backend/src/modules/upload/entities/Upload.ts +++ b/backend/src/modules/upload/entities/Upload.ts @@ -26,10 +26,10 @@ export class Upload { @Column() deleteTokenHash: string; - @OneToOne(() => UploadZk, zk => zk.upload) + @OneToOne(() => UploadZk, zk => zk.upload, { cascade: true }) zk: UploadZk | null; - @OneToOne(() => UploadHotlink, hotlink => hotlink.upload) + @OneToOne(() => UploadHotlink, hotlink => hotlink.upload, { cascade: true }) hotlink: UploadHotlink | null; @Column({ type: 'timestamptz', nullable: true }) diff --git a/backend/src/modules/upload/services/UploadService.spec.ts b/backend/src/modules/upload/services/UploadService.spec.ts new file mode 100644 index 0000000..b52d2c9 --- /dev/null +++ b/backend/src/modules/upload/services/UploadService.spec.ts @@ -0,0 +1,202 @@ +import { BadRequestException } from '@nestjs/common'; +import { createHash } from 'node:crypto'; +import type { DataSource, EntityManager } from 'typeorm'; +import { fileTypeFromBuffer } from 'file-type'; +import { StorageService } from '../../storage/services/StorageService'; +import { UploadMode } from '../types/UploadMode'; + +jest.mock('@nestjs/typeorm', () => ({ + InjectDataSource: () => (): void => undefined +})); + +jest.mock('file-type', () => ({ + fileTypeFromBuffer: jest.fn() +})); + +const fixedId = '11111111-1111-4111-8111-111111111111'; +const fixedDeleteTokenBytes = Buffer.alloc(32, 0xab); + +jest.mock('node:crypto', () => { + const actual = jest.requireActual('node:crypto'); + + return { + ...actual, + randomUUID: jest.fn(() => fixedId), + randomBytes: jest.fn((size: number) => (size === 32 ? fixedDeleteTokenBytes : Buffer.alloc(size))) + }; +}); + +import { UploadService } from './UploadService'; + +const mockFileTypeFromBuffer = fileTypeFromBuffer as jest.MockedFunction; + +const ivBase64 = (): string => Buffer.alloc(12, 0x11).toString('base64'); + +const ciphertextBase64 = (fill = 0xcd): string => Buffer.alloc(64, fill).toString('base64'); + +const wrapFieldBase64 = (length: number): string => Buffer.alloc(length, 0x22).toString('base64'); + +const hashDeleteToken = (token: string): string => createHash('sha256').update(token, 'utf8').digest('hex'); + +describe('UploadService', () => { + let service: UploadService; + let dataSource: jest.Mocked>; + let storageService: jest.Mocked>; + let uploadRepository: { create: jest.Mock; save: jest.Mock }; + let transaction: jest.Mock; + + beforeEach(() => { + uploadRepository = { + create: jest.fn((entityLike: unknown) => entityLike), + save: jest.fn(async (entity: unknown) => entity) + }; + + transaction = jest.fn(async (runInTransaction: (manager: EntityManager) => Promise) => { + const manager = { + getRepository: jest.fn(() => uploadRepository) + } as unknown as EntityManager; + + return runInTransaction(manager); + }); + + dataSource = { transaction }; + storageService = { write: jest.fn().mockResolvedValue(undefined) }; + + mockFileTypeFromBuffer.mockResolvedValue(undefined); + + service = new UploadService( + dataSource as unknown as DataSource, + storageService as unknown as StorageService + ); + }); + + describe('createHotlinkUpload', () => { + it('persists upload with hotlink relation and writes ciphertext', async () => { + const ciphertext = ciphertextBase64(); + const ciphertextBuffer = Buffer.from(ciphertext, 'base64'); + const iv = ivBase64(); + const deleteToken = fixedDeleteTokenBytes.toString('base64url'); + + const result = await service.createHotlinkUpload({ + iv, + ciphertext, + expiresAt: null, + maxViews: null, + mimeType: 'image/png' + }); + + expect(result).toEqual({ + id: fixedId, + mode: UploadMode.Hotlink, + deleteToken + }); + + expect(uploadRepository.create).toHaveBeenCalledWith({ + id: fixedId, + mode: UploadMode.Hotlink, + storagePath: fixedId, + iv: Buffer.from(iv, 'base64'), + maxViews: null, + deleteTokenHash: hashDeleteToken(deleteToken), + expiresAt: null, + hotlink: { mimeType: 'image/png' } + }); + expect(uploadRepository.save).toHaveBeenCalledTimes(1); + expect(storageService.write).toHaveBeenCalledWith(fixedId, ciphertextBuffer); + expect(transaction).toHaveBeenCalledTimes(1); + }); + }); + + describe('createZkUpload', () => { + it('persists zk upload without password wrap fields when not password protected', async () => { + const ciphertext = ciphertextBase64(); + + await service.createZkUpload({ + iv: ivBase64(), + ciphertext, + expiresAt: null, + maxViews: 5, + passwordProtected: false + }); + + expect(uploadRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ + mode: UploadMode.Zk, + maxViews: 5, + zk: { + passwordProtected: false, + wrappedDek: null, + wrapIv: null, + salt: null + } + }) + ); + expect(storageService.write).toHaveBeenCalledWith(fixedId, Buffer.from(ciphertext, 'base64')); + }); + + it('decodes password wrap fields when password protected', async () => { + const wrappedDek = wrapFieldBase64(32); + const wrapIv = ivBase64(); + const salt = wrapFieldBase64(16); + + await service.createZkUpload({ + iv: ivBase64(), + ciphertext: ciphertextBase64(), + expiresAt: null, + maxViews: null, + passwordProtected: true, + wrappedDek, + wrapIv, + salt + }); + + expect(uploadRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ + zk: { + passwordProtected: true, + wrappedDek: Buffer.from(wrappedDek, 'base64'), + wrapIv: Buffer.from(wrapIv, 'base64'), + salt: Buffer.from(salt, 'base64') + } + }) + ); + }); + }); + + describe('throwIfUnencryptedImage', () => { + it('rejects ciphertext that sniffs as an image before persisting', async () => { + mockFileTypeFromBuffer.mockResolvedValue({ ext: 'jpg', mime: 'image/jpeg' }); + + await expect( + service.createHotlinkUpload({ + iv: ivBase64(), + ciphertext: ciphertextBase64(0xff), + expiresAt: null, + maxViews: null, + mimeType: 'image/jpeg' + }) + ).rejects.toThrow(BadRequestException); + + expect(transaction).not.toHaveBeenCalled(); + expect(storageService.write).not.toHaveBeenCalled(); + }); + }); + + describe('transaction rollback on storage failure', () => { + it('propagates storage write errors from the transaction', async () => { + storageService.write.mockRejectedValue(new Error('disk full')); + + await expect( + service.createHotlinkUpload({ + iv: ivBase64(), + ciphertext: ciphertextBase64(), + expiresAt: null, + maxViews: null, + mimeType: 'image/png' + }) + ).rejects.toThrow('disk full'); + + expect(uploadRepository.save).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/backend/src/modules/upload/services/UploadService.ts b/backend/src/modules/upload/services/UploadService.ts new file mode 100644 index 0000000..dfbe41d --- /dev/null +++ b/backend/src/modules/upload/services/UploadService.ts @@ -0,0 +1,123 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { fileTypeFromBuffer } from 'file-type'; +import { createHash, randomBytes, randomUUID } from 'node:crypto'; +import { DataSource } from 'typeorm'; +import { fromBase64 } from '../../../utils/fromBase64'; +import { StorageService } from '../../storage/services/StorageService'; +import { CreateHotlinkUploadDto } from '../dto/CreateHotlinkUploadDto'; +import { CreateZkUploadDto } from '../dto/CreateZkUploadDto'; +import { Upload } from '../entities/Upload'; +import { CreatedUploadResult } from '../types/CreatedUploadResult'; +import { UploadMode } from '../types/UploadMode'; + +@Injectable() +export class UploadService { + constructor( + @InjectDataSource() + private readonly dataSource: DataSource, + private readonly storageService: StorageService + ) {} + + async createZkUpload({ + iv, + ciphertext, + expiresAt, + maxViews, + passwordProtected, + wrappedDek, + wrapIv, + salt + }: CreateZkUploadDto): Promise { + const ciphertextBuffer = fromBase64(ciphertext); + + await this.throwIfUnencryptedImage(ciphertextBuffer); + + const deleteToken = randomBytes(32).toString('base64url'); + const id = randomUUID(); + const storagePath = id; + + await this.dataSource.transaction(async manager => { + const uploadRepository = manager.getRepository(Upload); + + const upload = uploadRepository.create({ + id, + mode: UploadMode.Zk, + storagePath, + iv: fromBase64(iv), + maxViews, + deleteTokenHash: this.hashDeleteToken(deleteToken), + expiresAt, + zk: { + passwordProtected, + wrappedDek: passwordProtected && wrappedDek ? fromBase64(wrappedDek) : null, + wrapIv: passwordProtected && wrapIv ? fromBase64(wrapIv) : null, + salt: passwordProtected && salt ? fromBase64(salt) : null + } + }); + + await uploadRepository.save(upload); + + await this.storageService.write(storagePath, ciphertextBuffer); + }); + + return { + id, + mode: UploadMode.Zk, + deleteToken + }; + } + + async createHotlinkUpload({ + iv, + ciphertext, + expiresAt, + maxViews, + mimeType + }: CreateHotlinkUploadDto): Promise { + const ciphertextBuffer = fromBase64(ciphertext); + + await this.throwIfUnencryptedImage(ciphertextBuffer); + + const deleteToken = randomBytes(32).toString('base64url'); + const id = randomUUID(); + const storagePath = id; + + await this.dataSource.transaction(async manager => { + const uploadRepository = manager.getRepository(Upload); + + const upload = uploadRepository.create({ + id, + mode: UploadMode.Hotlink, + storagePath, + iv: fromBase64(iv), + maxViews, + deleteTokenHash: this.hashDeleteToken(deleteToken), + expiresAt, + hotlink: { mimeType } + }); + + await uploadRepository.save(upload); + + await this.storageService.write(storagePath, ciphertextBuffer); + }); + + return { + id, + mode: UploadMode.Hotlink, + deleteToken + }; + } + + private hashDeleteToken(token: string): string { + return createHash('sha256').update(token, 'utf8').digest('hex'); + } + + private async throwIfUnencryptedImage(ciphertext: Buffer): Promise { + const detected = await fileTypeFromBuffer(ciphertext); + + if (detected?.mime.startsWith('image/')) { + throw new BadRequestException('Ciphertext must not be a plaintext image'); + } + } +} diff --git a/backend/src/modules/upload/types/CreatedUploadResult.ts b/backend/src/modules/upload/types/CreatedUploadResult.ts new file mode 100644 index 0000000..7cf4cef --- /dev/null +++ b/backend/src/modules/upload/types/CreatedUploadResult.ts @@ -0,0 +1,7 @@ +import { UploadMode } from './UploadMode'; + +export type CreatedUploadResult = { + id: string; + mode: UploadMode; + deleteToken: string; +}; diff --git a/backend/src/utils/fromBase64.ts b/backend/src/utils/fromBase64.ts new file mode 100644 index 0000000..d0fd5e9 --- /dev/null +++ b/backend/src/utils/fromBase64.ts @@ -0,0 +1 @@ +export const fromBase64 = (value: string): Buffer => Buffer.from(value, 'base64'); From bffb6de14574d306bf0fc62cd4ff4381583e0b52 Mon Sep 17 00:00:00 2001 From: nobswebdev Date: Mon, 12 Oct 2026 20:18:43 +0200 Subject: [PATCH 8/9] Align frontend env with single-image upload validation limits. --- frontend/src/config/index.ts | 7 +++++-- frontend/src/vite-env.d.ts | 3 ++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/frontend/src/config/index.ts b/frontend/src/config/index.ts index 76d2b02..7a5ae00 100644 --- a/frontend/src/config/index.ts +++ b/frontend/src/config/index.ts @@ -15,7 +15,10 @@ export const config = { clearnetDomain: env('VITE_CLEARNET_DOMAIN'), onionHostname: env('VITE_ONION_HOSTNAME'), validation: { - maxImagesPerUpload: envInt('VITE_VALIDATION_UPLOAD_MAX_IMAGES_PER_UPLOAD'), - uploadMaxViewsMax: envInt('VITE_VALIDATION_UPLOAD_MAX_VIEWS_MAX') + upload: { + maxQueueSize: envInt('VITE_UPLOAD_MAX_QUEUE_SIZE'), + maxViewsMax: envInt('VITE_VALIDATION_UPLOAD_MAX_VIEWS_MAX'), + maxImageBytes: envInt('VITE_VALIDATION_UPLOAD_MAX_IMAGE_BYTES') + } } }; diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts index dab841d..aa316f0 100644 --- a/frontend/src/vite-env.d.ts +++ b/frontend/src/vite-env.d.ts @@ -4,8 +4,9 @@ interface ImportMetaEnv { readonly VITE_API_BASE_URL: string; readonly VITE_CLEARNET_DOMAIN: string; readonly VITE_ONION_HOSTNAME: string; - readonly VITE_VALIDATION_UPLOAD_MAX_IMAGES_PER_UPLOAD: string; + readonly VITE_UPLOAD_MAX_QUEUE_SIZE: string; readonly VITE_VALIDATION_UPLOAD_MAX_VIEWS_MAX: string; + readonly VITE_VALIDATION_UPLOAD_MAX_IMAGE_BYTES: string; } interface ImportMeta { From 8b42dbb9626342e3b45db615132960c55823d712 Mon Sep 17 00:00:00 2001 From: nobswebdev Date: Mon, 12 Oct 2026 21:45:44 +0200 Subject: [PATCH 9/9] Use Node 22 in dev Docker images for file-type v22. --- backend/Dockerfile.dev | 2 +- frontend/Dockerfile.dev | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/Dockerfile.dev b/backend/Dockerfile.dev index 40195d9..d56d43c 100644 --- a/backend/Dockerfile.dev +++ b/backend/Dockerfile.dev @@ -1,4 +1,4 @@ -FROM node:20-alpine +FROM node:22-alpine WORKDIR /backend diff --git a/frontend/Dockerfile.dev b/frontend/Dockerfile.dev index 764aca1..553bfe0 100644 --- a/frontend/Dockerfile.dev +++ b/frontend/Dockerfile.dev @@ -1,4 +1,4 @@ -FROM node:20-alpine +FROM node:22-alpine WORKDIR /frontend