import { existsSync } from 'node:fs'; import { resolve } from 'node:path'; import { Global, Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { z } from 'zod'; export const APP_CONFIG = Symbol('APP_CONFIG'); function findEnvironmentFile(): string { const directories = [ process.cwd(), resolve(process.cwd(), '..'), resolve(process.cwd(), '../..'), ]; const environmentFile = directories .map((directory) => resolve(directory, '.env')) .find((file) => existsSync(file)); return environmentFile ?? resolve(process.cwd(), '.env'); } // 此 schema 是 API 运行配置的唯一来源;默认值只允许用于不含秘密的本地开发场景。 const environmentSchema = z.object({ NODE_ENV: z .enum(['development', 'test', 'production']) .default('development'), API_PORT: z.coerce.number().int().min(1).max(65_535).default(8200), API_PUBLIC_ORIGIN: z.url().default('http://localhost:8200'), WEB_ORIGIN: z.url().default('http://localhost:8100'), // The redirect URI is deliberately exact. A wildcard would allow another // installed extension to exchange a code created from the user's Web session. EXTENSION_COLLECTOR_CLIENT_ID: z.string().min(1).max(128).default('atlas-collector'), EXTENSION_COLLECTOR_REDIRECT_URI: z.url().default('https://atlas-collector.chromiumapp.org/callback'), DATABASE_URL: z.url(), REDIS_URL: z.url(), OBJECT_STORAGE_ENDPOINT: z.url().default('http://localhost:9000'), OBJECT_STORAGE_BUCKET: z.string().min(3).max(63).default('atlas-assets'), OBJECT_STORAGE_FORCE_PATH_STYLE: z.enum(['true', 'false']).default('true'), OBJECT_STORAGE_ACCESS_KEY: z.string().min(3).optional(), OBJECT_STORAGE_SECRET_KEY: z.string().min(8).optional(), MINIO_ROOT_USER: z.string().min(3).optional(), MINIO_ROOT_PASSWORD: z.string().min(8).optional(), AUTH_JWT_SECRET: z.string().min(32), AUTH_RATE_LIMIT_KEY_SECRET: z.string().min(32), STORE_SECRET_ENCRYPTION_KEY: z.string().min(32), STORE_SECRET_ENCRYPTION_KEY_VERSION: z .string() .min(1) .max(64) .default('v1'), IDEMPOTENCY_KEY_SECRET: z.string().min(32), AUTH_PASSWORD_MIN_LENGTH: z.coerce .number() .int() .min(12) .max(256) .default(12), API_BODY_LIMIT_BYTES: z.coerce .number() .int() .min(1_024) .max(10 * 1024 * 1024) .default(1_048_576), AUTH_RATE_LIMIT_WINDOW_SECONDS: z.coerce .number() .int() .min(1) .max(86_400) .default(900), AUTH_LOGIN_IP_LIMIT: z.coerce.number().int().min(1).max(1000).default(10), AUTH_LOGIN_EMAIL_LIMIT: z.coerce.number().int().min(1).max(1000).default(5), AUTH_REGISTER_IP_LIMIT: z.coerce.number().int().min(1).max(1000).default(5), AUTH_REGISTER_EMAIL_LIMIT: z.coerce .number() .int() .min(1) .max(1000) .default(3), AUTH_REFRESH_IP_LIMIT: z.coerce .number() .int() .min(1) .max(10_000) .default(30), AUTH_CHANGE_PASSWORD_IP_LIMIT: z.coerce .number() .int() .min(1) .max(1_000) .default(10), AUTH_CHANGE_PASSWORD_USER_LIMIT: z.coerce .number() .int() .min(1) .max(1_000) .default(5), TRUSTED_PROXY_CIDRS: z.string().default(''), SWAGGER_ENABLED: z.enum(['true', 'false']).default('true'), LOG_LEVEL: z.enum(['error', 'warn', 'log', 'debug']).default('log'), }); type Environment = z.infer; export type AppConfig = Omit< Environment, 'TRUSTED_PROXY_CIDRS' | 'SWAGGER_ENABLED' > & { trustedProxyCidrs: string[]; swaggerEnabled: boolean; objectStorageAccessKey: string; objectStorageSecretKey: string; objectStorageForcePathStyle: boolean; }; function validateEnvironment( environment: Record, ): Environment { const result = environmentSchema.safeParse(environment); if (result.success) return result.data; // 在 Nest 开始监听前失败,避免半配置 API 对外提供不安全服务。 throw new Error( `Invalid environment configuration: ${result.error.issues.map((issue) => issue.path.join('.')).join(', ')}`, ); } function createAppConfig(environment: Environment): AppConfig { const objectStorageAccessKey = environment.OBJECT_STORAGE_ACCESS_KEY ?? environment.MINIO_ROOT_USER; const objectStorageSecretKey = environment.OBJECT_STORAGE_SECRET_KEY ?? environment.MINIO_ROOT_PASSWORD; if (!objectStorageAccessKey || !objectStorageSecretKey) { throw new Error( 'OBJECT_STORAGE_ACCESS_KEY and OBJECT_STORAGE_SECRET_KEY are required', ); } return { ...environment, // 只有显式给出代理网段时才信任转发头,避免客户端伪造来源 IP。 trustedProxyCidrs: environment.TRUSTED_PROXY_CIDRS.split(',') .map((value) => value.trim()) .filter(Boolean), swaggerEnabled: environment.SWAGGER_ENABLED === 'true', objectStorageAccessKey, objectStorageSecretKey, objectStorageForcePathStyle: environment.OBJECT_STORAGE_FORCE_PATH_STYLE === 'true', }; } @Global() @Module({ imports: [ ConfigModule.forRoot({ isGlobal: true, cache: true, // Turbo 从 apps/api 运行脚本,但共享开发环境文件位于仓库根目录。 envFilePath: findEnvironmentFile(), }), ], providers: [ { provide: APP_CONFIG, // 导出规范化配置,业务服务不得散落读取 process.env。 useFactory: (): AppConfig => createAppConfig(validateEnvironment(process.env)), }, ], exports: [APP_CONFIG], }) export class AppConfigModule {}