514 lines
14 KiB
TypeScript
514 lines
14 KiB
TypeScript
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
|
|
export type AppConfig = {
|
|
nodeEnv: string;
|
|
projectRoot: string;
|
|
publicDir: string;
|
|
logsDir: string;
|
|
dataDir: string;
|
|
rawEnv: Record<string, string>;
|
|
databaseUrl: string;
|
|
serverAddr: string;
|
|
logLevel: 'fatal' | 'error' | 'warn' | 'info' | 'debug' | 'trace' | 'silent';
|
|
editorApiEnabled: boolean;
|
|
assetsApiEnabled: boolean;
|
|
jwtSecret: string;
|
|
jwtExpiresIn: string;
|
|
jwtIssuer: string;
|
|
llm: {
|
|
baseUrl: string;
|
|
apiKey: string;
|
|
model: string;
|
|
};
|
|
dashScope: {
|
|
baseUrl: string;
|
|
apiKey: string;
|
|
imageModel: string;
|
|
requestTimeoutMs: number;
|
|
};
|
|
smsAuth: {
|
|
enabled: boolean;
|
|
provider: 'aliyun' | 'mock';
|
|
endpoint: string;
|
|
accessKeyId: string;
|
|
accessKeySecret: string;
|
|
signName: string;
|
|
templateCode: string;
|
|
templateParamKey: string;
|
|
countryCode: string;
|
|
schemeName: string;
|
|
codeLength: number;
|
|
codeType: number;
|
|
validTimeSeconds: number;
|
|
intervalSeconds: number;
|
|
duplicatePolicy: number;
|
|
caseAuthPolicy: number;
|
|
returnVerifyCode: boolean;
|
|
mockVerifyCode: string;
|
|
maxSendPerPhonePerDay: number;
|
|
maxSendPerIpPerHour: number;
|
|
maxVerifyFailuresPerPhonePerHour: number;
|
|
maxVerifyFailuresPerIpPerHour: number;
|
|
captchaTtlSeconds: number;
|
|
captchaTriggerVerifyFailuresPerPhone: number;
|
|
captchaTriggerVerifyFailuresPerIp: number;
|
|
blockPhoneFailureThreshold: number;
|
|
blockIpFailureThreshold: number;
|
|
blockPhoneDurationMinutes: number;
|
|
blockIpDurationMinutes: number;
|
|
};
|
|
wechatAuth: {
|
|
enabled: boolean;
|
|
provider: 'wechat' | 'mock';
|
|
appId: string;
|
|
appSecret: string;
|
|
authorizeEndpoint: string;
|
|
accessTokenEndpoint: string;
|
|
userInfoEndpoint: string;
|
|
callbackPath: string;
|
|
defaultRedirectPath: string;
|
|
mockUserId: string;
|
|
mockUnionId: string;
|
|
mockDisplayName: string;
|
|
mockAvatarUrl: string;
|
|
};
|
|
authSession: {
|
|
refreshCookieName: string;
|
|
refreshSessionTtlDays: number;
|
|
refreshCookieSecure: boolean;
|
|
refreshCookieSameSite: 'Lax' | 'Strict' | 'None';
|
|
refreshCookiePath: string;
|
|
};
|
|
};
|
|
|
|
type LoadConfigOptions = {
|
|
env?: NodeJS.ProcessEnv;
|
|
projectRoot?: string;
|
|
};
|
|
|
|
function parseEnvContents(contents: string) {
|
|
return contents
|
|
.split(/\r?\n/u)
|
|
.reduce<Record<string, string>>((envMap, rawLine) => {
|
|
const line = rawLine.trim();
|
|
if (!line || line.startsWith('#')) {
|
|
return envMap;
|
|
}
|
|
|
|
const separatorIndex = line.indexOf('=');
|
|
if (separatorIndex < 0) {
|
|
return envMap;
|
|
}
|
|
|
|
const key = line.slice(0, separatorIndex).trim();
|
|
let value = line.slice(separatorIndex + 1).trim();
|
|
|
|
if (
|
|
(value.startsWith('"') && value.endsWith('"')) ||
|
|
(value.startsWith("'") && value.endsWith("'"))
|
|
) {
|
|
value = value.slice(1, -1);
|
|
}
|
|
|
|
envMap[key] = value;
|
|
return envMap;
|
|
}, {});
|
|
}
|
|
|
|
function readEnvFile(filePath: string) {
|
|
if (!fs.existsSync(filePath)) {
|
|
return {};
|
|
}
|
|
|
|
return parseEnvContents(fs.readFileSync(filePath, 'utf8'));
|
|
}
|
|
|
|
function resolveDefaultProjectRoot() {
|
|
const cwd = process.cwd();
|
|
return path.basename(cwd) === 'server-node'
|
|
? path.resolve(cwd, '..')
|
|
: cwd;
|
|
}
|
|
|
|
function readMergedEnv(
|
|
exampleEnv: Record<string, string>,
|
|
localEnv: Record<string, string>,
|
|
processEnv: NodeJS.ProcessEnv,
|
|
) {
|
|
return {
|
|
...exampleEnv,
|
|
...localEnv,
|
|
...processEnv,
|
|
};
|
|
}
|
|
|
|
function hasOwnEnvKey(
|
|
env: Record<string, string | undefined>,
|
|
key: string,
|
|
) {
|
|
return Object.prototype.hasOwnProperty.call(env, key);
|
|
}
|
|
|
|
function readBooleanOverride(
|
|
env: Record<string, string | undefined>,
|
|
overrideSources: Array<Record<string, string | undefined>>,
|
|
key: string,
|
|
fallback: boolean,
|
|
) {
|
|
const hasOverride = overrideSources.some((source) => hasOwnEnvKey(source, key));
|
|
if (!hasOverride) {
|
|
return fallback;
|
|
}
|
|
|
|
return readBoolean(env, key, fallback);
|
|
}
|
|
|
|
function readString(
|
|
env: Record<string, string | undefined>,
|
|
key: string,
|
|
fallback: string,
|
|
) {
|
|
const value = env[key]?.trim();
|
|
return value ? value : fallback;
|
|
}
|
|
|
|
function readPositiveInt(
|
|
env: Record<string, string | undefined>,
|
|
key: string,
|
|
fallback: number,
|
|
) {
|
|
const parsed = Number(env[key]);
|
|
return Number.isFinite(parsed) && parsed > 0 ? Math.round(parsed) : fallback;
|
|
}
|
|
|
|
function readIntInRange(
|
|
env: Record<string, string | undefined>,
|
|
key: string,
|
|
fallback: number,
|
|
options: {
|
|
min: number;
|
|
max: number;
|
|
},
|
|
) {
|
|
const parsed = Number(env[key]);
|
|
if (!Number.isFinite(parsed)) {
|
|
return fallback;
|
|
}
|
|
|
|
const rounded = Math.round(parsed);
|
|
if (rounded < options.min || rounded > options.max) {
|
|
return fallback;
|
|
}
|
|
|
|
return rounded;
|
|
}
|
|
|
|
function readBoolean(
|
|
env: Record<string, string | undefined>,
|
|
key: string,
|
|
fallback: boolean,
|
|
) {
|
|
const value = env[key]?.trim().toLowerCase();
|
|
if (!value) {
|
|
return fallback;
|
|
}
|
|
if (value === '1' || value === 'true' || value === 'yes' || value === 'on') {
|
|
return true;
|
|
}
|
|
if (value === '0' || value === 'false' || value === 'no' || value === 'off') {
|
|
return false;
|
|
}
|
|
return fallback;
|
|
}
|
|
|
|
export function loadConfig(options: LoadConfigOptions = {}): AppConfig {
|
|
const projectRoot = options.projectRoot ?? resolveDefaultProjectRoot();
|
|
const exampleEnv = readEnvFile(path.join(projectRoot, '.env.example'));
|
|
const localEnv = readEnvFile(path.join(projectRoot, '.env.local'));
|
|
const processEnv = options.env ?? process.env;
|
|
const env = readMergedEnv(exampleEnv, localEnv, processEnv);
|
|
const logsDir = path.join(projectRoot, 'server-node', 'logs');
|
|
const dataDir = path.join(projectRoot, 'server-node', 'data');
|
|
const nodeEnv = readString(env, 'NODE_ENV', 'development');
|
|
const defaultEditorApiEnabled = nodeEnv !== 'production';
|
|
const editorApiEnabled = readBoolean(
|
|
env,
|
|
'EDITOR_API_ENABLED',
|
|
defaultEditorApiEnabled,
|
|
);
|
|
const smsProviderFromEnv = readString(
|
|
env,
|
|
'SMS_AUTH_PROVIDER',
|
|
nodeEnv === 'test' ? 'mock' : 'aliyun',
|
|
) as AppConfig['smsAuth']['provider'];
|
|
const smsAccessKeyId = readString(env, 'ALIYUN_SMS_ACCESS_KEY_ID', '');
|
|
const smsAccessKeySecret = readString(env, 'ALIYUN_SMS_ACCESS_KEY_SECRET', '');
|
|
const smsProvider = smsProviderFromEnv;
|
|
const defaultSmsEnabled =
|
|
smsProvider === 'mock' ||
|
|
Boolean(smsAccessKeyId && smsAccessKeySecret);
|
|
const smsEnabled = readBooleanOverride(
|
|
env,
|
|
[localEnv, processEnv],
|
|
'SMS_AUTH_ENABLED',
|
|
defaultSmsEnabled,
|
|
);
|
|
const wechatProvider = readString(
|
|
env,
|
|
'WECHAT_AUTH_PROVIDER',
|
|
nodeEnv === 'test' ? 'mock' : 'wechat',
|
|
) as AppConfig['wechatAuth']['provider'];
|
|
const wechatAppId = readString(env, 'WECHAT_APP_ID', '');
|
|
const wechatAppSecret = readString(env, 'WECHAT_APP_SECRET', '');
|
|
const defaultWechatEnabled =
|
|
wechatProvider === 'mock' || Boolean(wechatAppId && wechatAppSecret);
|
|
const wechatEnabled = readBooleanOverride(
|
|
env,
|
|
[localEnv, processEnv],
|
|
'WECHAT_AUTH_ENABLED',
|
|
defaultWechatEnabled,
|
|
);
|
|
const refreshSameSite = readString(
|
|
env,
|
|
'AUTH_REFRESH_COOKIE_SAME_SITE',
|
|
'Lax',
|
|
);
|
|
|
|
return {
|
|
nodeEnv,
|
|
projectRoot,
|
|
publicDir: path.join(projectRoot, 'public'),
|
|
logsDir,
|
|
dataDir,
|
|
rawEnv: Object.fromEntries(
|
|
Object.entries(env).flatMap(([key, value]) =>
|
|
typeof value === 'string' ? [[key, value]] : [],
|
|
),
|
|
),
|
|
databaseUrl: readString(
|
|
env,
|
|
'DATABASE_URL',
|
|
'postgresql://postgres:postgres@127.0.0.1:5432/genarrative',
|
|
),
|
|
serverAddr: readString(env, 'NODE_SERVER_ADDR', ':8081'),
|
|
logLevel: readString(env, 'LOG_LEVEL', 'info') as AppConfig['logLevel'],
|
|
editorApiEnabled,
|
|
assetsApiEnabled: readBoolean(
|
|
env,
|
|
'ASSETS_API_ENABLED',
|
|
editorApiEnabled,
|
|
),
|
|
jwtSecret: readString(env, 'JWT_SECRET', 'genarrative-dev-secret'),
|
|
jwtExpiresIn: readString(env, 'JWT_EXPIRES_IN', '2h'),
|
|
jwtIssuer: readString(env, 'JWT_ISSUER', 'genarrative-server-node'),
|
|
llm: {
|
|
baseUrl: readString(
|
|
env,
|
|
'LLM_BASE_URL',
|
|
'https://ark.cn-beijing.volces.com/api/v3',
|
|
),
|
|
apiKey:
|
|
env.LLM_API_KEY?.trim() ||
|
|
env.ARK_API_KEY?.trim() ||
|
|
env.VITE_LLM_API_KEY?.trim() ||
|
|
'',
|
|
model: readString(
|
|
env,
|
|
'LLM_MODEL',
|
|
readString(
|
|
env,
|
|
'VITE_LLM_MODEL',
|
|
'doubao-1-5-pro-32k-character-250715',
|
|
),
|
|
),
|
|
},
|
|
dashScope: {
|
|
baseUrl: readString(
|
|
env,
|
|
'DASHSCOPE_BASE_URL',
|
|
'https://dashscope.aliyuncs.com/api/v1',
|
|
),
|
|
apiKey: env.DASHSCOPE_API_KEY?.trim() || '',
|
|
imageModel: readString(env, 'DASHSCOPE_IMAGE_MODEL', 'wan2.2-t2i-flash'),
|
|
requestTimeoutMs: readPositiveInt(
|
|
env,
|
|
'DASHSCOPE_IMAGE_REQUEST_TIMEOUT_MS',
|
|
150000,
|
|
),
|
|
},
|
|
smsAuth: {
|
|
enabled: smsEnabled,
|
|
provider: smsProvider,
|
|
endpoint: readString(
|
|
env,
|
|
'ALIYUN_SMS_ENDPOINT',
|
|
'dypnsapi.aliyuncs.com',
|
|
),
|
|
accessKeyId: smsAccessKeyId,
|
|
accessKeySecret: smsAccessKeySecret,
|
|
signName: readString(
|
|
env,
|
|
'ALIYUN_SMS_SIGN_NAME',
|
|
'速通互联验证码',
|
|
),
|
|
templateCode: readString(env, 'ALIYUN_SMS_TEMPLATE_CODE', '100001'),
|
|
templateParamKey: readString(
|
|
env,
|
|
'ALIYUN_SMS_TEMPLATE_PARAM_KEY',
|
|
'code',
|
|
),
|
|
countryCode: readString(env, 'ALIYUN_SMS_COUNTRY_CODE', '86'),
|
|
schemeName: readString(env, 'ALIYUN_SMS_SCHEME_NAME', ''),
|
|
codeLength: readIntInRange(env, 'ALIYUN_SMS_CODE_LENGTH', 6, {
|
|
min: 4,
|
|
max: 8,
|
|
}),
|
|
codeType: readIntInRange(env, 'ALIYUN_SMS_CODE_TYPE', 1, {
|
|
min: 1,
|
|
max: 7,
|
|
}),
|
|
validTimeSeconds: readPositiveInt(
|
|
env,
|
|
'ALIYUN_SMS_VALID_TIME_SECONDS',
|
|
300,
|
|
),
|
|
intervalSeconds: readPositiveInt(
|
|
env,
|
|
'ALIYUN_SMS_INTERVAL_SECONDS',
|
|
60,
|
|
),
|
|
duplicatePolicy: readIntInRange(
|
|
env,
|
|
'ALIYUN_SMS_DUPLICATE_POLICY',
|
|
1,
|
|
{ min: 1, max: 2 },
|
|
),
|
|
caseAuthPolicy: readIntInRange(
|
|
env,
|
|
'ALIYUN_SMS_CASE_AUTH_POLICY',
|
|
1,
|
|
{ min: 1, max: 2 },
|
|
),
|
|
returnVerifyCode: readBoolean(
|
|
env,
|
|
'ALIYUN_SMS_RETURN_VERIFY_CODE',
|
|
false,
|
|
),
|
|
mockVerifyCode: readString(env, 'SMS_AUTH_MOCK_VERIFY_CODE', '123456'),
|
|
maxSendPerPhonePerDay: readPositiveInt(
|
|
env,
|
|
'SMS_AUTH_MAX_SEND_PER_PHONE_PER_DAY',
|
|
20,
|
|
),
|
|
maxSendPerIpPerHour: readPositiveInt(
|
|
env,
|
|
'SMS_AUTH_MAX_SEND_PER_IP_PER_HOUR',
|
|
30,
|
|
),
|
|
maxVerifyFailuresPerPhonePerHour: readPositiveInt(
|
|
env,
|
|
'SMS_AUTH_MAX_VERIFY_FAILURES_PER_PHONE_PER_HOUR',
|
|
12,
|
|
),
|
|
maxVerifyFailuresPerIpPerHour: readPositiveInt(
|
|
env,
|
|
'SMS_AUTH_MAX_VERIFY_FAILURES_PER_IP_PER_HOUR',
|
|
24,
|
|
),
|
|
captchaTtlSeconds: readPositiveInt(
|
|
env,
|
|
'SMS_AUTH_CAPTCHA_TTL_SECONDS',
|
|
180,
|
|
),
|
|
captchaTriggerVerifyFailuresPerPhone: readPositiveInt(
|
|
env,
|
|
'SMS_AUTH_CAPTCHA_TRIGGER_VERIFY_FAILURES_PER_PHONE',
|
|
3,
|
|
),
|
|
captchaTriggerVerifyFailuresPerIp: readPositiveInt(
|
|
env,
|
|
'SMS_AUTH_CAPTCHA_TRIGGER_VERIFY_FAILURES_PER_IP',
|
|
5,
|
|
),
|
|
blockPhoneFailureThreshold: readPositiveInt(
|
|
env,
|
|
'SMS_AUTH_BLOCK_PHONE_FAILURE_THRESHOLD',
|
|
6,
|
|
),
|
|
blockIpFailureThreshold: readPositiveInt(
|
|
env,
|
|
'SMS_AUTH_BLOCK_IP_FAILURE_THRESHOLD',
|
|
10,
|
|
),
|
|
blockPhoneDurationMinutes: readPositiveInt(
|
|
env,
|
|
'SMS_AUTH_BLOCK_PHONE_DURATION_MINUTES',
|
|
30,
|
|
),
|
|
blockIpDurationMinutes: readPositiveInt(
|
|
env,
|
|
'SMS_AUTH_BLOCK_IP_DURATION_MINUTES',
|
|
30,
|
|
),
|
|
},
|
|
wechatAuth: {
|
|
enabled: wechatEnabled,
|
|
provider: wechatProvider,
|
|
appId: wechatAppId,
|
|
appSecret: wechatAppSecret,
|
|
authorizeEndpoint: readString(
|
|
env,
|
|
'WECHAT_AUTHORIZE_ENDPOINT',
|
|
'https://open.weixin.qq.com/connect/qrconnect',
|
|
),
|
|
accessTokenEndpoint: readString(
|
|
env,
|
|
'WECHAT_ACCESS_TOKEN_ENDPOINT',
|
|
'https://api.weixin.qq.com/sns/oauth2/access_token',
|
|
),
|
|
userInfoEndpoint: readString(
|
|
env,
|
|
'WECHAT_USER_INFO_ENDPOINT',
|
|
'https://api.weixin.qq.com/sns/userinfo',
|
|
),
|
|
callbackPath: readString(
|
|
env,
|
|
'WECHAT_CALLBACK_PATH',
|
|
'/api/auth/wechat/callback',
|
|
),
|
|
defaultRedirectPath: readString(env, 'WECHAT_REDIRECT_PATH', '/'),
|
|
mockUserId: readString(env, 'WECHAT_MOCK_USER_ID', 'mock_wechat_user'),
|
|
mockUnionId: readString(env, 'WECHAT_MOCK_UNION_ID', 'mock_wechat_union'),
|
|
mockDisplayName: readString(env, 'WECHAT_MOCK_DISPLAY_NAME', '微信旅人'),
|
|
mockAvatarUrl: readString(env, 'WECHAT_MOCK_AVATAR_URL', ''),
|
|
},
|
|
authSession: {
|
|
refreshCookieName: readString(
|
|
env,
|
|
'AUTH_REFRESH_COOKIE_NAME',
|
|
'genarrative_refresh_session',
|
|
),
|
|
refreshSessionTtlDays: readPositiveInt(
|
|
env,
|
|
'AUTH_REFRESH_SESSION_TTL_DAYS',
|
|
30,
|
|
),
|
|
refreshCookieSecure: readBoolean(
|
|
env,
|
|
'AUTH_REFRESH_COOKIE_SECURE',
|
|
readString(env, 'NODE_ENV', 'development') === 'production',
|
|
),
|
|
refreshCookieSameSite:
|
|
refreshSameSite === 'None' || refreshSameSite === 'Strict'
|
|
? (refreshSameSite as AppConfig['authSession']['refreshCookieSameSite'])
|
|
: 'Lax',
|
|
refreshCookiePath: readString(
|
|
env,
|
|
'AUTH_REFRESH_COOKIE_PATH',
|
|
'/api/auth',
|
|
),
|
|
},
|
|
};
|
|
}
|