import { chmodSync, closeSync, existsSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, realpathSync, statSync, unlinkSync, writeSync } from "node:fs"; import path from "node:path"; function integerEnv(name: string, fallback: number, minimum = 1): number { const raw = process.env[name]; if (!raw) return fallback; const value = Number(raw); if (!Number.isInteger(value) || value < minimum) throw new Error(`${name} 必须是大于等于 ${minimum} 的整数`); return value; } function nonNegativeIntegerEnv(name: string, fallback: number): number { const raw = process.env[name]; if (!raw) return fallback; const value = Number(raw); if (!Number.isInteger(value) || value < 0) throw new Error(`${name} 必须是大于等于 0 的整数`); return value; } function booleanEnv(name: string, fallback: boolean): boolean { const raw = process.env[name]; if (raw === undefined) return fallback; if (raw === "true") return true; if (raw === "false") return false; throw new Error(`${name} 必须是 true 或 false`); } function trustProxyEnv(): boolean | number { const raw = process.env.TALLYNOTE_TRUST_PROXY; if (raw === undefined || raw === "false") return false; if (raw === "true") return true; if (/^[0-9]+$/.test(raw)) { const hops = Number(raw); if (Number.isSafeInteger(hops) && hops >= 0 && hops <= 10) return hops; } throw new Error("TALLYNOTE_TRUST_PROXY 必须是 false、true 或 0-10 的代理跳数"); } function csvEnv(name: string): string[] { return (process.env[name] ?? "") .split(",") .map((item) => item.trim()) .filter(Boolean); } function updatePublicKeyEnv(): string | undefined { const inline = process.env.TALLYNOTE_UPDATE_PUBLIC_KEY?.trim(); const file = process.env.TALLYNOTE_UPDATE_PUBLIC_KEY_FILE?.trim(); if (inline && file) throw new Error("TALLYNOTE_UPDATE_PUBLIC_KEY 与 TALLYNOTE_UPDATE_PUBLIC_KEY_FILE 只能配置一个"); if (file) { try { const info = lstatSync(file); if (!info.isFile() || info.isSymbolicLink() || info.size > 16 * 1024 || (info.mode & 0o022) !== 0) throw new Error("更新公钥文件无效"); return readFileSync(file, "utf8").trim(); } catch (error) { if (error instanceof Error && error.message === "更新公钥文件无效") throw error; throw new Error("更新公钥文件不可读取"); } } return inline || undefined; } export type AppConfig = ReturnType; export function loadConfig() { const projectRoot = path.resolve(process.cwd()); const dataDir = path.resolve(process.env.TALLYNOTE_DATA_DIR ?? path.join(projectRoot, "data")); const updateStrategyRaw = process.env.TALLYNOTE_UPDATE_STRATEGY?.trim().toLowerCase() || "disabled"; const installPrefix = path.resolve(process.env.TALLYNOTE_INSTALL_PREFIX ?? (updateStrategyRaw === "systemd" ? path.dirname(projectRoot) : projectRoot)); const host = process.env.TALLYNOTE_HOST ?? "127.0.0.1"; const port = integerEnv("TALLYNOTE_PORT", 3000, 1); const originHost = host.includes(":") && !host.startsWith("[") ? `[${host}]` : host; const publicOrigin = process.env.TALLYNOTE_PUBLIC_ORIGIN ?? `http://${originHost}:${port}`; let parsedOrigin: URL; try { parsedOrigin = new URL(publicOrigin); } catch { throw new Error("TALLYNOTE_PUBLIC_ORIGIN 必须是有效的 HTTP(S) 地址"); } if (!["http:", "https:"].includes(parsedOrigin.protocol) || parsedOrigin.username || parsedOrigin.password || parsedOrigin.search || parsedOrigin.hash || (parsedOrigin.pathname !== "/" && parsedOrigin.pathname !== "")) { throw new Error("TALLYNOTE_PUBLIC_ORIGIN 必须是没有路径或凭据的 HTTP(S) 地址"); } const timezone = process.env.TALLYNOTE_TIMEZONE ?? "Asia/Shanghai"; try { new Intl.DateTimeFormat("zh-CN", { timeZone: timezone }).format(); } catch { throw new Error(`无效时区:${timezone}`); } const isProduction = process.env.NODE_ENV === "production" || process.env.TALLYNOTE_ENV === "production"; const cookieSecure = booleanEnv("TALLYNOTE_COOKIE_SECURE", parsedOrigin.protocol === "https:"); // Direct IP access is useful during a first deployment, but it is not // encrypted. Keep this explicitly opt-in so a public install cannot // accidentally expose session cookies over HTTP. const allowInsecureHttp = booleanEnv("TALLYNOTE_ALLOW_INSECURE_HTTP", false); const publicHost = parsedOrigin.hostname.replace(/^\[|\]$/g, "").toLowerCase(); if (["0.0.0.0", "::"].includes(publicHost)) { throw new Error("TALLYNOTE_PUBLIC_ORIGIN 不能使用通配监听地址,请填写服务器 IP 或域名"); } const localOrigin = ["127.0.0.1", "localhost", "::1"].includes(publicHost); const appVersion = (() => { try { const packageJson = JSON.parse(readFileSync(path.join(projectRoot, "package.json"), "utf8")) as { version?: unknown }; return typeof packageJson.version === "string" && /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(packageJson.version) ? packageJson.version : "0.0.0"; } catch { return "0.0.0"; } })(); // The default points at the project's public Gitea repository. Operators // can override it for a fork or an internal release feed. const updateMetadataUrl = process.env.TALLYNOTE_UPDATE_METADATA_URL?.trim() || "https://git.awaioi.com/api/v1/repos/awaioi/TallyNote/releases/latest"; const updateAllowedHosts = csvEnv("TALLYNOTE_UPDATE_ALLOWED_HOSTS"); const updatePublicKey = updatePublicKeyEnv(); // Public releases always require HTTPS, host allowlisting, and SHA-256. // Detached signatures remain an opt-in hardening layer so a self-hosted // public repository can use one-click updates without provisioning a key. const updateRequireSignature = booleanEnv("TALLYNOTE_UPDATE_REQUIRE_SIGNATURE", false); if (!(updateStrategyRaw === "disabled" || updateStrategyRaw === "systemd")) { throw new Error("TALLYNOTE_UPDATE_STRATEGY 必须是 disabled 或 systemd"); } if (updateStrategyRaw === "systemd" && updateAllowedHosts.length === 0) { throw new Error("systemd 一键更新必须配置 TALLYNOTE_UPDATE_ALLOWED_HOSTS"); } const config = { projectRoot, host, port, publicOrigin: parsedOrigin.origin, timezone, trustProxy: trustProxyEnv(), cookieSecure, allowInsecureHttp, appVersion, updateMetadataUrl, updateAllowedHosts, updatePublicKey, updateRequireSignature, updateStrategy: updateStrategyRaw as "disabled" | "systemd", updateHelperPath: process.env.TALLYNOTE_UPDATE_HELPER_PATH?.trim() || path.join(projectRoot, "dist", "server", "cli", "update.js"), updateRequestPath: path.join(dataDir, "update-request.json"), installPrefix, currentLink: path.join(installPrefix, "current"), releasesDir: path.join(installPrefix, "releases"), // The privileged updater must never create its root-owned workspace below // the application-owned data tree. The installer provisions this directory // as 0700 root:root; development/test callers may override --staging-dir. updateWorkspaceDir: path.join(installPrefix, ".update-work"), updateMaxBytes: integerEnv("TALLYNOTE_UPDATE_MAX_MB", 512) * 1024 * 1024, updateTimeoutMs: integerEnv("TALLYNOTE_UPDATE_TIMEOUT_SECONDS", 30) * 1000, // Update checks hit an external release endpoint. Keep a short local // cooldown so an authenticated account cannot turn the endpoint into an // outbound request flood; set to 0 only for controlled test environments. updateCheckCooldownMs: nonNegativeIntegerEnv("TALLYNOTE_UPDATE_CHECK_COOLDOWN_SECONDS", 60) * 1000, updateDownloadCooldownMs: nonNegativeIntegerEnv("TALLYNOTE_UPDATE_DOWNLOAD_COOLDOWN_SECONDS", 15) * 1000, updateApplyCooldownMs: nonNegativeIntegerEnv("TALLYNOTE_UPDATE_APPLY_COOLDOWN_SECONDS", 15) * 1000, isLocalOrigin: localOrigin, dataDir, dbPath: path.join(dataDir, "tallynote.db"), filesDir: path.join(dataDir, "files"), stagingDir: path.join(dataDir, "staging"), exportsDir: path.join(dataDir, "exports"), migrationsDir: path.join(projectRoot, "migrations"), webDir: path.join(projectRoot, "dist", "web"), maxFileBytes: integerEnv("TALLYNOTE_MAX_FILE_MB", 20) * 1024 * 1024, maxFilesPerRequest: integerEnv("TALLYNOTE_MAX_FILES_PER_REQUEST", 20), maxRecordBytes: integerEnv("TALLYNOTE_MAX_RECORD_MB", 100) * 1024 * 1024, maxTotalBytes: integerEnv("TALLYNOTE_MAX_TOTAL_MB", 2048) * 1024 * 1024, maxConcurrentExports: integerEnv("TALLYNOTE_MAX_CONCURRENT_EXPORTS", 2), maxExportRecords: integerEnv("TALLYNOTE_MAX_EXPORT_RECORDS", 5000), maxExportBytes: integerEnv("TALLYNOTE_MAX_EXPORT_MB", 1024) * 1024 * 1024, maxExportStorageBytes: integerEnv("TALLYNOTE_MAX_EXPORT_STORAGE_MB", 2048) * 1024 * 1024, sessionIdleMs: integerEnv("TALLYNOTE_SESSION_IDLE_HOURS", 24) * 60 * 60 * 1000, sessionAbsoluteMs: integerEnv("TALLYNOTE_SESSION_ABSOLUTE_HOURS", 168) * 60 * 60 * 1000, exportTtlMs: integerEnv("TALLYNOTE_EXPORT_TTL_MINUTES", 15) * 60 * 1000, isProduction, }; if (!localOrigin && parsedOrigin.protocol !== "https:" && !allowInsecureHttp) { throw new Error("公网 HTTP 访问必须显式启用 TALLYNOTE_ALLOW_INSECURE_HTTP=true;生产环境建议使用 HTTPS"); } if (!localOrigin && parsedOrigin.protocol !== "https:" && cookieSecure) { throw new Error("HTTP public origin 不能启用安全 Cookie"); } if (!localOrigin && parsedOrigin.protocol === "https:" && !cookieSecure) { throw new Error("公网部署必须使用 HTTPS 并启用安全 Cookie"); } if (parsedOrigin.protocol === "https:" && !cookieSecure) { throw new Error("HTTPS public origin 不能关闭安全 Cookie"); } if (config.isProduction && config.trustProxy === true) { throw new Error("生产环境不能使用 TALLYNOTE_TRUST_PROXY=true,请填写明确的代理跳数(例如 1)"); } return config; } function secureDirectory(directory: string): void { const info = lstatSync(directory); if (!info.isDirectory() || info.isSymbolicLink()) throw new Error(`数据目录不能是符号链接:${directory}`); chmodSync(directory, 0o700); } function secureFile(filePath: string): void { if (!existsSync(filePath)) return; const info = lstatSync(filePath); if (!info.isFile() || info.isSymbolicLink()) throw new Error(`数据文件不能是符号链接:${filePath}`); chmodSync(filePath, 0o600); } export function prepareDataDirectories(config: AppConfig): void { mkdirSync(config.dataDir, { recursive: true, mode: 0o700 }); secureDirectory(config.dataDir); for (const directory of [config.filesDir, config.stagingDir, config.exportsDir]) { mkdirSync(directory, { recursive: true, mode: 0o700 }); secureDirectory(directory); } for (const filePath of [config.dbPath, `${config.dbPath}-wal`, `${config.dbPath}-shm`, config.updateRequestPath]) secureFile(filePath); const rootDevice = statSync(realpathSync(config.dataDir)).dev; for (const directory of [config.filesDir, config.stagingDir, config.exportsDir]) { if (statSync(realpathSync(directory)).dev !== rootDevice) { throw new Error("数据库、附件、暂存区和导出目录必须位于同一文件系统"); } } } export function acquireInstanceLock(config: AppConfig): () => void { const lockPath = path.join(config.dataDir, ".instance.lock"); const owner = JSON.stringify({ pid: process.pid, createdAt: Date.now() }); let fd: number; try { fd = openSync(lockPath, "wx", 0o600); writeSync(fd, owner); fsyncSync(fd); closeSync(fd); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; let ownerPid: number | undefined; try { ownerPid = (JSON.parse(readFileSync(lockPath, "utf8")) as { pid?: number }).pid; } catch { throw new Error("检测到另一个 TallyNote 进程正在初始化数据目录"); } if (ownerPid && ownerPid !== process.pid) { try { process.kill(ownerPid, 0); throw new Error("检测到另一个 TallyNote 进程正在使用该数据目录"); } catch (probeError) { if ((probeError as NodeJS.ErrnoException).code !== "ESRCH") throw probeError; } } try { unlinkSync(lockPath); } catch (unlinkError) { throw new Error(`无法接管数据目录锁:${String(unlinkError)}`); } fd = openSync(lockPath, "wx", 0o600); writeSync(fd, owner); fsyncSync(fd); closeSync(fd); } let released = false; return () => { if (released) return; released = true; try { const current = JSON.parse(readFileSync(lockPath, "utf8")) as { pid?: number }; if (current.pid === process.pid) unlinkSync(lockPath); } catch { // A stale lock is recovered on next startup. } }; }