2026-08-28 22:24:32 +08:00

179 lines
7.3 KiB
TypeScript

export type ProjectConfig = {
code: string
name: string
secret: string
allowedHosts: string[]
recipientAliases: Map<string, string>
login?: ProjectLoginConfig
}
export type ProjectLoginConfig = {
enabled: true
callbackUrl: string
allowedRedirectPathPrefixes: string[]
}
const clean = (value: unknown) => String(value || '').trim()
export const normalizeRecipientAlias = (value: unknown) => clean(value).normalize('NFKC').toLowerCase()
const parseRecipientAliases = (value: unknown, projectCode: string) => {
if (value === undefined || value === null) return new Map<string, string>()
if (!Array.isArray(value)) throw new Error(`项目 ${projectCode} 的 recipients 必须是数组`)
const aliases = new Map<string, string>()
for (const row of value) {
if (!row || typeof row !== 'object' || Array.isArray(row)) {
throw new Error(`项目 ${projectCode} 的 recipients 包含无效成员`)
}
const item = row as Record<string, unknown>
const alias = clean(item.alias)
const userId = clean(item.userId)
if (!alias || alias.length > 50 || /[\u0000-\u001f\u007f]/.test(alias)) {
throw new Error(`项目 ${projectCode} 包含无效收件人花名`)
}
if (!userId || userId.length > 128 || /[\u0000-\u001f\u007f]/.test(userId)) {
throw new Error(`项目 ${projectCode} 的花名 ${alias} 缺少有效钉钉 userId`)
}
const key = normalizeRecipientAlias(alias)
if (aliases.has(key)) throw new Error(`项目 ${projectCode} 的收件人花名重复: ${alias}`)
aliases.set(key, userId)
}
return aliases
}
const normalizeAllowedRedirectPrefix = (value: unknown, projectCode: string) => {
const prefix = clean(value)
if (!prefix.startsWith('/') || prefix.startsWith('//') || prefix.includes('\\') || /[\u0000-\u001f\u007f]/.test(prefix)) {
throw new Error(`项目 ${projectCode} 的登录回跳路径前缀不合法: ${prefix || '(空)'}`)
}
let parsed: URL
try {
parsed = new URL(prefix, 'https://internal.invalid')
} catch {
throw new Error(`项目 ${projectCode} 的登录回跳路径前缀不合法: ${prefix}`)
}
if (parsed.origin !== 'https://internal.invalid' || parsed.search || parsed.hash) {
throw new Error(`项目 ${projectCode} 的登录回跳路径前缀只能包含站内路径: ${prefix}`)
}
return parsed.pathname.length > 1 ? parsed.pathname.replace(/\/+$/, '') : '/'
}
const parseProjectLogin = (
value: unknown,
projectCode: string,
allowedHosts: string[],
): ProjectLoginConfig | undefined => {
if (value === undefined || value === null) return undefined
if (typeof value !== 'object' || Array.isArray(value)) {
throw new Error(`项目 ${projectCode} 的 login 必须是对象`)
}
const row = value as Record<string, unknown>
if (row.enabled === false) return undefined
if (row.enabled !== true) throw new Error(`项目 ${projectCode} 的 login.enabled 必须显式设为 true 或 false`)
const callbackUrl = clean(row.callbackUrl)
let callback: URL
try {
callback = new URL(callbackUrl)
} catch {
throw new Error(`项目 ${projectCode} 的 login.callbackUrl 不是有效 URL`)
}
if (
callback.protocol !== 'https:' ||
callback.port ||
callback.username ||
callback.password ||
callback.search ||
callback.hash ||
!allowedHosts.includes(callback.hostname.toLowerCase())
) {
throw new Error(`项目 ${projectCode} 的 login.callbackUrl 必须是 allowedHosts 内使用标准 443 端口的完整 HTTPS 地址且不能包含凭据、查询或片段`)
}
const prefixes = Array.isArray(row.allowedRedirectPathPrefixes)
? row.allowedRedirectPathPrefixes.map((item) => normalizeAllowedRedirectPrefix(item, projectCode))
: []
const allowedRedirectPathPrefixes = [...new Set(prefixes)]
if (allowedRedirectPathPrefixes.length === 0) {
throw new Error(`项目 ${projectCode} 至少需要一个 login.allowedRedirectPathPrefixes`)
}
return { enabled: true, callbackUrl: callback.toString(), allowedRedirectPathPrefixes }
}
export const loadProjectConfigs = (raw = process.env.YYD_NOTIFICATION_PROJECTS_JSON || '') => {
if (!raw.trim()) return new Map<string, ProjectConfig>()
let parsed: unknown
try {
parsed = JSON.parse(raw)
} catch {
throw new Error('YYD_NOTIFICATION_PROJECTS_JSON 不是有效 JSON')
}
if (!Array.isArray(parsed)) {
throw new Error('YYD_NOTIFICATION_PROJECTS_JSON 必须是数组')
}
const projects = new Map<string, ProjectConfig>()
for (const row of parsed) {
const item = row as Record<string, unknown>
const code = clean(item.code)
const name = clean(item.name)
const secret = clean(item.secret)
const allowedHosts = Array.isArray(item.allowedHosts)
? item.allowedHosts.map(clean).filter(Boolean).map((host) => host.toLowerCase())
: []
if (!/^[a-z0-9_-]{2,40}$/.test(code)) throw new Error(`项目 code 不合法: ${code || '(空)'}`)
if (!name) throw new Error(`项目 ${code} 缺少 name`)
if (secret.length < 24) throw new Error(`项目 ${code} 的 secret 至少需要 24 个字符`)
if (allowedHosts.length === 0) throw new Error(`项目 ${code} 至少需要一个 allowedHosts`)
for (const host of allowedHosts) {
let parsedHost: URL
try {
parsedHost = new URL(`https://${host}`)
} catch {
throw new Error(`项目 ${code} 的 allowedHosts 包含无效主机名: ${host}`)
}
if (parsedHost.hostname.toLowerCase() !== host || parsedHost.port || parsedHost.pathname !== '/') {
throw new Error(`项目 ${code} 的 allowedHosts 只能填写不含协议、端口和路径的主机名: ${host}`)
}
}
if (projects.has(code)) throw new Error(`项目 code 重复: ${code}`)
const recipientAliases = parseRecipientAliases(item.recipients, code)
const login = parseProjectLogin(item.login, code, allowedHosts)
projects.set(code, { code, name, secret, allowedHosts, recipientAliases, login })
}
return projects
}
export const getDingTalkConfig = () => ({
appKey: clean(process.env.DINGTALK_APP_KEY),
appSecret: clean(process.env.DINGTALK_APP_SECRET),
robotCode: clean(process.env.DINGTALK_ROBOT_CODE),
corpId: clean(process.env.DINGTALK_CORP_ID),
})
export const getPublicBaseUrl = () => {
const raw = clean(process.env.YYD_NOTIFICATION_PUBLIC_BASE_URL)
if (!raw) return ''
let url: URL
try {
url = new URL(raw)
} catch {
throw new Error('YYD_NOTIFICATION_PUBLIC_BASE_URL 不是有效 URL')
}
if (url.protocol !== 'https:' || url.username || url.password || url.search || url.hash) {
throw new Error('YYD_NOTIFICATION_PUBLIC_BASE_URL 必须是 HTTPS 地址且不能包含凭据、查询或片段')
}
return url.toString().replace(/\/$/, '')
}
export const assertRuntimeConfig = (projects: Map<string, ProjectConfig>) => {
if (projects.size === 0) throw new Error('未配置任何通知项目')
const dingTalk = getDingTalkConfig()
const missing = (['appKey', 'appSecret', 'robotCode'] as const).filter((key) => !dingTalk[key])
if (missing.length > 0) throw new Error(`钉钉配置缺失: ${missing.join(', ')}`)
if ([...projects.values()].some((project) => project.login)) {
if (!dingTalk.corpId) throw new Error('启用钉钉免登中转时必须配置 DINGTALK_CORP_ID')
if (!getPublicBaseUrl()) throw new Error('启用钉钉免登中转时必须配置 YYD_NOTIFICATION_PUBLIC_BASE_URL')
}
}