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.
This commit is contained in:
+5
-4
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>) => {
|
||||||
|
|||||||
@@ -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({})
|
||||||
|
|||||||
@@ -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';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user