179 lines
7.3 KiB
TypeScript
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')
|
|
}
|
|
}
|