121 lines
5.9 KiB
JavaScript
121 lines
5.9 KiB
JavaScript
import { lstatSync, readFileSync, readdirSync, realpathSync } from 'node:fs'
|
|
import path from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
|
|
export const sourceRoot = fileURLToPath(new URL('../', import.meta.url))
|
|
export const runtimeRoot = path.join(sourceRoot, 'yunx-test')
|
|
|
|
export function assertDeploymentLayout(root = runtimeRoot) {
|
|
const allowed = new Set(['config', 'data', 'ssl', 'log', 'smail', 'smail-admin', 'smail.exe', 'smail-admin.exe'])
|
|
for (const item of readdirSync(root, { withFileTypes: true })) {
|
|
if (!allowed.has(item.name) || item.isSymbolicLink()) throw new Error('部署根目录包含额外项目或链接')
|
|
}
|
|
const names = ['config.json', 'smtp.json', 'email.json', 'ssl.json', 'plugins.json']
|
|
if (JSON.stringify(readdirSync(path.join(root, 'config')).sort()) !== JSON.stringify(names.sort())) {
|
|
throw new Error('config 只能包含指定的五个配置文件')
|
|
}
|
|
const config = JSON.parse(readFileSync(resolveRuntimeFile(root, 'config/config.json'), 'utf8'))
|
|
for (const name of ['smtp', 'email', 'ssl', 'plugins']) {
|
|
resolveRuntimeFile(root, `config/${name}.json`)
|
|
}
|
|
if (config.setupState === 'ready') {
|
|
for (const [folder, name] of [
|
|
['credentials', 'credential.key'],
|
|
['external-email-encryption', 'external-email-encryption.key'],
|
|
['external-email-fingerprint', 'external-email-fingerprint.key']
|
|
]) {
|
|
const grouped = `ssl/${folder}/${name}`
|
|
const legacy = `ssl/${name}`
|
|
// 启动前兼容有效旧位置,迁移由新后端完成,检查器不生成或移动密钥。
|
|
const locations = [grouped, legacy, `data/${name}`, `config/${name}`]
|
|
.filter(reference => lstatSync(path.join(root, reference), { throwIfNoEntry: false }))
|
|
if (locations.length !== 1 || ![grouped, legacy].includes(locations[0])) {
|
|
throw new Error('业务密钥缺失或新旧位置冲突,拒绝自动选择')
|
|
}
|
|
const target = resolveRuntimeFile(root, locations[0])
|
|
if (lstatSync(target).size !== 32) throw new Error('业务密钥长度无效')
|
|
}
|
|
if (config.database?.type === 'sqlite') resolveRuntimeFile(root, `data/${config.database.localRef}`)
|
|
}
|
|
const ssl = JSON.parse(readFileSync(resolveRuntimeFile(root, 'config/ssl.json'), 'utf8')).tls
|
|
try {
|
|
resolveDevelopmentCertificate(root, ssl?.certificateRef)
|
|
resolveDevelopmentCertificate(root, ssl?.privateKeyRef, true)
|
|
} catch (error) {
|
|
// 启动预检与热加载使用相同边界;当前材料不可读时允许后端验证上一套。
|
|
try {
|
|
resolveDevelopmentCertificate(root, ssl?.previousCertificateRef)
|
|
resolveDevelopmentCertificate(root, ssl?.previousPrivateKeyRef, true)
|
|
} catch {
|
|
throw error
|
|
}
|
|
}
|
|
return config
|
|
}
|
|
|
|
export function resolveRuntimeFile(root, reference) {
|
|
if (typeof reference !== 'string' || !reference || path.isAbsolute(reference)) {
|
|
throw new Error('运行文件必须使用部署目录内的相对引用')
|
|
}
|
|
const resolvedRoot = realpathSync(root)
|
|
const absolute = path.resolve(root, reference)
|
|
const lexical = path.relative(path.resolve(root), absolute)
|
|
if (!lexical || lexical === '..' || lexical.startsWith(`..${path.sep}`) || path.isAbsolute(lexical)) {
|
|
throw new Error('运行文件不能越过部署目录')
|
|
}
|
|
let current = path.resolve(root)
|
|
for (const component of lexical.split(path.sep)) {
|
|
current = path.join(current, component)
|
|
if (lstatSync(current).isSymbolicLink()) throw new Error('运行文件路径不能包含链接')
|
|
}
|
|
const info = lstatSync(absolute)
|
|
if (!info.isFile() || info.isSymbolicLink()) throw new Error('运行文件必须是普通文件')
|
|
const target = realpathSync(absolute)
|
|
const relative = path.relative(resolvedRoot, target)
|
|
if (!relative || relative.startsWith(`..${path.sep}`) || relative === '..' || path.isAbsolute(relative)) {
|
|
throw new Error('运行文件不能越过部署目录')
|
|
}
|
|
return target
|
|
}
|
|
|
|
// 仅供 Node/Vite 服务端读取,不能把包含凭据的配置对象暴露给浏览器。
|
|
export function readDevelopmentRuntime(root = runtimeRoot, { previous = false } = {}) {
|
|
const read = name => JSON.parse(readFileSync(resolveRuntimeFile(root, `config/${name}.json`), 'utf8'))
|
|
const config = read('config')
|
|
const ssl = read('ssl')
|
|
if (ssl.meta?.generation !== config.generation) {
|
|
throw new Error('配置事务尚未完成,暂不切换开发证书')
|
|
}
|
|
const webDomain = config.domains?.web
|
|
if (typeof webDomain !== 'string' || !/^[a-z0-9.-]+(?::\d+)?$/i.test(webDomain)) {
|
|
throw new Error('请先在 yunx-test/config/config.json 配置有效的 Web 域名')
|
|
}
|
|
const certificatePath = resolveDevelopmentCertificate(root, previous ? ssl.tls?.previousCertificateRef : ssl.tls?.certificateRef)
|
|
const privateKeyPath = resolveDevelopmentCertificate(root, previous ? ssl.tls?.previousPrivateKeyRef : ssl.tls?.privateKeyRef, true)
|
|
return {
|
|
origin: `https://${webDomain}`,
|
|
certificatePath,
|
|
privateKeyPath,
|
|
generation: config.generation
|
|
}
|
|
}
|
|
|
|
// 证书允许显式绝对路径;配置与数据库仍保持部署根目录边界。
|
|
function resolveDevelopmentCertificate(root, reference, privateKey = false) {
|
|
if (typeof reference !== 'string' || !reference || reference.includes('\0') || reference.split(/[\\/]/).includes('..')) {
|
|
throw new Error('证书路径无效')
|
|
}
|
|
const target = path.isAbsolute(reference) ? path.resolve(reference) : resolveRuntimeFile(root, reference)
|
|
let current = target
|
|
while (true) {
|
|
if (lstatSync(current).isSymbolicLink()) throw new Error('证书路径不能包含链接')
|
|
const parent = path.dirname(current)
|
|
if (parent === current) break
|
|
current = parent
|
|
}
|
|
const info = lstatSync(target)
|
|
if (!info.isFile() || info.nlink !== 1 || info.size <= 0 || info.size > 1024 * 1024) throw new Error('证书必须是安全的普通文件')
|
|
if (privateKey && process.platform !== 'win32' && (info.mode & 0o077)) throw new Error('私钥权限过于宽松')
|
|
return target
|
|
}
|