Files
TallyNote/server/config.ts
T
Qiufeng 4f9629b089
TallyNote release / linux-x64 (push) Successful in 6m56s
fix: allow reverse proxy login
2026-09-03 15:27:10 +08:00

272 lines
12 KiB
TypeScript

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<typeof loadConfig>;
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,
// 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.
}
};
}