Files
gongxue-base/apps/api/src/core/config.ts
2026-07-04 00:24:57 +08:00

249 lines
11 KiB
TypeScript

import { DEFAULT_DATABASE_URL, DEFAULT_TENANT_SLUG, envBoolean, envList, envNumber, envString, loadDotenv } from '../../../../packages/config/src/index.js';
export interface ApiConfig {
nodeEnv: string;
port: number;
databaseUrl: string;
defaultTenantSlug: string;
corsOrigins: string[];
maxJsonBodyBytes: number;
maxImportJsonBodyBytes: number;
authCodePepper: string;
authSessionSecret: string;
authSmsProvider: string;
authJwtIssuer: string;
authJwtAudience: string;
authJwtSecret: string;
authJwtJwksUrl: string;
authCodeTtlSeconds: number;
authSmsCooldownSeconds: number;
authSessionTtlSeconds: number;
allowLegacyAuthHeaders: boolean;
allowPlatformAdminKey: boolean;
platformAdminApiKey: string;
storageDefaultProvider: string;
storageDefaultBucket: string;
storagePublicBaseUrl: string;
storageMaxUploadBytes: number;
storageAllowedMimePrefixes: string[];
storageAllowedMimeTypes: string[];
storageRequireTenantPrefix: boolean;
aliyunOssRegion: string;
aliyunOssEndpoint: string;
aliyunOssAccessKeyId: string;
aliyunOssAccessKeySecret: string;
aliyunOssStsToken: string;
aliyunOssInternal: boolean;
tencentCosRegion: string;
tencentCosAppId: string;
tencentCosSecretId: string;
tencentCosSecretKey: string;
tencentCosSecurityToken: string;
supabaseStorageUrl: string;
supabaseStorageServiceKey: string;
isProduction: boolean;
}
loadDotenv();
const DEFAULT_AUTH_CODE_PEPPER = 'development-code-pepper-change-me';
const DEFAULT_AUTH_SESSION_SECRET = 'development-session-secret-change-me';
const DEFAULT_AUTH_JWT_SECRET = 'development-jwt-secret-change-me';
const DEFAULT_PLATFORM_ADMIN_API_KEY = 'local-platform-admin-key';
const DEFAULT_MAX_JSON_BODY_BYTES = 1024 * 1024;
const DEFAULT_MAX_IMPORT_JSON_BODY_BYTES = 10 * 1024 * 1024;
const HARD_MAX_JSON_BODY_BYTES = 50 * 1024 * 1024;
const PRODUCTION_SMS_PROVIDERS = new Set([
'aliyun-pnvs',
'aliyun_pnvs',
'aliyun-pnvs-sms',
'aliyun-sms-auth',
'aliyun_sms_auth',
]);
const PRODUCTION_STORAGE_PROVIDERS = new Set(['aliyun_oss', 'tencent_cos', 'supabase_storage']);
function boundedBytes(key: string, fallback: number, hardMax = HARD_MAX_JSON_BODY_BYTES) {
const value = envNumber(key, fallback);
if (!Number.isFinite(value) || value <= 0) return fallback;
return Math.min(Math.trunc(value), hardMax);
}
function isUnsafeSecret(value: string, defaultValue: string) {
const normalized = value.trim().toLowerCase();
return (
value === defaultValue ||
normalized.length < 32 ||
normalized.includes('replace_with') ||
normalized.includes('change-me') ||
normalized.includes('changeme')
);
}
function hostFromUrl(value: string) {
try {
return new URL(value).hostname.toLowerCase();
} catch {
return '';
}
}
function isLocalHost(hostname: string) {
return ['localhost', '127.0.0.1', '::1', '0.0.0.0'].includes(hostname);
}
function isHttpsProductionUrl(value: string) {
if (!value.trim()) return false;
const host = hostFromUrl(value);
return value.startsWith('https://') && Boolean(host) && !isLocalHost(host);
}
function isAllowedHost(value: string, allowedHosts: string[]) {
const host = hostFromUrl(value);
return allowedHosts.some(allowed => host === allowed || host.endsWith(`.${allowed}`));
}
function validateProductionConfig(nextConfig: ApiConfig) {
if (!nextConfig.isProduction) return;
const failures: string[] = [];
if (nextConfig.corsOrigins.includes('*')) failures.push('CORS_ORIGIN must not include * in production');
if (!PRODUCTION_SMS_PROVIDERS.has(nextConfig.authSmsProvider.trim().toLowerCase())) {
failures.push('AUTH_SMS_PROVIDER must be aliyun-pnvs in production');
}
if (isUnsafeSecret(nextConfig.authCodePepper, DEFAULT_AUTH_CODE_PEPPER)) {
failures.push('AUTH_CODE_PEPPER must be a strong production secret');
}
if (isUnsafeSecret(nextConfig.authSessionSecret, DEFAULT_AUTH_SESSION_SECRET)) {
failures.push('AUTH_SESSION_SECRET must be a strong production secret');
}
if (!nextConfig.authJwtJwksUrl && isUnsafeSecret(nextConfig.authJwtSecret, DEFAULT_AUTH_JWT_SECRET)) {
failures.push('AUTH_JWT_SECRET or AUTH_JWT_JWKS_URL must be configured for production JWT verification');
}
if (nextConfig.authJwtJwksUrl && !nextConfig.authJwtIssuer.trim()) {
failures.push('AUTH_JWT_ISSUER is required when AUTH_JWT_JWKS_URL is configured');
}
if (isUnsafeSecret(nextConfig.platformAdminApiKey, DEFAULT_PLATFORM_ADMIN_API_KEY)) {
failures.push('PLATFORM_ADMIN_API_KEY must be a strong production secret until platform JWT is implemented');
}
if (nextConfig.allowLegacyAuthHeaders) {
failures.push('ALLOW_LEGACY_AUTH_HEADERS=true is not allowed in production');
}
if (nextConfig.allowPlatformAdminKey) {
failures.push('ALLOW_PLATFORM_ADMIN_KEY=true is not allowed in production');
}
if (nextConfig.storageDefaultProvider === 'local_dev') {
failures.push('STORAGE_DEFAULT_PROVIDER=local_dev is not allowed in production');
} else if (!PRODUCTION_STORAGE_PROVIDERS.has(nextConfig.storageDefaultProvider)) {
failures.push('STORAGE_DEFAULT_PROVIDER must be aliyun_oss, tencent_cos or supabase_storage in production');
}
if (!nextConfig.storageDefaultBucket.trim()) {
failures.push('STORAGE_DEFAULT_BUCKET is required in production');
}
if (!nextConfig.storageRequireTenantPrefix) {
failures.push('STORAGE_REQUIRE_TENANT_PREFIX=false is not allowed in production');
}
if (nextConfig.storagePublicBaseUrl && !isHttpsProductionUrl(nextConfig.storagePublicBaseUrl)) {
failures.push('STORAGE_PUBLIC_BASE_URL must be a production HTTPS URL when configured');
}
if (nextConfig.storageDefaultProvider === 'aliyun_oss') {
if (nextConfig.aliyunOssInternal) {
failures.push('ALIYUN_OSS_INTERNAL=true is not allowed for user-facing production signed URLs');
}
if (!nextConfig.aliyunOssAccessKeyId || !nextConfig.aliyunOssAccessKeySecret) {
failures.push('ALIYUN_OSS_ACCESS_KEY_ID and ALIYUN_OSS_ACCESS_KEY_SECRET are required for aliyun_oss');
}
if (!nextConfig.aliyunOssRegion && !nextConfig.aliyunOssEndpoint) {
failures.push('ALIYUN_OSS_REGION or ALIYUN_OSS_ENDPOINT is required for aliyun_oss');
}
if (nextConfig.aliyunOssEndpoint && (!isHttpsProductionUrl(nextConfig.aliyunOssEndpoint) || !isAllowedHost(nextConfig.aliyunOssEndpoint, ['aliyuncs.com']))) {
failures.push('ALIYUN_OSS_ENDPOINT must be a production HTTPS aliyuncs.com endpoint');
}
}
if (nextConfig.storageDefaultProvider === 'tencent_cos') {
if (!nextConfig.tencentCosRegion || !nextConfig.tencentCosAppId || !nextConfig.tencentCosSecretId || !nextConfig.tencentCosSecretKey) {
failures.push('TENCENT_COS_REGION, TENCENT_COS_APP_ID, TENCENT_COS_SECRET_ID and TENCENT_COS_SECRET_KEY are required for tencent_cos');
}
}
if (nextConfig.storageDefaultProvider === 'supabase_storage') {
if (!nextConfig.supabaseStorageUrl || !nextConfig.supabaseStorageServiceKey) {
failures.push('SUPABASE_STORAGE_URL and SUPABASE_STORAGE_SERVICE_KEY are required for supabase_storage');
}
if (nextConfig.supabaseStorageUrl && !isHttpsProductionUrl(nextConfig.supabaseStorageUrl)) {
failures.push('SUPABASE_STORAGE_URL must be a production HTTPS URL');
}
}
if (failures.length > 0) {
throw new Error(`Invalid production API configuration: ${failures.join('; ')}`);
}
}
const nodeEnv = envString('NODE_ENV', 'development');
const isProduction = nodeEnv === 'production';
const loadedConfig: ApiConfig = {
nodeEnv,
port: envNumber('PORT', 8787),
databaseUrl: envString('DATABASE_URL', DEFAULT_DATABASE_URL),
defaultTenantSlug: envString('DEFAULT_TENANT_SLUG', DEFAULT_TENANT_SLUG),
corsOrigins: envList('CORS_ORIGIN', '*'),
maxJsonBodyBytes: boundedBytes('MAX_JSON_BODY_BYTES', DEFAULT_MAX_JSON_BODY_BYTES),
maxImportJsonBodyBytes: boundedBytes('MAX_IMPORT_JSON_BODY_BYTES', DEFAULT_MAX_IMPORT_JSON_BODY_BYTES),
authCodePepper: envString('AUTH_CODE_PEPPER', DEFAULT_AUTH_CODE_PEPPER),
authSessionSecret: envString('AUTH_SESSION_SECRET', DEFAULT_AUTH_SESSION_SECRET),
authSmsProvider: envString('AUTH_SMS_PROVIDER', 'mock'),
authJwtIssuer: envString('AUTH_JWT_ISSUER', ''),
authJwtAudience: envString('AUTH_JWT_AUDIENCE', 'authenticated'),
authJwtSecret: envString('AUTH_JWT_SECRET', DEFAULT_AUTH_JWT_SECRET),
authJwtJwksUrl: envString('AUTH_JWT_JWKS_URL', ''),
authCodeTtlSeconds: envNumber('AUTH_CODE_TTL_SECONDS', 300),
authSmsCooldownSeconds: envNumber('AUTH_SMS_COOLDOWN_SECONDS', 60),
authSessionTtlSeconds: envNumber('AUTH_SESSION_TTL_SECONDS', 60 * 60 * 24 * 7),
allowLegacyAuthHeaders: envBoolean('ALLOW_LEGACY_AUTH_HEADERS', !isProduction),
allowPlatformAdminKey: envBoolean('ALLOW_PLATFORM_ADMIN_KEY', !isProduction),
platformAdminApiKey: envString('PLATFORM_ADMIN_API_KEY', DEFAULT_PLATFORM_ADMIN_API_KEY),
storageDefaultProvider: envString('STORAGE_DEFAULT_PROVIDER', 'local_dev'),
storageDefaultBucket: envString('STORAGE_DEFAULT_BUCKET', 'tenant-assets'),
storagePublicBaseUrl: envString('STORAGE_PUBLIC_BASE_URL', ''),
storageMaxUploadBytes: envNumber('STORAGE_MAX_UPLOAD_BYTES', 1024 * 1024 * 500),
storageAllowedMimePrefixes: envList('STORAGE_ALLOWED_MIME_PREFIXES', 'image/,video/,audio/'),
storageAllowedMimeTypes: envList(
'STORAGE_ALLOWED_MIME_TYPES',
[
'application/pdf',
'application/json',
'application/zip',
'application/x-zip-compressed',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/vnd.ms-powerpoint',
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'application/octet-stream',
'text/plain',
'text/markdown',
'text/csv',
].join(','),
),
storageRequireTenantPrefix: envBoolean('STORAGE_REQUIRE_TENANT_PREFIX', true),
aliyunOssRegion: envString('ALIYUN_OSS_REGION', ''),
aliyunOssEndpoint: envString('ALIYUN_OSS_ENDPOINT', ''),
aliyunOssAccessKeyId: envString('ALIYUN_OSS_ACCESS_KEY_ID', ''),
aliyunOssAccessKeySecret: envString('ALIYUN_OSS_ACCESS_KEY_SECRET', ''),
aliyunOssStsToken: envString('ALIYUN_OSS_STS_TOKEN', ''),
aliyunOssInternal: envBoolean('ALIYUN_OSS_INTERNAL', false),
tencentCosRegion: envString('TENCENT_COS_REGION', ''),
tencentCosAppId: envString('TENCENT_COS_APP_ID', ''),
tencentCosSecretId: envString('TENCENT_COS_SECRET_ID', ''),
tencentCosSecretKey: envString('TENCENT_COS_SECRET_KEY', ''),
tencentCosSecurityToken: envString('TENCENT_COS_SECURITY_TOKEN', ''),
supabaseStorageUrl: envString('SUPABASE_STORAGE_URL', ''),
supabaseStorageServiceKey: envString('SUPABASE_STORAGE_SERVICE_KEY', ''),
isProduction,
};
validateProductionConfig(loadedConfig);
export const config = loadedConfig;