171 lines
5.9 KiB
TypeScript
171 lines
5.9 KiB
TypeScript
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<typeof environmentSchema>;
|
|
export type AppConfig = Omit<
|
|
Environment,
|
|
'TRUSTED_PROXY_CIDRS' | 'SWAGGER_ENABLED'
|
|
> & {
|
|
trustedProxyCidrs: string[];
|
|
swaggerEnabled: boolean;
|
|
objectStorageAccessKey: string;
|
|
objectStorageSecretKey: string;
|
|
objectStorageForcePathStyle: boolean;
|
|
};
|
|
|
|
function validateEnvironment(
|
|
environment: Record<string, unknown>,
|
|
): 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 {}
|