From 87376ea37f44c11c008e9dc3883265b90025ca48 Mon Sep 17 00:00:00 2001 From: nobswebdev Date: Mon, 5 Oct 2026 23:53:28 +0200 Subject: [PATCH] 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; +}