Slave/upload api #5

Merged
nobswebdev merged 9 commits from slave/upload-api into master 2026-10-12 20:12:16 +00:00
15 changed files with 90 additions and 29 deletions
Showing only changes of commit 96383a7426 - Show all commits
+5 -4
View File
@@ -18,11 +18,10 @@ NODE_ENV=development
CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173 CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
MULTER_MAX_FILE_BYTES=10485760
ALTCHA_ENABLED=true ALTCHA_ENABLED=true
ALTCHA_HMAC_KEY=change-me-use-long-random-secret ALTCHA_HMAC_KEY=change-me-use-long-random-secret
ALTCHA_MAX_NUMBER=50000 ALTCHA_MAX_NUMBER=50000
ALTCHA_CHALLENGE_TTL_MS=600000
NEVER_MODE_INACTIVITY_DAYS=365 NEVER_MODE_INACTIVITY_DAYS=365
@@ -31,11 +30,13 @@ THROTTLE_LIMIT=1000
THROTTLE_UPLOAD_TTL_MS=60000 THROTTLE_UPLOAD_TTL_MS=60000
THROTTLE_UPLOAD_LIMIT=20 THROTTLE_UPLOAD_LIMIT=20
VALIDATION_UPLOAD_MAX_IMAGES_PER_UPLOAD=10
VALIDATION_UPLOAD_MAX_VIEWS_MAX=1000000 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_API_BASE_URL=http://localhost:3000/api
VITE_CLEARNET_DOMAIN=localhost:5173 VITE_CLEARNET_DOMAIN=localhost:5173
VITE_ONION_HOSTNAME= 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_VIEWS_MAX=1000000
VITE_VALIDATION_UPLOAD_MAX_IMAGE_BYTES=10485760 # 10MiB
+19 -6
View File
@@ -1,7 +1,7 @@
import { AppConfig } from '../types/AppConfig'; import { AltchaConfig } from '../types/config/AltchaConfig';
import { AltchaConfig } from '../types/AltchaConfig'; import { AppConfig } from '../types/config/AppConfig';
import { PostgresConfig } from '../types/PostgresConfig';
import { NodeEnv } from '../types/NodeEnv'; import { NodeEnv } from '../types/NodeEnv';
import { PostgresConfig } from '../types/config/PostgresConfig';
const env = (key: string): string => process.env[key] || ''; const env = (key: string): string => process.env[key] || '';
@@ -28,12 +28,19 @@ export const getAltchaConfig = (): AltchaConfig => {
return { return {
enabled: isEnabled('ALTCHA_ENABLED'), enabled: isEnabled('ALTCHA_ENABLED'),
hmacKey: env('ALTCHA_HMAC_KEY'), 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 => { export const getAppConfig = (): AppConfig => {
const aesGcmCiphertextOverheadBytes = 16;
const jsonBase64ByteRatioNumerator = 4;
const jsonBase64ByteRatioDenominator = 3;
const rawOrigins = env('CORS_ORIGINS'); const rawOrigins = env('CORS_ORIGINS');
const maxImageBytes = envInt('VALIDATION_UPLOAD_MAX_IMAGE_BYTES');
const jsonBodyPaddingBytes = envInt('VALIDATION_UPLOAD_JSON_BODY_PADDING_BYTES');
return { return {
port: envInt('BACKEND_PORT'), port: envInt('BACKEND_PORT'),
@@ -51,8 +58,14 @@ export const getAppConfig = (): AppConfig => {
limit: envInt('THROTTLE_UPLOAD_LIMIT') limit: envInt('THROTTLE_UPLOAD_LIMIT')
}, },
validation: { validation: {
maxImagesPerUpload: envInt('VALIDATION_UPLOAD_MAX_IMAGES_PER_UPLOAD'), upload: {
uploadMaxViewsMax: envInt('VALIDATION_UPLOAD_MAX_VIEWS_MAX') maxViewsMax: envInt('VALIDATION_UPLOAD_MAX_VIEWS_MAX'),
maxImageBytes,
maxCiphertextBytes: maxImageBytes + aesGcmCiphertextOverheadBytes,
maxRequestBodyBytes:
Math.ceil((maxImageBytes * jsonBase64ByteRatioNumerator) / jsonBase64ByteRatioDenominator) +
jsonBodyPaddingBytes
}
} }
}; };
}; };
+3 -2
View File
@@ -11,11 +11,12 @@ const validConfig = (overrides: Record<string, unknown> = {}): Record<string, un
POSTGRES_PORT: 5432, POSTGRES_PORT: 5432,
POSTGRES_MIGRATIONS_RUN: 'false', POSTGRES_MIGRATIONS_RUN: 'false',
CORS_ORIGINS: 'http://localhost:5173', CORS_ORIGINS: 'http://localhost:5173',
VALIDATION_UPLOAD_MAX_IMAGES_PER_UPLOAD: 10, VALIDATION_UPLOAD_MAX_IMAGE_BYTES: 10485760,
MULTER_MAX_FILE_BYTES: 10485760, VALIDATION_UPLOAD_JSON_BODY_PADDING_BYTES: 2_097_152,
ALTCHA_ENABLED: 'true', ALTCHA_ENABLED: 'true',
ALTCHA_HMAC_KEY: 'secret', ALTCHA_HMAC_KEY: 'secret',
ALTCHA_MAX_NUMBER: 50000, ALTCHA_MAX_NUMBER: 50000,
ALTCHA_CHALLENGE_TTL_MS: 600_000,
NEVER_MODE_INACTIVITY_DAYS: 365, NEVER_MODE_INACTIVITY_DAYS: 365,
THROTTLE_TTL_MS: 60000, THROTTLE_TTL_MS: 60000,
THROTTLE_LIMIT: 1000, THROTTLE_LIMIT: 1000,
+16 -10
View File
@@ -45,12 +45,6 @@ class EnvironmentVariables {
@IsString() @IsString()
CORS_ORIGINS: string; CORS_ORIGINS: string;
@IsNotEmpty()
@IsNumber()
@Min(1)
@Max(50 * 1024 * 1024)
MULTER_MAX_FILE_BYTES: number;
@IsNotEmpty() @IsNotEmpty()
@IsIn(BOOLEAN_STRING_VALUES) @IsIn(BOOLEAN_STRING_VALUES)
ALTCHA_ENABLED: BooleanStringValue; ALTCHA_ENABLED: BooleanStringValue;
@@ -65,6 +59,12 @@ class EnvironmentVariables {
@Max(1_000_000) @Max(1_000_000)
ALTCHA_MAX_NUMBER: number; ALTCHA_MAX_NUMBER: number;
@IsNotEmpty()
@IsNumber()
@Min(60_000)
@Max(3_600_000)
ALTCHA_CHALLENGE_TTL_MS: number;
@IsNotEmpty() @IsNotEmpty()
@IsNumber() @IsNumber()
@Min(1) @Min(1)
@@ -98,14 +98,20 @@ class EnvironmentVariables {
@IsNotEmpty() @IsNotEmpty()
@IsNumber() @IsNumber()
@Min(1) @Min(1)
@Max(100) @Max(10_000_000)
VALIDATION_UPLOAD_MAX_IMAGES_PER_UPLOAD: number; VALIDATION_UPLOAD_MAX_VIEWS_MAX: number;
@IsNotEmpty() @IsNotEmpty()
@IsNumber() @IsNumber()
@Min(1) @Min(1)
@Max(10_000_000) @Max(50 * 1024 * 1024) // 50MB
VALIDATION_UPLOAD_MAX_VIEWS_MAX: number; VALIDATION_UPLOAD_MAX_IMAGE_BYTES: number;
@IsNotEmpty()
@IsNumber()
@Min(256 * 1024) // 256KiB
@Max(16 * 1024 * 1024) // 16MB
VALIDATION_UPLOAD_JSON_BODY_PADDING_BYTES: number;
} }
export const validate = (config: Record<string, unknown>) => { export const validate = (config: Record<string, unknown>) => {
-4
View File
@@ -1,4 +0,0 @@
export type ValidationConfig = {
maxImagesPerUpload: number;
uploadMaxViewsMax: number;
};
@@ -2,4 +2,5 @@ export type AltchaConfig = {
enabled: boolean; enabled: boolean;
hmacKey: string; hmacKey: string;
maxNumber: number; maxNumber: number;
challengeTtlMs: number;
}; };
@@ -1,4 +1,4 @@
import { NodeEnv } from './NodeEnv'; import { NodeEnv } from '../NodeEnv';
import { ThrottleConfig } from './ThrottleConfig'; import { ThrottleConfig } from './ThrottleConfig';
import { ValidationConfig } from './ValidationConfig'; import { ValidationConfig } from './ValidationConfig';
@@ -0,0 +1,6 @@
export type UploadValidationConfig = {
maxViewsMax: number;
maxImageBytes: number;
maxCiphertextBytes: number;
maxRequestBodyBytes: number;
};
@@ -0,0 +1,5 @@
import { UploadValidationConfig } from './UploadValidationConfig';
export type ValidationConfig = {
upload: UploadValidationConfig;
};
@@ -1,3 +1,4 @@
export interface IsBase64Options { export interface IsBase64Options {
byteLength?: number; byteLength?: number;
maxByteLength?: number;
} }
@@ -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', () => { describe('default options object', () => {
class DefaultOptionsDto { class DefaultOptionsDto {
@IsBase64({}) @IsBase64({})
+10 -2
View File
@@ -25,22 +25,30 @@ class IsBase64Constraint implements ValidatorConstraintInterface {
return false; return false;
} }
const [{ byteLength }] = args.constraints as [IsBase64Options]; const [{ byteLength, maxByteLength }] = args.constraints as [IsBase64Options];
if (byteLength !== undefined) { if (byteLength !== undefined) {
return decoded.length === byteLength; return decoded.length === byteLength;
} }
if (maxByteLength !== undefined) {
return decoded.length <= maxByteLength;
}
return true; return true;
} }
defaultMessage(args: ValidationArguments): string { defaultMessage(args: ValidationArguments): string {
const [{ byteLength }] = args.constraints as [IsBase64Options]; const [{ byteLength, maxByteLength }] = args.constraints as [IsBase64Options];
if (byteLength !== undefined) { if (byteLength !== undefined) {
return `$property must be a base64-encoded ${byteLength}-byte value`; 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'; return '$property must be a valid base64 string';
} }
} }