253 lines
11 KiB
TypeScript
253 lines
11 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 publicOrigin = process.env.TALLYNOTE_PUBLIC_ORIGIN ?? `http://${host}:${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:");
|
|
const publicHost = parsedOrigin.hostname.replace(/^\[|\]$/g, "").toLowerCase();
|
|
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();
|
|
const updateRequireSignature = booleanEnv("TALLYNOTE_UPDATE_REQUIRE_SIGNATURE", updateStrategyRaw === "systemd");
|
|
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,
|
|
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,
|
|
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:" || !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.
|
|
}
|
|
};
|
|
}
|