Merge pull request 'Slave/upload api' (#5) from slave/upload-api into master
Reviewed-on: #5
This commit was merged in pull request #5.
This commit is contained in:
+8
-4
@@ -18,12 +18,10 @@ 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
|
||||
ALTCHA_HMAC_KEY=change-me-use-long-random-secret
|
||||
ALTCHA_MAX_NUMBER=50000
|
||||
ALTCHA_CHALLENGE_TTL_MS=600000
|
||||
|
||||
NEVER_MODE_INACTIVITY_DAYS=365
|
||||
|
||||
@@ -32,7 +30,13 @@ THROTTLE_LIMIT=1000
|
||||
THROTTLE_UPLOAD_TTL_MS=60000
|
||||
THROTTLE_UPLOAD_LIMIT=20
|
||||
|
||||
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_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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:20-alpine
|
||||
FROM node:22-alpine
|
||||
|
||||
WORKDIR /backend
|
||||
|
||||
|
||||
Generated
+73
-7
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'),
|
||||
@@ -49,6 +56,16 @@ export const getAppConfig = (): AppConfig => {
|
||||
uploadThrottle: {
|
||||
ttlMs: envInt('THROTTLE_UPLOAD_TTL_MS'),
|
||||
limit: envInt('THROTTLE_UPLOAD_LIMIT')
|
||||
},
|
||||
validation: {
|
||||
upload: {
|
||||
maxViewsMax: envInt('VALIDATION_UPLOAD_MAX_VIEWS_MAX'),
|
||||
maxImageBytes,
|
||||
maxCiphertextBytes: maxImageBytes + aesGcmCiphertextOverheadBytes,
|
||||
maxRequestBodyBytes:
|
||||
Math.ceil((maxImageBytes * jsonBase64ByteRatioNumerator) / jsonBase64ByteRatioDenominator) +
|
||||
jsonBodyPaddingBytes
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
@@ -11,16 +11,18 @@ const validConfig = (overrides: Record<string, unknown> = {}): Record<string, un
|
||||
POSTGRES_PORT: 5432,
|
||||
POSTGRES_MIGRATIONS_RUN: 'false',
|
||||
CORS_ORIGINS: 'http://localhost:5173',
|
||||
MAX_IMAGES_PER_UPLOAD: 10,
|
||||
MULTER_MAX_FILE_BYTES: 10485760,
|
||||
VALIDATION_UPLOAD_MAX_IMAGE_BYTES: 10485760,
|
||||
VALIDATION_UPLOAD_JSON_BODY_PADDING_BYTES: 2_097_152,
|
||||
ALTCHA_ENABLED: 'true',
|
||||
ALTCHA_HMAC_KEY: 'secret',
|
||||
ALTCHA_MAX_NUMBER: 50000,
|
||||
ALTCHA_CHALLENGE_TTL_MS: 600_000,
|
||||
NEVER_MODE_INACTIVITY_DAYS: 365,
|
||||
THROTTLE_TTL_MS: 60000,
|
||||
THROTTLE_LIMIT: 1000,
|
||||
THROTTLE_UPLOAD_TTL_MS: 60000,
|
||||
THROTTLE_UPLOAD_LIMIT: 20,
|
||||
VALIDATION_UPLOAD_MAX_VIEWS_MAX: 1_000_000,
|
||||
...overrides
|
||||
});
|
||||
|
||||
|
||||
@@ -45,18 +45,6 @@ class EnvironmentVariables {
|
||||
@IsString()
|
||||
CORS_ORIGINS: string;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
@Max(100)
|
||||
MAX_IMAGES_PER_UPLOAD: number;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
@Max(50 * 1024 * 1024)
|
||||
MULTER_MAX_FILE_BYTES: number;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsIn(BOOLEAN_STRING_VALUES)
|
||||
ALTCHA_ENABLED: BooleanStringValue;
|
||||
@@ -71,6 +59,12 @@ class EnvironmentVariables {
|
||||
@Max(1_000_000)
|
||||
ALTCHA_MAX_NUMBER: number;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsNumber()
|
||||
@Min(60_000)
|
||||
@Max(3_600_000)
|
||||
ALTCHA_CHALLENGE_TTL_MS: number;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
@@ -100,6 +94,24 @@ class EnvironmentVariables {
|
||||
@Min(1)
|
||||
@Max(10_000)
|
||||
THROTTLE_UPLOAD_LIMIT: number;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
@Max(10_000_000)
|
||||
VALIDATION_UPLOAD_MAX_VIEWS_MAX: number;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
@Max(50 * 1024 * 1024) // 50MB
|
||||
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>) => {
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export const ALTCHA_REQUEST_HEADER = 'x-altcha';
|
||||
+36
-5
@@ -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<NestExpressApplication>(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,
|
||||
|
||||
@@ -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<Exclude<AltchaVerificationStatus, AltchaVerificationStatus.Ok>, 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);
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<boolean> {
|
||||
const { enabled } = this.configService.get('altcha') as Config['altcha'];
|
||||
|
||||
if (!enabled) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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<string> => {
|
||||
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<string> =>
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<Challenge> {
|
||||
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<boolean> {
|
||||
const { hmacKey } = this.configService.get('altcha') as Config['altcha'];
|
||||
async verifySolution(altcha: string | undefined): Promise<AltchaVerificationStatus> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<void>(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<void>(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<void>(resolve => {
|
||||
setTimeout(() => {
|
||||
store.pruneExpired();
|
||||
expect(store.tryConsume('first')).toBe(true);
|
||||
expect(store.tryConsume('second')).toBe(true);
|
||||
resolve();
|
||||
}, 5);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<string, number>();
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export enum AltchaVerificationStatus {
|
||||
Ok = 'ok',
|
||||
Missing = 'missing',
|
||||
Invalid = 'invalid',
|
||||
AlreadyUsed = 'already_used'
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
|
||||
import { StorageService } from './services/StorageService';
|
||||
|
||||
@Module({
|
||||
providers: [StorageService]
|
||||
providers: [StorageService],
|
||||
exports: [StorageService]
|
||||
})
|
||||
export class StorageModule {}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { IsMimeType, IsNotEmpty, IsString } from 'class-validator';
|
||||
import { UploadCiphertextOptionsDto } from './UploadCiphertextOptionsDto';
|
||||
|
||||
export class CreateHotlinkUploadDto extends UploadCiphertextOptionsDto {
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
@IsMimeType()
|
||||
mimeType: string;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { IsBoolean, IsNotEmpty, ValidateIf } from 'class-validator';
|
||||
import { IsBase64 } from '../../../validation/decorators/isBase64';
|
||||
import { UploadCiphertextOptionsDto } from './UploadCiphertextOptionsDto';
|
||||
|
||||
export class CreateZkUploadDto extends UploadCiphertextOptionsDto {
|
||||
@IsNotEmpty()
|
||||
@IsBoolean()
|
||||
passwordProtected: boolean;
|
||||
|
||||
@ValidateIf((dto: CreateZkUploadDto) => dto.passwordProtected === true)
|
||||
@IsNotEmpty()
|
||||
@IsBase64({ maxByteLength: 128 })
|
||||
wrappedDek?: string;
|
||||
|
||||
@ValidateIf((dto: CreateZkUploadDto) => dto.passwordProtected === true)
|
||||
@IsNotEmpty()
|
||||
@IsBase64({ byteLength: 12 })
|
||||
wrapIv?: string;
|
||||
|
||||
@ValidateIf((dto: CreateZkUploadDto) => dto.passwordProtected === true)
|
||||
@IsNotEmpty()
|
||||
@IsBase64({ maxByteLength: 64 })
|
||||
salt?: string;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { getAppConfig } from '../../../config';
|
||||
import { NullOrDate, NullOrInt } from '../../../validation/decorators/nullOr';
|
||||
|
||||
const {
|
||||
validation: {
|
||||
upload: { maxViewsMax }
|
||||
}
|
||||
} = getAppConfig();
|
||||
|
||||
export class UploadOptionsDto {
|
||||
@NullOrDate()
|
||||
expiresAt: Date | null;
|
||||
|
||||
@NullOrInt({ min: 1, max: maxViewsMax })
|
||||
maxViews: number | null;
|
||||
}
|
||||
@@ -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 })
|
||||
|
||||
@@ -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<typeof import('node:crypto')>('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<typeof fileTypeFromBuffer>;
|
||||
|
||||
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<Pick<DataSource, 'transaction'>>;
|
||||
let storageService: jest.Mocked<Pick<StorageService, 'write'>>;
|
||||
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<unknown>) => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<CreatedUploadResult> {
|
||||
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<CreatedUploadResult> {
|
||||
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<void> {
|
||||
const detected = await fileTypeFromBuffer(ciphertext);
|
||||
|
||||
if (detected?.mime.startsWith('image/')) {
|
||||
throw new BadRequestException('Ciphertext must not be a plaintext image');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { UploadMode } from './UploadMode';
|
||||
|
||||
export type CreatedUploadResult = {
|
||||
id: string;
|
||||
mode: UploadMode;
|
||||
deleteToken: string;
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
export enum HttpMethod {
|
||||
Get = 'GET',
|
||||
Head = 'HEAD',
|
||||
Post = 'POST',
|
||||
Put = 'PUT',
|
||||
Patch = 'PATCH',
|
||||
Delete = 'DELETE',
|
||||
Options = 'OPTIONS'
|
||||
}
|
||||
@@ -2,4 +2,5 @@ export type AltchaConfig = {
|
||||
enabled: boolean;
|
||||
hmacKey: string;
|
||||
maxNumber: number;
|
||||
challengeTtlMs: number;
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NodeEnv } from './NodeEnv';
|
||||
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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export interface IsBase64Options {
|
||||
byteLength?: number;
|
||||
maxByteLength?: number;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export type NullOrIntOptions = {
|
||||
min?: number;
|
||||
max?: number;
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export const fromBase64 = (value: string): Buffer => Buffer.from(value, 'base64');
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,2 @@
|
||||
export const toBase64 = (input: Buffer | string): string =>
|
||||
typeof input === 'string' ? Buffer.from(input, 'utf8').toString('base64') : input.toString('base64');
|
||||
@@ -0,0 +1,276 @@
|
||||
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 = <T extends object>(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 = <T extends object>(DtoClass: new () => T, property: keyof T & string, value: unknown) => {
|
||||
expect(validateField(DtoClass, property, value)).toHaveLength(0);
|
||||
};
|
||||
|
||||
const rejects = <T extends object>(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('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({})
|
||||
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'));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
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, 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, 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';
|
||||
}
|
||||
}
|
||||
|
||||
export const IsBase64 = (options: IsBase64Options = {}) => Validate(IsBase64Constraint, [options]);
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:20-alpine
|
||||
FROM node:22-alpine
|
||||
|
||||
WORKDIR /frontend
|
||||
|
||||
|
||||
@@ -1,29 +1,24 @@
|
||||
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: {
|
||||
upload: {
|
||||
maxQueueSize: envInt('VITE_UPLOAD_MAX_QUEUE_SIZE'),
|
||||
maxViewsMax: envInt('VITE_VALIDATION_UPLOAD_MAX_VIEWS_MAX'),
|
||||
maxImageBytes: envInt('VITE_VALIDATION_UPLOAD_MAX_IMAGE_BYTES')
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
import axios from 'axios';
|
||||
import { config } from '@/config';
|
||||
|
||||
export const api = axios.create({
|
||||
baseURL: config.baseUrl
|
||||
});
|
||||
Vendored
+3
-1
@@ -4,7 +4,9 @@ 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_UPLOAD_MAX_QUEUE_SIZE: string;
|
||||
readonly VITE_VALIDATION_UPLOAD_MAX_VIEWS_MAX: string;
|
||||
readonly VITE_VALIDATION_UPLOAD_MAX_IMAGE_BYTES: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
|
||||
Reference in New Issue
Block a user