From 87376ea37f44c11c008e9dc3883265b90025ca48 Mon Sep 17 00:00:00 2001 From: nobswebdev Date: Mon, 5 Oct 2026 23:53:28 +0200 Subject: [PATCH 1/6] add ALTCHA challenge endpoint and env config. GET /api/altcha/challenge, nullcart-style env validation, and ALTCHA_ENABLED wiring. --- .env.example | 3 +- backend/package-lock.json | 70 ++++++++++ backend/package.json | 1 + backend/src/AppModule.ts | 12 +- backend/src/config/index.ts | 11 +- backend/src/config/validate.ts | 129 ++++++++++++++++++ backend/src/modules/altcha/AltchaModule.ts | 10 ++ .../altcha/controllers/AltchaController.ts | 23 ++++ .../modules/altcha/services/AltchaService.ts | 19 +++ backend/src/types/AltchaConfig.ts | 5 + backend/src/types/Config.ts | 16 +-- backend/src/types/PostgresConfig.ts | 12 ++ 12 files changed, 293 insertions(+), 18 deletions(-) create mode 100644 backend/src/config/validate.ts create mode 100644 backend/src/modules/altcha/AltchaModule.ts create mode 100644 backend/src/modules/altcha/controllers/AltchaController.ts create mode 100644 backend/src/modules/altcha/services/AltchaService.ts create mode 100644 backend/src/types/AltchaConfig.ts create mode 100644 backend/src/types/PostgresConfig.ts diff --git a/.env.example b/.env.example index 40b6c51..3dd597f 100644 --- a/.env.example +++ b/.env.example @@ -21,8 +21,7 @@ CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173 MAX_IMAGES_PER_UPLOAD=10 MULTER_MAX_FILE_BYTES=10485760 -CAPTCHA_ENABLED=true - +ALTCHA_ENABLED=true ALTCHA_HMAC_KEY=change-me-use-long-random-secret ALTCHA_MAX_NUMBER=50000 diff --git a/backend/package-lock.json b/backend/package-lock.json index d6abff0..71eb8fd 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -14,6 +14,7 @@ "@nestjs/core": "^11.0.1", "@nestjs/platform-express": "^11.0.1", "@nestjs/typeorm": "^12.0.2", + "altcha-lib": "^2.6.0", "class-transformer": "^0.5.1", "class-validator": "^0.15.1", "pg": "^8.23.1", @@ -3269,6 +3270,75 @@ "ajv": "^8.8.2" } }, + "node_modules/altcha-lib": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/altcha-lib/-/altcha-lib-2.6.0.tgz", + "integrity": "sha512-mBp/KxMZmQkckWX5iM0gHqCm0qTLGhF9r8hhFjNrBpzP0qHZBadR9oafxVx9cTLHl4h2+f56N4hRIrZzvonYLQ==", + "license": "MIT", + "bin": { + "altcha-lib": "bin/cli.mjs" + }, + "peerDependencies": { + "@fastify/cookie": "^11.0.0", + "@fastify/cors": "^11.0.0", + "@fastify/formbody": "^8.0.0 || ^9.0.0", + "@nestjs/common": "^10.0.0 || ^11.0.0 || ^12.0.0", + "@nestjs/core": "^10.0.0 || ^11.0.0 || ^12.0.0", + "@nestjs/platform-express": "^10.0.0 || ^11.0.0 || ^12.0.0", + "@nestjs/platform-fastify": "^10.0.0 || ^11.0.0 || ^12.0.0", + "@sveltejs/kit": "^2.0.0", + "cookie-parser": "^1.0.0", + "cors": "^2.0.0", + "express": "^4.0.0 || ^5.0.0", + "fastify": "^5.0.0", + "h3": "^2.0.0", + "hono": "^4.0.0" + }, + "peerDependenciesMeta": { + "@fastify/cookie": { + "optional": true + }, + "@fastify/cors": { + "optional": true + }, + "@fastify/formbody": { + "optional": true + }, + "@nestjs/common": { + "optional": true + }, + "@nestjs/core": { + "optional": true + }, + "@nestjs/platform-express": { + "optional": true + }, + "@nestjs/platform-fastify": { + "optional": true + }, + "@sveltejs/kit": { + "optional": true + }, + "cookie-parser": { + "optional": true + }, + "cors": { + "optional": true + }, + "express": { + "optional": true + }, + "fastify": { + "optional": true + }, + "h3": { + "optional": true + }, + "hono": { + "optional": true + } + } + }, "node_modules/ansi-colors": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", diff --git a/backend/package.json b/backend/package.json index 6937537..e856fd1 100644 --- a/backend/package.json +++ b/backend/package.json @@ -25,6 +25,7 @@ "@nestjs/core": "^11.0.1", "@nestjs/platform-express": "^11.0.1", "@nestjs/typeorm": "^12.0.2", + "altcha-lib": "^2.6.0", "class-transformer": "^0.5.1", "class-validator": "^0.15.1", "pg": "^8.23.1", diff --git a/backend/src/AppModule.ts b/backend/src/AppModule.ts index b7efcc9..19734d5 100644 --- a/backend/src/AppModule.ts +++ b/backend/src/AppModule.ts @@ -1,7 +1,9 @@ import { Module } from '@nestjs/common'; import { ConfigModule, ConfigService, registerAs } from '@nestjs/config'; import { TypeOrmModule } from '@nestjs/typeorm'; -import { getAppConfig, getPostgresConfig } from './config'; +import { getAltchaConfig, getAppConfig, getPostgresConfig } from './config'; +import { validate } from './config/validate'; +import { AltchaModule } from './modules/altcha/AltchaModule'; import { HealthCheckModule } from './modules/healthCheck/HealthCheckModule'; import { StorageModule } from './modules/storage/StorageModule'; import { UploadModule } from './modules/upload/UploadModule'; @@ -11,13 +13,19 @@ import { Config } from './types/Config'; imports: [ ConfigModule.forRoot({ isGlobal: true, - load: [registerAs('postgres', getPostgresConfig), registerAs('app', getAppConfig)] + validate, + load: [ + registerAs('postgres', getPostgresConfig), + registerAs('app', getAppConfig), + registerAs('altcha', getAltchaConfig) + ] }), TypeOrmModule.forRootAsync({ useFactory: (configService: ConfigService) => configService.get('postgres') as Config['postgres'], inject: [ConfigService] }), HealthCheckModule, + AltchaModule, StorageModule, UploadModule ] diff --git a/backend/src/config/index.ts b/backend/src/config/index.ts index 67e942d..0e185b3 100644 --- a/backend/src/config/index.ts +++ b/backend/src/config/index.ts @@ -1,5 +1,6 @@ import { AppConfig } from '../types/AppConfig'; -import { PostgresConfig } from '../types/Config'; +import { AltchaConfig } from '../types/AltchaConfig'; +import { PostgresConfig } from '../types/PostgresConfig'; import { NodeEnv } from '../types/NodeEnv'; const env = (key: string): string => process.env[key] || ''; @@ -23,6 +24,14 @@ export const getPostgresConfig = (): PostgresConfig => { }; }; +export const getAltchaConfig = (): AltchaConfig => { + return { + enabled: isEnabled('ALTCHA_ENABLED'), + hmacKey: env('ALTCHA_HMAC_KEY'), + maxNumber: envInt('ALTCHA_MAX_NUMBER') + }; +}; + export const getAppConfig = (): AppConfig => { const rawOrigins = env('CORS_ORIGINS'); diff --git a/backend/src/config/validate.ts b/backend/src/config/validate.ts new file mode 100644 index 0000000..a593f8f --- /dev/null +++ b/backend/src/config/validate.ts @@ -0,0 +1,129 @@ +import { plainToClass } from 'class-transformer'; +import { IsBoolean, IsEnum, IsNotEmpty, IsNumber, IsString, Max, Min, validateSync } from 'class-validator'; +import { NodeEnv } from '../types/NodeEnv'; + +class EnvironmentVariables { + @IsNotEmpty() + @IsEnum(NodeEnv) + NODE_ENV: NodeEnv; + + @IsNotEmpty() + @IsNumber() + @Min(1) + @Max(65535) + BACKEND_PORT: number; + + @IsNotEmpty() + @IsString() + POSTGRES_USER: string; + + @IsNotEmpty() + @IsString() + POSTGRES_PASSWORD: string; + + @IsNotEmpty() + @IsString() + POSTGRES_DB: string; + + @IsNotEmpty() + @IsString() + POSTGRES_HOST: string; + + @IsNotEmpty() + @IsNumber() + @Min(1) + @Max(65535) + POSTGRES_PORT: number; + + @IsNotEmpty() + @IsBoolean() + POSTGRES_MIGRATIONS_RUN: boolean; + + @IsNotEmpty() + @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() + @IsBoolean() + ALTCHA_ENABLED: boolean; + + @IsNotEmpty() + @IsString() + ALTCHA_HMAC_KEY: string; + + @IsNotEmpty() + @IsNumber() + @Min(1) + @Max(1_000_000) + ALTCHA_MAX_NUMBER: number; + + @IsNotEmpty() + @IsNumber() + @Min(1) + @Max(3650) + NEVER_MODE_INACTIVITY_DAYS: number; + + @IsNotEmpty() + @IsNumber() + @Min(1000) + @Max(3_600_000) + THROTTLE_UPLOAD_TTL_MS: number; + + @IsNotEmpty() + @IsNumber() + @Min(1) + @Max(10_000) + THROTTLE_UPLOAD_LIMIT: number; + + @IsNotEmpty() + @IsNumber() + @Min(1000) + @Max(3_600_000) + THROTTLE_HOTLINK_PER_IP_TTL_MS: number; + + @IsNotEmpty() + @IsNumber() + @Min(1) + @Max(1_000_000) + THROTTLE_HOTLINK_PER_IP_LIMIT: number; + + @IsNotEmpty() + @IsNumber() + @Min(1000) + @Max(3_600_000) + THROTTLE_HOTLINK_PER_ID_TTL_MS: number; + + @IsNotEmpty() + @IsNumber() + @Min(1) + @Max(1_000_000) + THROTTLE_HOTLINK_PER_ID_LIMIT: number; +} + +export const validate = (config: Record) => { + const validatedConfig = plainToClass(EnvironmentVariables, config, { + enableImplicitConversion: true + }); + + const errors = validateSync(validatedConfig, { + skipMissingProperties: false + }); + + if (errors.length > 0) { + throw new Error(errors.toString()); + } + + return validatedConfig; +}; diff --git a/backend/src/modules/altcha/AltchaModule.ts b/backend/src/modules/altcha/AltchaModule.ts new file mode 100644 index 0000000..b2744b5 --- /dev/null +++ b/backend/src/modules/altcha/AltchaModule.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { AltchaController } from './controllers/AltchaController'; +import { AltchaService } from './services/AltchaService'; + +@Module({ + controllers: [AltchaController], + providers: [AltchaService], + exports: [AltchaService] +}) +export class AltchaModule {} diff --git a/backend/src/modules/altcha/controllers/AltchaController.ts b/backend/src/modules/altcha/controllers/AltchaController.ts new file mode 100644 index 0000000..0ae75e0 --- /dev/null +++ b/backend/src/modules/altcha/controllers/AltchaController.ts @@ -0,0 +1,23 @@ +import { Controller, Get, NotFoundException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { AltchaService } from '../services/AltchaService'; +import { Config } from '../../../types/Config'; + +@Controller('altcha') +export class AltchaController { + constructor( + private readonly altchaService: AltchaService, + private readonly configService: ConfigService + ) {} + + @Get('challenge') + async getChallenge() { + const { enabled } = this.configService.get('altcha') as Config['altcha']; + + if (!enabled) { + throw new NotFoundException(); + } + + return this.altchaService.createChallenge(); + } +} diff --git a/backend/src/modules/altcha/services/AltchaService.ts b/backend/src/modules/altcha/services/AltchaService.ts new file mode 100644 index 0000000..1266fd9 --- /dev/null +++ b/backend/src/modules/altcha/services/AltchaService.ts @@ -0,0 +1,19 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { createChallenge } from 'altcha-lib/v1'; +import type { Challenge } from 'altcha-lib/v1/types'; +import { Config } from '../../../types/Config'; + +@Injectable() +export class AltchaService { + constructor(private readonly configService: ConfigService) {} + + async createChallenge(): Promise { + const { hmacKey, maxNumber } = this.configService.get('altcha') as Config['altcha']; + + return createChallenge({ + hmacKey, + maxNumber + }); + } +} diff --git a/backend/src/types/AltchaConfig.ts b/backend/src/types/AltchaConfig.ts new file mode 100644 index 0000000..ef224f8 --- /dev/null +++ b/backend/src/types/AltchaConfig.ts @@ -0,0 +1,5 @@ +export type AltchaConfig = { + enabled: boolean; + hmacKey: string; + maxNumber: number; +}; diff --git a/backend/src/types/Config.ts b/backend/src/types/Config.ts index 803785a..61702f7 100644 --- a/backend/src/types/Config.ts +++ b/backend/src/types/Config.ts @@ -1,19 +1,9 @@ +import { AltchaConfig } from './AltchaConfig'; import { AppConfig } from './AppConfig'; - -export interface PostgresConfig { - type: 'postgres'; - url: string; - host: string; - username: string; - password: string; - port: number; - database: string; - entities: string[]; - migrations: string[]; - migrationsRun: boolean; -} +import { PostgresConfig } from './PostgresConfig'; export interface Config { app: AppConfig; + altcha: AltchaConfig; postgres: PostgresConfig; } diff --git a/backend/src/types/PostgresConfig.ts b/backend/src/types/PostgresConfig.ts new file mode 100644 index 0000000..eed3d53 --- /dev/null +++ b/backend/src/types/PostgresConfig.ts @@ -0,0 +1,12 @@ +export interface PostgresConfig { + type: 'postgres'; + url: string; + host: string; + username: string; + password: string; + port: number; + database: string; + entities: string[]; + migrations: string[]; + migrationsRun: boolean; +} -- 2.54.0 From 84f8a852149d24074c9da71630a2da8ee78d3106 Mon Sep 17 00:00:00 2001 From: nobswebdev Date: Tue, 6 Oct 2026 15:04:31 +0200 Subject: [PATCH 2/6] add ALTCHA verification guard for uploads. Verify body.altcha when ALTCHA_ENABLED; export AltchaGuard only. Drop unused StorageModule export. --- backend/src/modules/altcha/AltchaModule.ts | 5 ++- .../src/modules/altcha/guards/AltchaGuard.ts | 36 +++++++++++++++++ .../altcha/services/AltchaService.spec.ts | 39 +++++++++++++++++++ .../modules/altcha/services/AltchaService.ts | 8 +++- backend/src/modules/storage/StorageModule.ts | 3 +- 5 files changed, 86 insertions(+), 5 deletions(-) create mode 100644 backend/src/modules/altcha/guards/AltchaGuard.ts create mode 100644 backend/src/modules/altcha/services/AltchaService.spec.ts diff --git a/backend/src/modules/altcha/AltchaModule.ts b/backend/src/modules/altcha/AltchaModule.ts index b2744b5..0037b45 100644 --- a/backend/src/modules/altcha/AltchaModule.ts +++ b/backend/src/modules/altcha/AltchaModule.ts @@ -1,10 +1,11 @@ import { Module } from '@nestjs/common'; import { AltchaController } from './controllers/AltchaController'; +import { AltchaGuard } from './guards/AltchaGuard'; import { AltchaService } from './services/AltchaService'; @Module({ controllers: [AltchaController], - providers: [AltchaService], - exports: [AltchaService] + providers: [AltchaService, AltchaGuard], + exports: [AltchaGuard] }) export class AltchaModule {} diff --git a/backend/src/modules/altcha/guards/AltchaGuard.ts b/backend/src/modules/altcha/guards/AltchaGuard.ts new file mode 100644 index 0000000..d213756 --- /dev/null +++ b/backend/src/modules/altcha/guards/AltchaGuard.ts @@ -0,0 +1,36 @@ +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 new file mode 100644 index 0000000..de6c458 --- /dev/null +++ b/backend/src/modules/altcha/services/AltchaService.spec.ts @@ -0,0 +1,39 @@ +import { ConfigService } from '@nestjs/config'; +import { createChallenge, solveChallenge } from 'altcha-lib/v1'; +import type { Payload } from 'altcha-lib/v1/types'; +import { AltchaService } from './AltchaService'; + +const hmacKey = 'test-hmac-key'; + +const encodePayload = (payload: Payload): string => + Buffer.from(JSON.stringify(payload), 'utf8').toString('base64'); + +describe('AltchaService', () => { + const configService = { + get: jest.fn(() => ({ enabled: true, hmacKey, maxNumber: 100_000 })) + } as unknown as ConfigService; + + 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); + }); + + it('rejects an invalid ALTCHA payload', async () => { + await expect(service.verifyPayload('not-valid-base64-json')).resolves.toBe(false); + }); +}); diff --git a/backend/src/modules/altcha/services/AltchaService.ts b/backend/src/modules/altcha/services/AltchaService.ts index 1266fd9..137cafb 100644 --- a/backend/src/modules/altcha/services/AltchaService.ts +++ b/backend/src/modules/altcha/services/AltchaService.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; -import { createChallenge } from 'altcha-lib/v1'; +import { createChallenge, verifySolution } from 'altcha-lib/v1'; import type { Challenge } from 'altcha-lib/v1/types'; import { Config } from '../../../types/Config'; @@ -16,4 +16,10 @@ export class AltchaService { maxNumber }); } + + async verifyPayload(altcha: string): Promise { + const { hmacKey } = this.configService.get('altcha') as Config['altcha']; + + return verifySolution(altcha, hmacKey); + } } diff --git a/backend/src/modules/storage/StorageModule.ts b/backend/src/modules/storage/StorageModule.ts index 1ea11c8..9497dcd 100644 --- a/backend/src/modules/storage/StorageModule.ts +++ b/backend/src/modules/storage/StorageModule.ts @@ -2,7 +2,6 @@ import { Module } from '@nestjs/common'; import { StorageService } from './services/StorageService'; @Module({ - providers: [StorageService], - exports: [StorageService] + providers: [StorageService] }) export class StorageModule {} -- 2.54.0 From 7e4a889b50137e48f362143f8ea61a182dbf9719 Mon Sep 17 00:00:00 2001 From: nobswebdev Date: Tue, 6 Oct 2026 18:59:44 +0200 Subject: [PATCH 3/6] add upload rate limiter per IP to backend. Nest throttler with nullcart-style default profile and upload override via throttleProfiles. --- backend/package-lock.json | 15 +++++++++++ backend/package.json | 1 + backend/src/AppModule.ts | 24 +++++++++++++++++ backend/src/config/index.ts | 10 ++++++- backend/src/config/throttleProfiles.ts | 9 +++++++ backend/src/config/validate.ts | 36 +++++++++----------------- backend/src/types/AppConfig.ts | 3 +++ backend/src/types/ThrottleConfig.ts | 4 +++ 8 files changed, 77 insertions(+), 25 deletions(-) create mode 100644 backend/src/config/throttleProfiles.ts create mode 100644 backend/src/types/ThrottleConfig.ts diff --git a/backend/package-lock.json b/backend/package-lock.json index 71eb8fd..0ac44d3 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -13,6 +13,7 @@ "@nestjs/config": "^4.0.3", "@nestjs/core": "^11.0.1", "@nestjs/platform-express": "^11.0.1", + "@nestjs/throttler": "^6.7.1", "@nestjs/typeorm": "^12.0.2", "altcha-lib": "^2.6.0", "class-transformer": "^0.5.1", @@ -1995,6 +1996,20 @@ "tslib": "^2.1.0" } }, + "node_modules/@nestjs/throttler": { + "version": "6.7.1", + "resolved": "https://registry.npmjs.org/@nestjs/throttler/-/throttler-6.7.1.tgz", + "integrity": "sha512-gAyY1AkU+cSK5DCjFOB43xax9oakameH4xZa9kAgqBqSBQZgmv3DZylLV8KyZNzOGhceL3kDVtby+CF8VAdmEA==", + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@nestjs/common": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 || ^12.0.0", + "@nestjs/core": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 || ^12.0.0", + "reflect-metadata": "^0.1.13 || ^0.2.0" + } + }, "node_modules/@nestjs/typeorm": { "version": "12.0.2", "resolved": "https://registry.npmjs.org/@nestjs/typeorm/-/typeorm-12.0.2.tgz", diff --git a/backend/package.json b/backend/package.json index e856fd1..de9e26b 100644 --- a/backend/package.json +++ b/backend/package.json @@ -24,6 +24,7 @@ "@nestjs/config": "^4.0.3", "@nestjs/core": "^11.0.1", "@nestjs/platform-express": "^11.0.1", + "@nestjs/throttler": "^6.7.1", "@nestjs/typeorm": "^12.0.2", "altcha-lib": "^2.6.0", "class-transformer": "^0.5.1", diff --git a/backend/src/AppModule.ts b/backend/src/AppModule.ts index 19734d5..762e603 100644 --- a/backend/src/AppModule.ts +++ b/backend/src/AppModule.ts @@ -1,5 +1,7 @@ import { Module } from '@nestjs/common'; import { ConfigModule, ConfigService, registerAs } from '@nestjs/config'; +import { APP_GUARD } from '@nestjs/core'; +import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler'; import { TypeOrmModule } from '@nestjs/typeorm'; import { getAltchaConfig, getAppConfig, getPostgresConfig } from './config'; import { validate } from './config/validate'; @@ -20,6 +22,22 @@ import { Config } from './types/Config'; registerAs('altcha', getAltchaConfig) ] }), + ThrottlerModule.forRootAsync({ + inject: [ConfigService], + useFactory: (configService: ConfigService) => { + const { throttle } = configService.get('app') as Config['app']; + + return { + throttlers: [ + { + name: 'default', + ttl: throttle.ttlMs, + limit: throttle.limit + } + ] + }; + } + }), TypeOrmModule.forRootAsync({ useFactory: (configService: ConfigService) => configService.get('postgres') as Config['postgres'], inject: [ConfigService] @@ -28,6 +46,12 @@ import { Config } from './types/Config'; AltchaModule, StorageModule, UploadModule + ], + providers: [ + { + provide: APP_GUARD, + useClass: ThrottlerGuard + } ] }) export class AppModule {} diff --git a/backend/src/config/index.ts b/backend/src/config/index.ts index 0e185b3..e544bbd 100644 --- a/backend/src/config/index.ts +++ b/backend/src/config/index.ts @@ -41,6 +41,14 @@ export const getAppConfig = (): AppConfig => { corsOrigins: rawOrigins .split(',') .map(o => o.trim()) - .filter(Boolean) + .filter(Boolean), + throttle: { + ttlMs: envInt('THROTTLE_TTL_MS'), + limit: envInt('THROTTLE_LIMIT') + }, + uploadThrottle: { + ttlMs: envInt('THROTTLE_UPLOAD_TTL_MS'), + limit: envInt('THROTTLE_UPLOAD_LIMIT') + } }; }; diff --git a/backend/src/config/throttleProfiles.ts b/backend/src/config/throttleProfiles.ts new file mode 100644 index 0000000..cc44df5 --- /dev/null +++ b/backend/src/config/throttleProfiles.ts @@ -0,0 +1,9 @@ +import { getAppConfig } from './index'; + +const { + uploadThrottle: { limit, ttlMs } +} = getAppConfig(); + +export const throttleProfiles = { + upload: { default: { limit, ttl: ttlMs } } +} as const; diff --git a/backend/src/config/validate.ts b/backend/src/config/validate.ts index a593f8f..f95945d 100644 --- a/backend/src/config/validate.ts +++ b/backend/src/config/validate.ts @@ -75,6 +75,18 @@ class EnvironmentVariables { @Max(3650) NEVER_MODE_INACTIVITY_DAYS: number; + @IsNotEmpty() + @IsNumber() + @Min(1000) + @Max(3_600_000) + THROTTLE_TTL_MS: number; + + @IsNotEmpty() + @IsNumber() + @Min(1) + @Max(1_000_000) + THROTTLE_LIMIT: number; + @IsNotEmpty() @IsNumber() @Min(1000) @@ -86,30 +98,6 @@ class EnvironmentVariables { @Min(1) @Max(10_000) THROTTLE_UPLOAD_LIMIT: number; - - @IsNotEmpty() - @IsNumber() - @Min(1000) - @Max(3_600_000) - THROTTLE_HOTLINK_PER_IP_TTL_MS: number; - - @IsNotEmpty() - @IsNumber() - @Min(1) - @Max(1_000_000) - THROTTLE_HOTLINK_PER_IP_LIMIT: number; - - @IsNotEmpty() - @IsNumber() - @Min(1000) - @Max(3_600_000) - THROTTLE_HOTLINK_PER_ID_TTL_MS: number; - - @IsNotEmpty() - @IsNumber() - @Min(1) - @Max(1_000_000) - THROTTLE_HOTLINK_PER_ID_LIMIT: number; } export const validate = (config: Record) => { diff --git a/backend/src/types/AppConfig.ts b/backend/src/types/AppConfig.ts index 1b39094..808eb2f 100644 --- a/backend/src/types/AppConfig.ts +++ b/backend/src/types/AppConfig.ts @@ -1,7 +1,10 @@ import { NodeEnv } from './NodeEnv'; +import { ThrottleConfig } from './ThrottleConfig'; export type AppConfig = { port: number; nodeEnv: NodeEnv; corsOrigins: string[]; + throttle: ThrottleConfig; + uploadThrottle: ThrottleConfig; }; diff --git a/backend/src/types/ThrottleConfig.ts b/backend/src/types/ThrottleConfig.ts new file mode 100644 index 0000000..18d2845 --- /dev/null +++ b/backend/src/types/ThrottleConfig.ts @@ -0,0 +1,4 @@ +export type ThrottleConfig = { + ttlMs: number; + limit: number; +}; -- 2.54.0 From e50c71e5499ba8b8adce4e2d72403d8be3b3ccb6 Mon Sep 17 00:00:00 2001 From: nobswebdev Date: Tue, 6 Oct 2026 19:06:32 +0200 Subject: [PATCH 4/6] update .env.example with ALTCHA and throttle variables. Add default THROTTLE_TTL_MS and THROTTLE_LIMIT; remove obsolete hotlink throttle env keys. --- .env.example | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/.env.example b/.env.example index 3dd597f..c42577c 100644 --- a/.env.example +++ b/.env.example @@ -27,12 +27,10 @@ ALTCHA_MAX_NUMBER=50000 NEVER_MODE_INACTIVITY_DAYS=365 +THROTTLE_TTL_MS=60000 +THROTTLE_LIMIT=1000 THROTTLE_UPLOAD_TTL_MS=60000 THROTTLE_UPLOAD_LIMIT=20 -THROTTLE_HOTLINK_PER_IP_TTL_MS=60000 -THROTTLE_HOTLINK_PER_IP_LIMIT=1000 -THROTTLE_HOTLINK_PER_ID_TTL_MS=60000 -THROTTLE_HOTLINK_PER_ID_LIMIT=120 VITE_API_BASE_URL=http://localhost:3000/api VITE_CLEARNET_DOMAIN=localhost:5173 -- 2.54.0 From b16678bf74162da30df95326ba8105823d8f1623 Mon Sep 17 00:00:00 2001 From: nobswebdev Date: Tue, 6 Oct 2026 19:44:13 +0200 Subject: [PATCH 5/6] add dockerignore for backend and frontend dev images. Prevent copying host node_modules, dist, and build artifacts into Docker build context. --- backend/.dockerignore | 10 ++++++++++ frontend/.dockerignore | 6 ++++++ 2 files changed, 16 insertions(+) create mode 100644 backend/.dockerignore create mode 100644 frontend/.dockerignore diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..c6acd0e --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,10 @@ +node_modules +npm-debug.log +dist +*.tsbuildinfo +.git +.gitignore +Dockerfile* +coverage +*.local +uploads diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..7abceb9 --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,6 @@ +node_modules +dist +.git +.gitignore +Dockerfile* +npm-debug.log -- 2.54.0 From 64180d21e79b51146990ef1b2f93fc0ff7f0f5e6 Mon Sep 17 00:00:00 2001 From: nobswebdev Date: Tue, 6 Oct 2026 19:44:31 +0200 Subject: [PATCH 6/6] validate env booleans as true/false strings only. Avoid @IsBoolean with implicit conversion mismatching isEnabled(); add tests and shared BOOLEAN_STRING_VALUES. --- backend/src/config/validate.spec.ts | 37 +++++++++++++++++++++++ backend/src/config/validate.ts | 12 +++++--- backend/src/consts/booleanStringValues.ts | 1 + backend/src/types/BooleanStringValue.ts | 3 ++ 4 files changed, 48 insertions(+), 5 deletions(-) create mode 100644 backend/src/config/validate.spec.ts create mode 100644 backend/src/consts/booleanStringValues.ts create mode 100644 backend/src/types/BooleanStringValue.ts diff --git a/backend/src/config/validate.spec.ts b/backend/src/config/validate.spec.ts new file mode 100644 index 0000000..e81a0f5 --- /dev/null +++ b/backend/src/config/validate.spec.ts @@ -0,0 +1,37 @@ +import 'reflect-metadata'; +import { validate } from './validate'; + +const validConfig = (overrides: Record = {}): Record => ({ + NODE_ENV: 'development', + BACKEND_PORT: 3000, + POSTGRES_USER: 'postgres', + POSTGRES_PASSWORD: 'postgres', + POSTGRES_DB: 'private_image', + POSTGRES_HOST: 'postgres', + POSTGRES_PORT: 5432, + POSTGRES_MIGRATIONS_RUN: 'false', + CORS_ORIGINS: 'http://localhost:5173', + MAX_IMAGES_PER_UPLOAD: 10, + MULTER_MAX_FILE_BYTES: 10485760, + ALTCHA_ENABLED: 'true', + ALTCHA_HMAC_KEY: 'secret', + ALTCHA_MAX_NUMBER: 50000, + NEVER_MODE_INACTIVITY_DAYS: 365, + THROTTLE_TTL_MS: 60000, + THROTTLE_LIMIT: 1000, + THROTTLE_UPLOAD_TTL_MS: 60000, + THROTTLE_UPLOAD_LIMIT: 20, + ...overrides +}); + +describe('validate', () => { + it('accepts true and false env boolean strings', () => { + expect(() => validate(validConfig({ POSTGRES_MIGRATIONS_RUN: 'true' }))).not.toThrow(); + expect(() => validate(validConfig({ ALTCHA_ENABLED: 'false' }))).not.toThrow(); + }); + + it('rejects non-literal boolean env values', () => { + expect(() => validate(validConfig({ ALTCHA_ENABLED: 'yes' }))).toThrow(); + expect(() => validate(validConfig({ POSTGRES_MIGRATIONS_RUN: '1' }))).toThrow(); + }); +}); diff --git a/backend/src/config/validate.ts b/backend/src/config/validate.ts index f95945d..65923e9 100644 --- a/backend/src/config/validate.ts +++ b/backend/src/config/validate.ts @@ -1,5 +1,7 @@ import { plainToClass } from 'class-transformer'; -import { IsBoolean, IsEnum, IsNotEmpty, IsNumber, IsString, Max, Min, validateSync } from 'class-validator'; +import { IsEnum, IsIn, IsNotEmpty, IsNumber, IsString, Max, Min, validateSync } from 'class-validator'; +import { BOOLEAN_STRING_VALUES } from '../consts/booleanStringValues'; +import type { BooleanStringValue } from '../types/BooleanStringValue'; import { NodeEnv } from '../types/NodeEnv'; class EnvironmentVariables { @@ -36,8 +38,8 @@ class EnvironmentVariables { POSTGRES_PORT: number; @IsNotEmpty() - @IsBoolean() - POSTGRES_MIGRATIONS_RUN: boolean; + @IsIn(BOOLEAN_STRING_VALUES) + POSTGRES_MIGRATIONS_RUN: BooleanStringValue; @IsNotEmpty() @IsString() @@ -56,8 +58,8 @@ class EnvironmentVariables { MULTER_MAX_FILE_BYTES: number; @IsNotEmpty() - @IsBoolean() - ALTCHA_ENABLED: boolean; + @IsIn(BOOLEAN_STRING_VALUES) + ALTCHA_ENABLED: BooleanStringValue; @IsNotEmpty() @IsString() diff --git a/backend/src/consts/booleanStringValues.ts b/backend/src/consts/booleanStringValues.ts new file mode 100644 index 0000000..309c04f --- /dev/null +++ b/backend/src/consts/booleanStringValues.ts @@ -0,0 +1 @@ +export const BOOLEAN_STRING_VALUES = ['true', 'false'] as const; diff --git a/backend/src/types/BooleanStringValue.ts b/backend/src/types/BooleanStringValue.ts new file mode 100644 index 0000000..40237ab --- /dev/null +++ b/backend/src/types/BooleanStringValue.ts @@ -0,0 +1,3 @@ +import { BOOLEAN_STRING_VALUES } from '../consts/booleanStringValues'; + +export type BooleanStringValue = (typeof BOOLEAN_STRING_VALUES)[number]; -- 2.54.0