317 lines
8.5 KiB
TypeScript
317 lines
8.5 KiB
TypeScript
import process from 'node:process'
|
|
|
|
import type { AdminDefaultUser, RuntimeConfig } from '../types/runtime-config.js'
|
|
|
|
type RuntimeEnvironment = {
|
|
NODE_ENV?: string
|
|
CI?: string
|
|
}
|
|
|
|
type ValidationOptions = {
|
|
env?: RuntimeEnvironment
|
|
}
|
|
|
|
export type RuntimeConfigValidationIssue = {
|
|
path: string
|
|
message: string
|
|
}
|
|
|
|
export class RuntimeConfigValidationError extends Error {
|
|
issues: RuntimeConfigValidationIssue[]
|
|
|
|
constructor(issues: RuntimeConfigValidationIssue[]) {
|
|
super(formatRuntimeConfigValidationMessage(issues))
|
|
this.name = 'RuntimeConfigValidationError'
|
|
this.issues = issues
|
|
}
|
|
}
|
|
|
|
export function assertRuntimeConfigValid(
|
|
config: RuntimeConfig,
|
|
options: ValidationOptions = {},
|
|
): void {
|
|
const issues = validateRuntimeConfig(config, options)
|
|
if (issues.length > 0) {
|
|
throw new RuntimeConfigValidationError(issues)
|
|
}
|
|
}
|
|
|
|
export function validateRuntimeConfig(
|
|
config: RuntimeConfig,
|
|
{ env = process.env }: ValidationOptions = {},
|
|
): RuntimeConfigValidationIssue[] {
|
|
const issues: RuntimeConfigValidationIssue[] = []
|
|
const productionLike = isProductionLike(env)
|
|
|
|
requireInteger(issues, 'server.port', config.server?.port, { min: 1, max: 65535 })
|
|
const trustProxy = config.server?.trustProxy
|
|
if (trustProxy !== undefined && trustProxy !== null && !isValidTrustProxy(trustProxy)) {
|
|
issues.push({
|
|
path: 'server.trustProxy',
|
|
message: 'server.trustProxy 仅支持 true/false、非负整数或 loopback/uniquelocal/linklocal',
|
|
})
|
|
}
|
|
requireInteger(issues, 'database.maxConnections', config.database?.maxConnections, { min: 1 })
|
|
requireOptionalInteger(issues, 'database.idleTimeoutMs', config.database?.idleTimeoutMs, {
|
|
min: 1,
|
|
})
|
|
requireOptionalInteger(
|
|
issues,
|
|
'database.connectionTimeoutMs',
|
|
config.database?.connectionTimeoutMs,
|
|
{ min: 1 },
|
|
)
|
|
requireOptionalInteger(
|
|
issues,
|
|
'database.statementTimeoutMs',
|
|
config.database?.statementTimeoutMs,
|
|
{
|
|
min: 1,
|
|
},
|
|
)
|
|
requireOptionalInteger(
|
|
issues,
|
|
'database.slowQueryThresholdMs',
|
|
config.database?.slowQueryThresholdMs,
|
|
{ min: 0 },
|
|
)
|
|
requireInteger(issues, 'storage.maxUploadSizeMb', config.storage?.maxUploadSizeMb, {
|
|
min: 1,
|
|
max: 100,
|
|
})
|
|
requireInteger(issues, 'orders.tokenTtlHours', config.orders?.tokenTtlHours, { min: 0 })
|
|
requireInteger(issues, 'admin.sessionTtlHours', config.admin?.sessionTtlHours, { min: 1 })
|
|
requireInteger(
|
|
issues,
|
|
'platforms.cloudtentacles.timeoutMs',
|
|
config.platforms?.cloudtentacles?.timeoutMs,
|
|
{ min: 1 },
|
|
)
|
|
if (config.platforms?.kuaishouFeifei) {
|
|
requireInteger(
|
|
issues,
|
|
'platforms.kuaishouFeifei.timeoutMs',
|
|
config.platforms.kuaishouFeifei.timeoutMs,
|
|
{ min: 1 },
|
|
)
|
|
}
|
|
requireInteger(
|
|
issues,
|
|
'platforms.cloudtentacles.bindUrlTtlSeconds',
|
|
config.platforms?.cloudtentacles?.bindUrlTtlSeconds,
|
|
{ min: 1 },
|
|
)
|
|
requireInteger(
|
|
issues,
|
|
'platforms.cloudtentacles.bindUrlProbeIntervalSeconds',
|
|
config.platforms?.cloudtentacles?.bindUrlProbeIntervalSeconds,
|
|
{ min: 1 },
|
|
)
|
|
requireInteger(
|
|
issues,
|
|
'platforms.cloudtentacles.bindUrlProbeTimeoutMs',
|
|
config.platforms?.cloudtentacles?.bindUrlProbeTimeoutMs,
|
|
{ min: 1 },
|
|
)
|
|
|
|
validateOptionalHttpUrl(issues, 'orders.claimBaseUrl', config.orders?.claimBaseUrl)
|
|
validateOptionalStorageEndpoint(issues, 'storage.endpoint', config.storage?.endpoint)
|
|
validateRequiredString(issues, 'data.root', config.data?.root)
|
|
|
|
if (productionLike) {
|
|
validateRequiredString(issues, 'database.url', config.database?.url)
|
|
validateRequiredString(issues, 'storage.endpoint', config.storage?.endpoint)
|
|
validateOptionalStorageEndpoint(issues, 'storage.endpoint', config.storage?.endpoint)
|
|
validateRequiredString(issues, 'storage.bucket', config.storage?.bucket)
|
|
validateRequiredString(issues, 'storage.accessKeyId', config.storage?.accessKeyId)
|
|
validateSecret(issues, 'storage.secretAccessKey', config.storage?.secretAccessKey, {
|
|
minLength: 8,
|
|
})
|
|
validateRequiredHttpUrl(issues, 'orders.claimBaseUrl', config.orders?.claimBaseUrl)
|
|
validateSecret(issues, 'admin.sessionSecret', config.admin?.sessionSecret, { minLength: 32 })
|
|
validateAdminDefaultUsers(issues, config.admin?.defaultUsers)
|
|
}
|
|
|
|
return issues
|
|
}
|
|
|
|
export function isProductionLike(env: RuntimeEnvironment = process.env): boolean {
|
|
return env.NODE_ENV === 'production'
|
|
}
|
|
|
|
function validateAdminDefaultUsers(
|
|
issues: RuntimeConfigValidationIssue[],
|
|
users: AdminDefaultUser[] | undefined,
|
|
): void {
|
|
if (!Array.isArray(users) || users.length === 0) {
|
|
issues.push({
|
|
path: 'admin.defaultUsers',
|
|
message: '生产环境必须配置至少一个后台默认用户',
|
|
})
|
|
return
|
|
}
|
|
|
|
users.forEach((user, index) => {
|
|
validateRequiredString(issues, `admin.defaultUsers[${index}].username`, user?.username)
|
|
validateSecret(issues, `admin.defaultUsers[${index}].password`, user?.password, {
|
|
minLength: 8,
|
|
})
|
|
validateRequiredString(issues, `admin.defaultUsers[${index}].role`, user?.role)
|
|
})
|
|
}
|
|
|
|
function validateRequiredString(
|
|
issues: RuntimeConfigValidationIssue[],
|
|
configPath: string,
|
|
value: unknown,
|
|
): void {
|
|
if (!String(value || '').trim()) {
|
|
issues.push({
|
|
path: configPath,
|
|
message: '必须配置非空字符串',
|
|
})
|
|
}
|
|
}
|
|
|
|
function validateSecret(
|
|
issues: RuntimeConfigValidationIssue[],
|
|
configPath: string,
|
|
value: unknown,
|
|
{ minLength }: { minLength: number },
|
|
): void {
|
|
const text = String(value || '').trim()
|
|
if (!text) {
|
|
issues.push({
|
|
path: configPath,
|
|
message: '必须配置密钥',
|
|
})
|
|
return
|
|
}
|
|
|
|
if (text.length < minLength) {
|
|
issues.push({
|
|
path: configPath,
|
|
message: `长度不能少于 ${minLength} 个字符`,
|
|
})
|
|
}
|
|
|
|
if (/change-me|example|password|secret/i.test(text)) {
|
|
issues.push({
|
|
path: configPath,
|
|
message: '不能使用示例占位值',
|
|
})
|
|
}
|
|
}
|
|
|
|
function validateOptionalHttpUrl(
|
|
issues: RuntimeConfigValidationIssue[],
|
|
configPath: string,
|
|
value: unknown,
|
|
): void {
|
|
const text = String(value || '').trim()
|
|
if (!text) {
|
|
return
|
|
}
|
|
|
|
if (!isHttpUrl(text)) {
|
|
issues.push({
|
|
path: configPath,
|
|
message: '必须是 http 或 https URL',
|
|
})
|
|
}
|
|
}
|
|
|
|
function validateRequiredHttpUrl(
|
|
issues: RuntimeConfigValidationIssue[],
|
|
configPath: string,
|
|
value: unknown,
|
|
): void {
|
|
validateRequiredString(issues, configPath, value)
|
|
validateOptionalHttpUrl(issues, configPath, value)
|
|
}
|
|
|
|
function validateOptionalStorageEndpoint(
|
|
issues: RuntimeConfigValidationIssue[],
|
|
configPath: string,
|
|
value: unknown,
|
|
): void {
|
|
const text = String(value || '').trim()
|
|
if (!text) {
|
|
return
|
|
}
|
|
|
|
if (/^https?:\/\//i.test(text)) {
|
|
validateOptionalHttpUrl(issues, configPath, text)
|
|
return
|
|
}
|
|
|
|
if (!/^[a-z0-9.-]+(?::\d{1,5})?$/i.test(text)) {
|
|
issues.push({
|
|
path: configPath,
|
|
message: '必须是 http/https URL 或 host:port',
|
|
})
|
|
}
|
|
}
|
|
|
|
function requireInteger(
|
|
issues: RuntimeConfigValidationIssue[],
|
|
configPath: string,
|
|
value: unknown,
|
|
{ min, max }: { min?: number; max?: number } = {},
|
|
): void {
|
|
const numberValue = Number(value)
|
|
if (!Number.isInteger(numberValue)) {
|
|
issues.push({
|
|
path: configPath,
|
|
message: '必须是整数',
|
|
})
|
|
return
|
|
}
|
|
|
|
if (typeof min === 'number' && numberValue < min) {
|
|
issues.push({
|
|
path: configPath,
|
|
message: `不能小于 ${min}`,
|
|
})
|
|
}
|
|
|
|
if (typeof max === 'number' && numberValue > max) {
|
|
issues.push({
|
|
path: configPath,
|
|
message: `不能大于 ${max}`,
|
|
})
|
|
}
|
|
}
|
|
|
|
function requireOptionalInteger(
|
|
issues: RuntimeConfigValidationIssue[],
|
|
configPath: string,
|
|
value: unknown,
|
|
options: { min?: number; max?: number } = {},
|
|
): void {
|
|
if (value === undefined || value === null || value === '') {
|
|
return
|
|
}
|
|
requireInteger(issues, configPath, value, options)
|
|
}
|
|
|
|
function isValidTrustProxy(value: unknown): boolean {
|
|
if (typeof value === 'boolean') return true
|
|
if (typeof value === 'number') return Number.isInteger(value) && value >= 0
|
|
return ['loopback', 'uniquelocal', 'linklocal'].includes(String(value))
|
|
}
|
|
|
|
function isHttpUrl(value: string): boolean {
|
|
try {
|
|
const url = new URL(value)
|
|
return url.protocol === 'http:' || url.protocol === 'https:'
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
function formatRuntimeConfigValidationMessage(issues: RuntimeConfigValidationIssue[]): string {
|
|
const lines = issues.map((issue) => `- ${issue.path}: ${issue.message}`)
|
|
return `运行时配置校验失败:\n${lines.join('\n')}`
|
|
}
|