forked from wangziqi/gongxue-base
249 lines
11 KiB
TypeScript
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;
|