feat: add TallyNote local reimbursement ledger
TallyNote release / linux-x64 (push) Failing after 2m41s

This commit is contained in:
Qiufeng
2026-08-29 01:02:29 +08:00
commit 9719429f4a
62 changed files with 29200 additions and 0 deletions
+1654
View File
File diff suppressed because it is too large Load Diff
+39
View File
@@ -0,0 +1,39 @@
import type Database from "better-sqlite3";
export type AuditInput = {
requestId: string;
actorAdminId?: string | null;
actorUsername?: string | null;
action: string;
targetType: string;
targetId?: string | null;
outcome?: "success" | "denied" | "failure";
before?: unknown;
after?: unknown;
metadata?: unknown;
};
function json(value: unknown): string | null {
return value === undefined ? null : JSON.stringify(value);
}
export function writeAudit(sqlite: Database.Database, input: AuditInput): void {
sqlite.prepare(`
INSERT INTO audit_events (
occurred_at, request_id, actor_admin_id, actor_username, action,
target_type, target_id, outcome, before_json, after_json, metadata_json
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
Date.now(),
input.requestId,
input.actorAdminId ?? null,
input.actorUsername ?? null,
input.action,
input.targetType,
input.targetId ?? null,
input.outcome ?? "success",
json(input.before),
json(input.after),
json(input.metadata),
);
}
+97
View File
@@ -0,0 +1,97 @@
import { stdin as input, stdout as output } from "node:process";
import { mkdirSync } from "node:fs";
import { randomUUID } from "node:crypto";
import { openDatabase } from "../db/index.js";
import { acquireInstanceLock, loadConfig, prepareDataDirectories } from "../config.js";
import { hashPassword, normalizeUsername, validateNewPassword, temporaryPassword } from "../security.js";
import { writeAudit } from "../audit.js";
function arg(name: string): string | undefined {
const index = process.argv.indexOf(name);
return index >= 0 ? process.argv[index + 1] : undefined;
}
async function readSecret(prompt: string): Promise<string> {
if (!input.isTTY) throw new Error("admin:init 需要交互式 TTY,不能通过管道传入密码");
output.write(prompt);
return await new Promise<string>((resolve, reject) => {
let value = "";
const wasRaw = Boolean(input.isRaw);
const onData = (chunk: Buffer) => {
const text = chunk.toString("utf8");
if (text === "\u0003") {
cleanup();
reject(new Error("已取消"));
} else if (text === "\r" || text === "\n") {
cleanup();
output.write("\n");
resolve(value);
} else if (text === "\u007f") {
value = value.slice(0, -1);
} else if (!text.includes("\u001b")) {
value += text;
}
};
const cleanup = () => {
input.off("data", onData);
input.setRawMode?.(wasRaw);
input.pause();
};
input.resume();
input.setRawMode?.(true);
input.on("data", onData);
});
}
async function main() {
const config = loadConfig();
prepareDataDirectories(config);
mkdirSync(config.dataDir, { recursive: true, mode: 0o700 });
const release = acquireInstanceLock(config);
const database = openDatabase(config);
try {
const existing = database.sqlite.prepare("SELECT COUNT(*) AS count FROM admins").get() as { count: number };
if (existing.count > 0) throw new Error("INITIAL_ADMIN_EXISTS:管理员已经初始化");
const username = arg("--username") ?? (await readSecret("用户名: "));
const displayName = arg("--display-name") ?? (await readSecret("显示名称: "));
const generate = process.argv.includes("--generate");
let password = generate ? temporaryPassword() : await readSecret("密码(至少 12 个字符): ");
if (!generate) {
const confirmation = await readSecret("再次输入密码: ");
if (password !== confirmation) throw new Error("两次密码输入不一致");
}
const policyError = validateNewPassword(password);
if (policyError) throw new Error(policyError);
const normalized = normalizeUsername(username);
if ([...normalized].length < 3) throw new Error("用户名至少需要 3 个字符");
const passwordHash = await hashPassword(password);
const id = randomUUID();
const now = Date.now();
database.sqlite.transaction(() => {
const current = database.sqlite.prepare("SELECT COUNT(*) AS count FROM admins").get() as { count: number };
if (current.count > 0) throw new Error("INITIAL_ADMIN_EXISTS:管理员已经初始化");
database.sqlite.prepare(`
INSERT INTO admins(id, username, username_norm, display_name, password_hash, status,
must_change_password, auth_version, version, created_at)
VALUES (?, ?, ?, ?, ?, 'active', 1, 1, 1, ?)
`).run(id, username.normalize("NFKC").trim(), normalized, displayName.trim(), passwordHash, now);
writeAudit(database.sqlite, {
requestId: `cli:${randomUUID()}`,
actorUsername: "cli",
action: "admin.initialized",
targetType: "admin",
targetId: id,
after: { username: normalized, displayName: displayName.trim(), status: "active" },
});
})();
console.log(generate ? `已创建首位管理员。一次性密码:${password}` : "已创建首位管理员。");
} finally {
database.sqlite.close();
release();
}
}
main().catch((error) => {
console.error(error instanceof Error ? error.message : error);
process.exitCode = 1;
});
+418
View File
@@ -0,0 +1,418 @@
import { randomUUID } from "node:crypto";
import { lstat, mkdir, mkdtemp, readFile, realpath, rm } from "node:fs/promises";
import path from "node:path";
import { pathToFileURL } from "node:url";
import type Database from "better-sqlite3";
import { z } from "zod";
import { acquireInstanceLock, loadConfig, prepareDataDirectories, type AppConfig } from "../config.js";
import { openDatabase } from "../db/index.js";
import { writeAudit } from "../audit.js";
import {
atomicSwitchDirectory,
atomicSwitchRelease,
compareSemver,
createSafeArchive,
detectPlatform,
downloadReleaseAsset,
extractSafeArchive,
fetchReleaseMetadata,
isNewerVersion,
normalizeReleasePermissions,
parseSemver,
selectReleaseAsset,
sanitizeAssetName,
validateHttpsUrl,
type ReleaseAsset,
type ReleaseMetadata,
type UrlPolicy,
} from "../update.js";
import { attachSidecarHash } from "../update-service.js";
import type { UpdateJobStatus } from "../../shared/contracts.js";
const updateRequestFileSchema = z.object({
jobId: z.string().uuid(),
version: z.string().regex(/^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/),
metadataUrl: z.string().url(),
assetUrl: z.string().url(),
assetName: z.string().min(1).max(200),
expectedSha256: z.string().regex(/^[a-f0-9]{64}$/i),
requestedAt: z.number().int().positive(),
currentLink: z.string().min(1),
releasesDir: z.string().min(1),
dataDir: z.string().min(1),
}).strict();
export type UpdateRequestFile = z.infer<typeof updateRequestFileSchema>;
/** Validate the hand-off from the unprivileged web process. URL and path
* fields are treated as untrusted data even though the file is local: the
* privileged runner must bind them to its own configuration before using it.
*/
export function validateUpdateRequest(requestValue: unknown, config: AppConfig): UpdateRequestFile {
const request = updateRequestFileSchema.parse(requestValue);
if (path.resolve(request.dataDir) !== path.resolve(config.dataDir)
|| path.resolve(request.currentLink) !== path.resolve(config.currentLink)
|| path.resolve(request.releasesDir) !== path.resolve(config.releasesDir)) {
throw new Error("更新请求目录与服务配置不一致");
}
const configuredMetadataUrl = validateHttpsUrl(config.updateMetadataUrl, {
allowedHosts: config.updateAllowedHosts,
baseUrl: config.updateMetadataUrl,
}).toString();
const requestedMetadataUrl = validateHttpsUrl(request.metadataUrl, {
allowedHosts: config.updateAllowedHosts,
baseUrl: config.updateMetadataUrl,
}).toString();
if (requestedMetadataUrl !== configuredMetadataUrl) throw new Error("更新请求源与服务配置不一致");
const requestAge = Date.now() - request.requestedAt;
if (requestAge > 24 * 60 * 60 * 1000 || requestAge < -5 * 60 * 1000) throw new Error("更新请求已过期");
return request;
}
export type UpdateRunOptions = UrlPolicy & {
sqlite?: Database.Database;
metadataUrl?: string | undefined;
assetUrl?: string | undefined;
assetName?: string | undefined;
version?: string | undefined;
expectedSha256?: string | undefined;
currentVersion?: string | undefined;
currentDir: string;
stagingDir: string;
backupArchivePath?: string | undefined;
dataBackupArchivePath?: string | undefined;
dataBackupSource?: string | undefined;
backupDir?: string | undefined;
releasesDir?: string | undefined;
currentLink?: string | undefined;
adminId?: string | undefined;
sessionHash?: string | undefined;
requestId?: string | undefined;
deferCompletion?: boolean | undefined;
maxBytes?: number | undefined;
dataBackupMaxBytes?: number | undefined;
fetchImpl?: typeof fetch;
platform?: ReturnType<typeof detectPlatform> | undefined;
jobId?: string | undefined;
publicKey?: string | undefined;
requireSignature?: boolean | undefined;
};
export type UpdateRunResult = {
jobId: string;
version: string;
asset: ReleaseAsset;
archivePath: string;
backupArchivePath?: string;
backupDir?: string;
};
function safeErrorMessage(error: unknown): string {
if (!(error instanceof Error)) return "更新失败";
const message = error.message;
if (message.length > 200 || /https?:\/\//i.test(message) || /authorization|token|secret|password|cookie|apikey/i.test(message)) return "更新失败";
return message || "更新失败";
}
function normalizedSha256(value: string | undefined): string | undefined {
if (value === undefined) return undefined;
const normalized = value.trim().replace(/^sha256:/i, "").toLowerCase();
if (!/^[a-f0-9]{64}$/.test(normalized)) throw new Error("SHA-256 校验值无效");
return normalized;
}
function writeJob(sqlite: Database.Database | undefined, jobId: string, values: {
status: UpdateJobStatus;
version: string;
platform: string;
releaseUrl?: string | undefined;
assetName?: string | undefined;
assetUrl: string;
expectedSha256?: string | undefined;
actualSha256?: string | undefined;
downloadPath?: string | undefined;
backupPath?: string | undefined;
sizeBytes?: number | undefined;
errorMessage?: string | undefined;
completedAt?: number | undefined;
adminId?: string | undefined;
sessionHash?: string | undefined;
requestId?: string | undefined;
requestedAt?: number | undefined;
startedAt?: number | undefined;
}): void {
if (!sqlite) return;
const now = Date.now();
sqlite.prepare(`
INSERT INTO update_jobs(id, admin_id, session_hash, request_id, requested_at, started_at,
status, version, platform, release_url, asset_name, asset_url,
expected_sha256, actual_sha256, download_path, backup_path, size_bytes, error_message,
created_at, updated_at, completed_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
admin_id=COALESCE(excluded.admin_id, update_jobs.admin_id),
session_hash=COALESCE(excluded.session_hash, update_jobs.session_hash),
request_id=COALESCE(excluded.request_id, update_jobs.request_id),
requested_at=COALESCE(excluded.requested_at, update_jobs.requested_at),
started_at=COALESCE(excluded.started_at, update_jobs.started_at),
status=excluded.status, version=excluded.version, platform=excluded.platform,
release_url=COALESCE(excluded.release_url, update_jobs.release_url),
asset_name=COALESCE(excluded.asset_name, update_jobs.asset_name),
asset_url=excluded.asset_url,
expected_sha256=COALESCE(excluded.expected_sha256, update_jobs.expected_sha256),
actual_sha256=COALESCE(excluded.actual_sha256, update_jobs.actual_sha256),
download_path=COALESCE(excluded.download_path, update_jobs.download_path),
backup_path=COALESCE(excluded.backup_path, update_jobs.backup_path),
size_bytes=COALESCE(excluded.size_bytes, update_jobs.size_bytes),
error_message=COALESCE(excluded.error_message, update_jobs.error_message),
updated_at=excluded.updated_at,
completed_at=COALESCE(excluded.completed_at, update_jobs.completed_at)
`).run(
jobId,
values.adminId ?? null,
values.sessionHash ?? null,
values.requestId ?? null,
values.requestedAt ?? null,
values.startedAt ?? null,
values.status,
values.version,
values.platform,
values.releaseUrl ?? null,
values.assetName ?? null,
values.assetUrl,
values.expectedSha256 ?? null,
values.actualSha256 ?? null,
values.downloadPath ?? null,
values.backupPath ?? null,
values.sizeBytes ?? null,
values.errorMessage ?? null,
now,
now,
values.completedAt ?? null,
);
}
function updateJob(sqlite: Database.Database | undefined, jobId: string, values: Parameters<typeof writeJob>[2]): void {
writeJob(sqlite, jobId, values);
}
function clearTransientJobPath(sqlite: Database.Database | undefined, jobId: string): void {
if (!sqlite) return;
sqlite.prepare("UPDATE update_jobs SET download_path=NULL, updated_at=? WHERE id=?").run(Date.now(), jobId);
}
async function resolveRelease(options: UpdateRunOptions, platform: ReturnType<typeof detectPlatform>): Promise<{ release?: ReleaseMetadata; asset: ReleaseAsset; version: string; releaseUrl?: string }> {
if (options.metadataUrl) {
const metadataUrl = validateHttpsUrl(options.metadataUrl, options);
const release = await fetchReleaseMetadata(metadataUrl, options);
let asset = options.assetUrl && !options.requireSignature
? { name: sanitizeAssetName(options.assetName ?? path.basename(new URL(options.assetUrl).pathname)), url: validateHttpsUrl(options.assetUrl, { ...options, baseUrl: metadataUrl }).toString(), ...(options.expectedSha256 ? { sha256: options.expectedSha256 } : {}) }
: selectReleaseAsset(release, platform);
if (!asset) throw new Error("没有匹配当前平台的更新文件");
const integrity = await attachSidecarHash(release, asset, {
allowedHosts: options.allowedHosts ?? [],
baseUrl: metadataUrl.toString(),
maxBytes: options.maxBytes ?? 512 * 1024 * 1024,
publicKey: options.publicKey,
requireSignature: options.requireSignature,
});
asset = integrity.asset;
if (options.requireSignature && !integrity.signatureVerified) throw new Error("更新发布签名校验失败");
if (options.version && compareSemver(options.version, release.version) !== 0) throw new Error("更新版本与发布信息不一致");
return { release, asset: { ...asset, name: sanitizeAssetName(asset.name) }, version: release.version, releaseUrl: metadataUrl.toString() };
}
if (!options.assetUrl || !options.version) throw new Error("必须提供 metadata URL,或同时提供更新文件地址和版本号");
const assetUrl = validateHttpsUrl(options.assetUrl, options);
parseSemver(options.version);
return { asset: { name: sanitizeAssetName(options.assetName ?? path.basename(assetUrl.pathname)), url: assetUrl.toString(), ...(options.expectedSha256 ? { sha256: options.expectedSha256 } : {}) }, version: options.version };
}
async function ensurePrivilegedWorkspace(directory: string): Promise<string> {
const resolved = path.resolve(directory);
await mkdir(resolved, { recursive: true, mode: 0o700 });
const info = await lstat(resolved).catch(() => null);
const uid = typeof process.getuid === "function" ? process.getuid() : -1;
if (!info?.isDirectory() || info.isSymbolicLink() || (info.mode & 0o077) !== 0 || info.uid !== 0 || uid !== 0) {
throw new Error("更新工作目录必须是 root 拥有且权限为 0700");
}
return resolved;
}
export async function runUpdate(options: UpdateRunOptions): Promise<UpdateRunResult> {
const platform = options.platform ?? detectPlatform();
const jobId = options.jobId ?? randomUUID();
let resolved: Awaited<ReturnType<typeof resolveRelease>> | undefined;
try {
resolved = await resolveRelease(options, platform);
const suppliedSha256 = normalizedSha256(options.expectedSha256);
const expectedSha256 = normalizedSha256(options.requireSignature && options.metadataUrl ? resolved.asset.sha256 : suppliedSha256 ?? resolved.asset.sha256);
if (options.requireSignature && options.metadataUrl && suppliedSha256 && suppliedSha256 !== expectedSha256) throw new Error("更新校验值与发布信息不一致");
if (!expectedSha256) throw new Error("发布信息缺少 SHA-256 校验值");
if (options.currentVersion && !isNewerVersion(options.currentVersion, resolved.version)) throw new Error("更新版本不是较新版本");
writeJob(options.sqlite, jobId, {
status: "queued", version: resolved.version, platform: platform.target,
releaseUrl: resolved.releaseUrl, assetName: resolved.asset.name, assetUrl: resolved.asset.url,
expectedSha256, adminId: options.adminId, sessionHash: options.sessionHash,
requestId: options.requestId, requestedAt: Date.now(),
});
await mkdir(options.stagingDir, { recursive: true, mode: 0o700 });
const workspace = await mkdtemp(path.join(path.resolve(options.stagingDir), `update-${jobId}-`));
const archivePath = path.join(workspace, resolved.asset.name.endsWith(".gz") || resolved.asset.name.endsWith(".zip") ? resolved.asset.name : `${resolved.asset.name}.tar.gz`);
try {
updateJob(options.sqlite, jobId, { status: "downloading", version: resolved.version, platform: platform.target, releaseUrl: resolved.releaseUrl, assetName: resolved.asset.name, assetUrl: resolved.asset.url, expectedSha256, downloadPath: path.basename(archivePath), startedAt: Date.now() });
const downloaded = await downloadReleaseAsset(resolved.asset.url, archivePath, options);
if (expectedSha256 && downloaded.sha256 !== expectedSha256) throw new Error("更新文件 SHA-256 校验失败");
updateJob(options.sqlite, jobId, { status: "verifying", version: resolved.version, platform: platform.target, releaseUrl: resolved.releaseUrl, assetName: resolved.asset.name, assetUrl: resolved.asset.url, expectedSha256, actualSha256: downloaded.sha256, sizeBytes: downloaded.size, downloadPath: path.basename(archivePath) });
if (!archivePath.endsWith(".tar.gz") && !archivePath.endsWith(".tgz") && !archivePath.endsWith(".tar") && !archivePath.endsWith(".zip")) throw new Error("更新文件格式仅支持 tar.gz、tar 或 zip");
const stagedDir = path.join(workspace, "payload");
await extractSafeArchive(archivePath, stagedDir, options.maxBytes === undefined ? {} : { maxBytes: options.maxBytes });
await normalizeReleasePermissions(stagedDir);
const payloadInfo = await lstat(path.join(stagedDir, "dist")).catch(() => null);
if (!payloadInfo?.isDirectory() || payloadInfo.isSymbolicLink()) throw new Error("发布包缺少 dist 目录");
updateJob(options.sqlite, jobId, { status: "staged", version: resolved.version, platform: platform.target, releaseUrl: resolved.releaseUrl, assetName: resolved.asset.name, assetUrl: resolved.asset.url, expectedSha256, actualSha256: downloaded.sha256, sizeBytes: downloaded.size, downloadPath: archivePath });
let backupArchivePath: string | undefined;
if (options.dataBackupArchivePath && options.dataBackupSource) {
updateJob(options.sqlite, jobId, { status: "backing_up", version: resolved.version, platform: platform.target, releaseUrl: resolved.releaseUrl, assetName: resolved.asset.name, assetUrl: resolved.asset.url, expectedSha256, actualSha256: downloaded.sha256, sizeBytes: downloaded.size, downloadPath: path.basename(archivePath), backupPath: options.dataBackupArchivePath });
await createSafeArchive(options.dataBackupSource, options.dataBackupArchivePath, {
maxBytes: options.dataBackupMaxBytes ?? 2 * 1024 * 1024 * 1024,
});
}
if (options.backupArchivePath) {
updateJob(options.sqlite, jobId, { status: "backing_up", version: resolved.version, platform: platform.target, releaseUrl: resolved.releaseUrl, assetName: resolved.asset.name, assetUrl: resolved.asset.url, expectedSha256, actualSha256: downloaded.sha256, sizeBytes: downloaded.size, downloadPath: archivePath, backupPath: options.backupArchivePath });
const backupSource = await realpath(options.currentDir).catch(() => options.currentDir);
await createSafeArchive(backupSource, options.backupArchivePath, {
maxBytes: options.maxBytes ?? 512 * 1024 * 1024,
});
backupArchivePath = options.backupArchivePath;
}
updateJob(options.sqlite, jobId, { status: "applying", version: resolved.version, platform: platform.target, releaseUrl: resolved.releaseUrl, assetName: resolved.asset.name, assetUrl: resolved.asset.url, expectedSha256, actualSha256: downloaded.sha256, sizeBytes: downloaded.size, downloadPath: archivePath, backupPath: options.backupArchivePath });
const switchedBackup = options.releasesDir && options.currentLink
? (await atomicSwitchRelease(stagedDir, options.currentLink, options.releasesDir, resolved.version)).previousTarget
: await atomicSwitchDirectory(stagedDir, options.currentDir, options.backupDir);
const completedAt = Date.now();
updateJob(options.sqlite, jobId, { status: options.deferCompletion ? "applying" : "completed", version: resolved.version, platform: platform.target, releaseUrl: resolved.releaseUrl, assetName: resolved.asset.name, assetUrl: resolved.asset.url, expectedSha256, actualSha256: downloaded.sha256, sizeBytes: downloaded.size, downloadPath: path.basename(archivePath), backupPath: switchedBackup ?? backupArchivePath, ...(options.deferCompletion ? {} : { completedAt }) });
return { jobId, version: resolved.version, asset: resolved.asset, archivePath, ...(backupArchivePath ? { backupArchivePath } : {}), ...(switchedBackup ? { backupDir: switchedBackup } : {}) };
} finally {
await rm(workspace, { recursive: true, force: true });
clearTransientJobPath(options.sqlite, jobId);
}
} catch (error) {
const fallbackVersion = resolved?.version ?? options.version ?? "0.0.0";
const fallbackAsset = resolved?.asset ?? { name: options.assetName ?? "unknown", url: options.assetUrl ?? "https://invalid.invalid/unknown" };
updateJob(options.sqlite, jobId, { status: "failed", version: fallbackVersion, platform: platform.target, releaseUrl: resolved?.releaseUrl, assetName: fallbackAsset.name, assetUrl: fallbackAsset.url, expectedSha256: options.expectedSha256 ?? fallbackAsset.sha256, errorMessage: safeErrorMessage(error) });
throw new Error(safeErrorMessage(error));
}
}
export function finalizeUpdateJob(
sqlite: Database.Database,
jobId: string,
status: "completed" | "failed",
message?: string,
): void {
const row = sqlite.prepare(`
SELECT id, status, version, platform, admin_id AS adminId,
request_id AS requestId, session_hash AS sessionHash
FROM update_jobs WHERE id=?
`).get(jobId) as { id: string; status: UpdateJobStatus; version: string; platform: string; adminId: string | null; requestId: string | null; sessionHash: string | null } | undefined;
if (!row) throw new Error("更新任务不存在");
if (row.status !== "applying" && row.status !== "completed" && row.status !== "failed") throw new Error("更新任务状态不允许完成");
const now = Date.now();
const safeFailureMessage = status === "failed" ? "新版本健康检查失败,已恢复上一版本" : null;
sqlite.transaction(() => {
sqlite.prepare("UPDATE update_jobs SET status=?, error_message=?, completed_at=?, updated_at=? WHERE id=?").run(status, safeFailureMessage, now, now, jobId);
writeAudit(sqlite, {
requestId: row.requestId || randomUUID(),
actorAdminId: row.adminId,
action: status === "completed" ? "update.completed" : "update.failed",
targetType: "update",
targetId: jobId,
outcome: status === "completed" ? "success" : "failure",
after: { status, version: row.version, platform: row.platform, ...(status === "failed" ? { reason: "health_check_failed" } : {}) },
});
})();
}
function arg(name: string): string | undefined {
const index = process.argv.indexOf(name);
return index >= 0 ? process.argv[index + 1] : undefined;
}
export async function main(config: AppConfig = loadConfig()): Promise<void> {
const finalizeJobId = arg("--finalize-job");
if (finalizeJobId) {
const finalStatus = arg("--finalize-status");
if (finalStatus !== "completed" && finalStatus !== "failed") throw new Error("更新完成状态无效");
prepareDataDirectories(config);
const database = openDatabase(config);
try {
finalizeUpdateJob(database.sqlite, finalizeJobId, finalStatus, arg("--message"));
} finally {
database.sqlite.close();
}
return;
}
const requestPath = arg("--request-file");
const metadataUrl = arg("--metadata-url");
const assetUrl = arg("--asset-url");
const version = arg("--version");
let request: UpdateRequestFile | undefined;
if (requestPath) {
if (path.resolve(requestPath) !== path.resolve(config.updateRequestPath)) throw new Error("更新请求文件路径无效");
try {
const requestInfo = await lstat(requestPath);
if (!requestInfo.isFile() || requestInfo.isSymbolicLink() || (requestInfo.mode & 0o077) !== 0) throw new Error("权限");
request = validateUpdateRequest(JSON.parse(await readFile(requestPath, "utf8")), config);
} catch { throw new Error("更新请求文件无效"); }
}
const effectiveMetadataUrl = request ? config.updateMetadataUrl : metadataUrl;
const effectiveAssetUrl = request ? undefined : assetUrl;
const effectiveVersion = request?.version ?? version;
const deferCompletion = request ? process.argv.includes("--defer-completion") : false;
const currentDir = request?.currentLink ?? arg("--current-dir") ?? config.projectRoot;
const stagingDir = arg("--staging-dir") ?? (request ? config.updateWorkspaceDir : config.stagingDir);
const backupArchive = arg("--backup-archive") ?? (request ? path.join(config.dataDir, "backups", `update-${request.jobId}.tar.gz`) : undefined);
const dataBackupArchive = arg("--data-backup") ?? (request ? path.join(path.dirname(config.dataDir), "tallynote-backups", `data-${request.jobId}.tar.gz`) : undefined);
const allowedHosts = process.argv.flatMap((value, index) => value === "--allow-host" && process.argv[index + 1] ? [process.argv[index + 1]!] : []);
prepareDataDirectories(config);
if (request) await ensurePrivilegedWorkspace(stagingDir);
else await mkdir(stagingDir, { recursive: true, mode: 0o700 });
const release = acquireInstanceLock(config);
const database = openDatabase(config);
try {
const result = await runUpdate({
...(effectiveMetadataUrl ? { metadataUrl: effectiveMetadataUrl } : {}),
...(effectiveAssetUrl ? { assetUrl: effectiveAssetUrl } : {}),
...(effectiveVersion ? { version: effectiveVersion } : {}),
...((request ? undefined : arg("--sha256")) ? { expectedSha256: arg("--sha256") } : {}),
currentDir,
stagingDir,
...(request ? { currentLink: request.currentLink, releasesDir: request.releasesDir } : {}),
...(backupArchive ? { backupArchivePath: backupArchive } : {}),
...(dataBackupArchive ? { dataBackupArchivePath: dataBackupArchive, dataBackupSource: config.dataDir } : {}),
...((arg("--backup-dir")) ? { backupDir: arg("--backup-dir") } : {}),
allowedHosts: allowedHosts.length ? allowedHosts : config.updateAllowedHosts,
maxBytes: config.updateMaxBytes,
dataBackupMaxBytes: config.maxTotalBytes,
currentVersion: config.appVersion,
...(deferCompletion ? { deferCompletion: true } : {}),
...(request ? { jobId: request.jobId } : {}),
publicKey: config.updatePublicKey,
requireSignature: request ? true : config.updateRequireSignature,
sqlite: database.sqlite,
});
console.log(`更新完成:${result.version}`);
} finally {
database.sqlite.close();
release();
}
}
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
main().catch((error) => {
console.error(safeErrorMessage(error));
process.exitCode = 1;
});
}
+252
View File
@@ -0,0 +1,252 @@
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.
}
};
}
+46
View File
@@ -0,0 +1,46 @@
import Database from "better-sqlite3";
import { drizzle, type BetterSQLite3Database } from "drizzle-orm/better-sqlite3";
import { readdirSync, readFileSync } from "node:fs";
import { chmodSync, existsSync } from "node:fs";
import path from "node:path";
import type { AppConfig } from "../config.js";
import * as schema from "./schema.js";
export type DatabaseContext = {
sqlite: Database.Database;
db: BetterSQLite3Database<typeof schema>;
};
function migrate(sqlite: Database.Database, migrationsDir: string): void {
sqlite.exec("CREATE TABLE IF NOT EXISTS schema_migrations (name TEXT PRIMARY KEY, applied_at INTEGER NOT NULL) STRICT");
const applied = new Set(
(sqlite.prepare("SELECT name FROM schema_migrations").all() as Array<{ name: string }>).map((row) => row.name),
);
const files = readdirSync(migrationsDir).filter((name) => name.endsWith(".sql")).sort();
const apply = sqlite.transaction((name: string, sqlText: string) => {
sqlite.exec(sqlText);
sqlite.prepare("INSERT INTO schema_migrations(name, applied_at) VALUES (?, ?)").run(name, Date.now());
});
for (const name of files) {
if (!applied.has(name)) apply(name, readFileSync(path.join(migrationsDir, name), "utf8"));
}
}
export function openDatabase(config: AppConfig): DatabaseContext {
const sqlite = new Database(config.dbPath);
sqlite.pragma("foreign_keys = ON");
sqlite.pragma("journal_mode = WAL");
sqlite.pragma("synchronous = FULL");
sqlite.pragma("busy_timeout = 5000");
sqlite.pragma("temp_store = MEMORY");
migrate(sqlite, config.migrationsDir);
// SQLite creates the database and journal files after the initial directory
// preparation. Enforce private permissions again after opening so a broad
// process umask can never expose financial data to other local users.
for (const filePath of [config.dbPath, `${config.dbPath}-wal`, `${config.dbPath}-shm`]) {
if (existsSync(filePath)) chmodSync(filePath, 0o600);
}
const foreignKeys = sqlite.pragma("foreign_keys", { simple: true });
if (foreignKeys !== 1) throw new Error("SQLite 外键未启用");
return { sqlite, db: drizzle(sqlite, { schema }) };
}
+160
View File
@@ -0,0 +1,160 @@
import { sql } from "drizzle-orm";
import { blob, check, index, integer, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core";
export const admins = sqliteTable("admins", {
id: text("id").primaryKey(),
username: text("username").notNull(),
usernameNorm: text("username_norm").notNull(),
displayName: text("display_name").notNull(),
passwordHash: text("password_hash").notNull(),
status: text("status", { enum: ["active", "disabled"] }).notNull().default("active"),
mustChangePassword: integer("must_change_password", { mode: "boolean" }).notNull().default(true),
authVersion: integer("auth_version").notNull().default(1),
version: integer("version").notNull().default(1),
createdAt: integer("created_at").notNull(),
createdBy: text("created_by"),
passwordChangedAt: integer("password_changed_at"),
lastLoginAt: integer("last_login_at"),
disabledAt: integer("disabled_at"),
disabledBy: text("disabled_by"),
}, (table) => [
uniqueIndex("admins_username_norm_uq").on(table.usernameNorm),
check("admins_status_ck", sql`${table.status} in ('active','disabled')`),
check("admins_versions_ck", sql`${table.version} >= 1 and ${table.authVersion} >= 1`),
]);
export const sessions = sqliteTable("sessions", {
tokenHash: text("token_hash").primaryKey(),
adminId: text("admin_id").notNull().references(() => admins.id, { onDelete: "cascade" }),
csrfHash: text("csrf_hash").notNull(),
authVersion: integer("auth_version").notNull(),
createdAt: integer("created_at").notNull(),
lastSeenAt: integer("last_seen_at").notNull(),
idleExpiresAt: integer("idle_expires_at").notNull(),
absoluteExpiresAt: integer("absolute_expires_at").notNull(),
}, (table) => [index("sessions_admin_idx").on(table.adminId), index("sessions_expiry_idx").on(table.idleExpiresAt)]);
export const expenses = sqliteTable("expenses", {
id: text("id").primaryKey(),
paidAt: integer("paid_at").notNull(),
amountCents: integer("amount_cents").notNull(),
note: text("note").notNull().default(""),
invoiceMissingReason: text("invoice_missing_reason"),
status: text("status", { enum: ["unreimbursed", "reimbursed"] }).notNull().default("unreimbursed"),
version: integer("version").notNull().default(1),
createdAt: integer("created_at").notNull(),
createdBy: text("created_by").notNull().references(() => admins.id, { onDelete: "restrict" }),
updatedAt: integer("updated_at").notNull(),
updatedBy: text("updated_by").notNull().references(() => admins.id, { onDelete: "restrict" }),
reimbursedAt: integer("reimbursed_at"),
reimbursedBy: text("reimbursed_by").references(() => admins.id, { onDelete: "restrict" }),
deletedAt: integer("deleted_at"),
deletedBy: text("deleted_by").references(() => admins.id, { onDelete: "restrict" }),
}, (table) => [
index("expenses_list_idx").on(table.deletedAt, table.status, table.paidAt),
check("expenses_amount_ck", sql`${table.amountCents} > 0 and ${table.amountCents} <= 999999999999`),
check("expenses_status_ck", sql`${table.status} in ('unreimbursed','reimbursed')`),
check("expenses_version_ck", sql`${table.version} >= 1`),
]);
export const attachments = sqliteTable("attachments", {
id: text("id").primaryKey(),
expenseId: text("expense_id").notNull().references(() => expenses.id, { onDelete: "cascade" }),
kind: text("kind", { enum: ["payment_proof", "invoice"] }).notNull(),
storagePath: text("storage_path").notNull(),
originalName: text("original_name").notNull(),
mimeType: text("mime_type").notNull(),
sizeBytes: integer("size_bytes").notNull(),
sha256: text("sha256").notNull(),
createdAt: integer("created_at").notNull(),
createdBy: text("created_by").notNull().references(() => admins.id, { onDelete: "restrict" }),
}, (table) => [
uniqueIndex("attachments_path_uq").on(table.storagePath),
index("attachments_expense_idx").on(table.expenseId),
check("attachments_kind_ck", sql`${table.kind} in ('payment_proof','invoice')`),
check("attachments_size_ck", sql`${table.sizeBytes} > 0`),
]);
export const auditEvents = sqliteTable("audit_events", {
id: integer("id").primaryKey({ autoIncrement: true }),
occurredAt: integer("occurred_at").notNull(),
requestId: text("request_id").notNull(),
actorAdminId: text("actor_admin_id"),
actorUsername: text("actor_username"),
action: text("action").notNull(),
targetType: text("target_type").notNull(),
targetId: text("target_id"),
outcome: text("outcome", { enum: ["success", "denied", "failure"] }).notNull(),
beforeJson: text("before_json"),
afterJson: text("after_json"),
metadataJson: text("metadata_json"),
}, (table) => [index("audit_time_idx").on(table.occurredAt), index("audit_target_idx").on(table.targetType, table.targetId)]);
export const systemSettings = sqliteTable("system_settings", {
key: text("key").primaryKey(),
value: text("value").notNull(),
updatedAt: integer("updated_at").notNull(),
});
export const exportJobs = sqliteTable("export_jobs", {
id: text("id").primaryKey(),
adminId: text("admin_id").notNull().references(() => admins.id, { onDelete: "cascade" }),
sessionHash: text("session_hash").notNull(),
status: text("status", { enum: ["queued", "building", "ready", "failed", "expired"] }).notNull(),
selectionJson: text("selection_json").notNull(),
snapshotJson: text("snapshot_json").notNull(),
filePath: text("file_path"),
fileName: text("file_name").notNull(),
sizeBytes: integer("size_bytes"),
sha256: text("sha256"),
errorMessage: text("error_message"),
createdAt: integer("created_at").notNull(),
readyAt: integer("ready_at"),
expiresAt: integer("expires_at").notNull(),
}, (table) => [index("exports_expiry_idx").on(table.expiresAt), index("exports_session_idx").on(table.sessionHash)]);
export const loginAttempts = sqliteTable("login_attempts", {
keyHash: text("key_hash").primaryKey(),
windowStart: integer("window_start").notNull(),
failures: integer("failures").notNull(),
blockedUntil: integer("blocked_until"),
});
export const fileDeletions = sqliteTable("file_deletions", {
id: text("id").primaryKey(),
storagePath: text("storage_path").notNull(),
reason: text("reason").notNull(),
status: text("status", { enum: ["pending", "complete", "failed"] }).notNull().default("pending"),
attempts: integer("attempts").notNull().default(0),
lastError: text("last_error"),
createdAt: integer("created_at").notNull(),
completedAt: integer("completed_at"),
}, (table) => [index("file_deletions_status_idx").on(table.status)]);
export const updateJobs = sqliteTable("update_jobs", {
id: text("id").primaryKey(),
adminId: text("admin_id").references(() => admins.id, { onDelete: "set null" }),
sessionHash: text("session_hash"),
requestId: text("request_id"),
status: text("status", { enum: ["queued", "downloading", "verifying", "staged", "backing_up", "applying", "completed", "failed", "cancelled"] }).notNull(),
version: text("version").notNull(),
platform: text("platform").notNull(),
releaseUrl: text("release_url"),
assetName: text("asset_name"),
assetUrl: text("asset_url").notNull(),
expectedSha256: text("expected_sha256"),
actualSha256: text("actual_sha256"),
downloadPath: text("download_path"),
backupPath: text("backup_path"),
sizeBytes: integer("size_bytes"),
errorMessage: text("error_message"),
createdAt: integer("created_at").notNull(),
requestedAt: integer("requested_at"),
startedAt: integer("started_at"),
updatedAt: integer("updated_at").notNull(),
completedAt: integer("completed_at"),
}, (table) => [
index("update_jobs_status_idx").on(table.status, table.createdAt),
index("update_jobs_admin_idx").on(table.adminId, table.createdAt),
index("update_jobs_session_idx").on(table.sessionHash),
]);
+27
View File
@@ -0,0 +1,27 @@
import type { FastifyRequest } from "fastify";
export class AppError extends Error {
constructor(
public readonly statusCode: number,
public readonly code: string,
message: string,
public readonly details?: unknown,
) {
super(message);
}
}
export function errorPayload(request: FastifyRequest, error: AppError) {
return {
error: {
code: error.code,
message: error.message,
requestId: request.id,
...(error.details === undefined ? {} : { details: error.details }),
},
};
}
export function notFound(message = "没有找到对应内容"): never {
throw new AppError(404, "NOT_FOUND", message);
}
+276
View File
@@ -0,0 +1,276 @@
import { createHash, randomUUID } from "node:crypto";
import { constants as fsConstants, createWriteStream } from "node:fs";
import { open, readdir, rename, rm, stat, unlink } from "node:fs/promises";
import path from "node:path";
import { ZipArchive } from "archiver";
import type Database from "better-sqlite3";
import ExcelJS from "exceljs";
import type { AppConfig } from "./config.js";
import { readStorageFile, safeStoragePath, sanitizeOriginalName } from "./files.js";
export type ExportAttachment = {
id: string;
kind: "payment_proof" | "invoice";
originalName: string;
mimeType: string;
storagePath: string;
sizeBytes: number;
sha256: string;
};
export type ExportExpense = {
id: string;
paidAt: number;
amountCents: number;
note: string;
invoiceMissingReason: string | null;
status: "unreimbursed" | "reimbursed";
attachments: ExportAttachment[];
};
export type ExportSnapshot = { expenses: ExportExpense[]; includeManifest?: boolean };
// The application is intentionally single-instance, but a queued export can
// still be triggered twice by a retry or two browser tabs. Keep one builder
// per job so both calls cannot write the same .part file concurrently.
const activeExportBuilds = new Set<string>();
export function safeExcelText(value: string): string {
const cleaned = value.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g, "").slice(0, 32_767);
return /^[\s\u0000-\u001f]*[=+\-@]/.test(cleaned) ? `'${cleaned}` : cleaned;
}
function dateParts(timestamp: number, timezone: string): { display: string; compact: string } {
const formatter = new Intl.DateTimeFormat("zh-CN", {
timeZone: timezone,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
const pieces = Object.fromEntries(formatter.formatToParts(timestamp).map((part) => [part.type, part.value]));
return {
display: `${pieces.year}-${pieces.month}-${pieces.day} ${pieces.hour}:${pieces.minute}`,
compact: `${pieces.year}${pieces.month}${pieces.day}`,
};
}
function uniqueAttachmentName(attachment: ExportAttachment, seen: Set<string>): string {
const parsed = path.parse(sanitizeOriginalName(attachment.originalName));
const fallbackExtension = path.extname(attachment.storagePath);
const extension = (parsed.ext || fallbackExtension).slice(0, 16);
const base = (parsed.name || attachment.kind).slice(0, 100);
if ([base, extension].some((part) => part.includes("/") || part.includes("\\") || part === "." || part === "..")) {
throw new Error("附件文件名包含非法路径片段");
}
let candidate = `${base}${extension}`;
let counter = 2;
while (seen.has(candidate.toLocaleLowerCase("und"))) candidate = `${base}_${counter++}${extension}`;
seen.add(candidate.toLocaleLowerCase("und"));
return candidate;
}
async function workbookBuffer(snapshot: ExportSnapshot, config: AppConfig): Promise<Buffer> {
const workbook = new ExcelJS.Workbook();
workbook.creator = "TallyNote";
workbook.created = new Date();
const sheet = workbook.addWorksheet("报销清单", { views: [{ state: "frozen", ySplit: 1 }] });
sheet.columns = [
{ header: "序号", key: "sequence", width: 8 },
{ header: "支付时间", key: "paidAt", width: 22 },
{ header: "金额(元)", key: "amount", width: 16 },
{ header: "备注", key: "note", width: 44 },
{ header: "状态", key: "status", width: 14 },
{ header: "记录 ID", key: "id", width: 38 },
{ header: "付款凭证", key: "proofs", width: 38 },
{ header: "发票", key: "invoices", width: 38 },
{ header: "无发票原因", key: "invoiceMissingReason", width: 44 },
];
sheet.getRow(1).font = { bold: true, color: { argb: "FFFFFFFF" } };
sheet.getRow(1).fill = { type: "pattern", pattern: "solid", fgColor: { argb: "FF1F4D43" } };
sheet.getRow(1).height = 24;
snapshot.expenses.forEach((expense, index) => {
const row = sheet.addRow({
sequence: index + 1,
paidAt: dateParts(expense.paidAt, config.timezone).display,
amount: expense.amountCents / 100,
note: safeExcelText(expense.note),
status: expense.status === "reimbursed" ? "已报销" : "未报销",
id: expense.id,
proofs: safeExcelText(expense.attachments.filter((item) => item.kind === "payment_proof").map((item) => item.originalName).join(";")),
invoices: safeExcelText(expense.attachments.filter((item) => item.kind === "invoice").map((item) => item.originalName).join(";")),
invoiceMissingReason: safeExcelText(expense.invoiceMissingReason || ""),
});
row.getCell("amount").numFmt = '¥#,##0.00';
row.alignment = { vertical: "top", wrapText: true };
});
const totalRow = sheet.addRow({
sequence: "合计",
amount: snapshot.expenses.reduce((sum, expense) => sum + expense.amountCents, 0) / 100,
});
totalRow.font = { bold: true };
totalRow.getCell("amount").numFmt = '¥#,##0.00';
sheet.autoFilter = { from: "A1", to: "I1" };
return Buffer.from(await workbook.xlsx.writeBuffer());
}
export async function buildExportJob(sqlite: Database.Database, config: AppConfig, jobId: string): Promise<void> {
if (activeExportBuilds.has(jobId)) return;
activeExportBuilds.add(jobId);
try {
await buildExportJobOnce(sqlite, config, jobId);
} finally {
activeExportBuilds.delete(jobId);
}
}
async function buildExportJobOnce(sqlite: Database.Database, config: AppConfig, jobId: string): Promise<void> {
const job = sqlite.prepare("SELECT snapshot_json AS snapshotJson FROM export_jobs WHERE id=? AND status IN ('queued','building')").get(jobId) as { snapshotJson: string } | undefined;
if (!job) return;
sqlite.prepare("UPDATE export_jobs SET status='building', error_message=NULL WHERE id=?").run(jobId);
// Use a fresh O_EXCL path for every build. A deterministic `.part` path can
// be pre-created as a symlink by another local process and then followed by
// createWriteStream. The final rename remains atomic and replaces only the
// destination entry itself.
const partialPath = path.join(config.exportsDir, `${jobId}.zip.part-${randomUUID()}`);
const finalPath = path.join(config.exportsDir, `${jobId}.zip`);
try {
const snapshot = JSON.parse(job.snapshotJson) as ExportSnapshot;
const output = createWriteStream(partialPath, { flags: "wx", mode: 0o600 });
const archive = new ZipArchive({ zlib: { level: 6 } });
const completed = new Promise<void>((resolve, reject) => {
output.on("close", resolve);
output.on("error", reject);
archive.on("warning", reject);
archive.on("error", reject);
});
archive.pipe(output);
archive.append(await workbookBuffer(snapshot, config), { name: "报销清单.xlsx" });
if (snapshot.includeManifest === true) {
const manifest = {
generatedAt: new Date().toISOString(),
records: snapshot.expenses.map((expense) => ({
id: expense.id,
paidAt: dateParts(expense.paidAt, config.timezone).display,
amountCents: expense.amountCents,
invoiceMissingReason: expense.invoiceMissingReason || null,
attachments: expense.attachments.map((attachment) => ({
id: attachment.id,
kind: attachment.kind,
originalName: attachment.originalName,
mimeType: attachment.mimeType,
sizeBytes: attachment.sizeBytes,
sha256: attachment.sha256,
})),
})),
};
archive.append(JSON.stringify(manifest, null, 2), { name: "manifest.json" });
}
for (const [index, expense] of snapshot.expenses.entries()) {
const date = dateParts(expense.paidAt, config.timezone).compact;
const folder = `${String(index + 1).padStart(3, "0")}_${date}_${(expense.amountCents / 100).toFixed(2)}_${expense.id.slice(0, 8)}`;
const seen = new Set<string>();
for (const attachment of expense.attachments) {
const group = attachment.kind === "payment_proof" ? "付款凭证" : "发票";
const fileName = uniqueAttachmentName(attachment, seen);
const absolute = safeStoragePath(config.filesDir, attachment.storagePath);
const bytes = await readStorageFile(config, attachment.storagePath);
const digest = createHash("sha256").update(bytes).digest("hex");
if (bytes.length !== attachment.sizeBytes || digest !== attachment.sha256) {
throw new Error(`附件校验失败:${attachment.id}`);
}
archive.append(bytes, { name: `${folder}/${group}/${fileName}` });
}
}
await archive.finalize();
await completed;
await rename(partialPath, finalPath);
// Open without following symlinks and keep the descriptor for the digest
// and size read. This closes the check/use gap around the published file.
const handle = await open(finalPath, fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0));
let bytes: Buffer;
let info;
try {
info = await handle.stat();
if (!info.isFile()) throw new Error("导出文件类型无效");
bytes = await handle.readFile();
} finally {
await handle.close();
}
const published = sqlite.prepare(`
UPDATE export_jobs SET status='ready', file_path=?, size_bytes=?, sha256=?, ready_at=?
WHERE id=? AND status='building'
`).run(path.basename(finalPath), info.size, createHash("sha256").update(bytes).digest("hex"), Date.now(), jobId);
if (published.changes !== 1) await rm(finalPath, { force: true });
} catch (error) {
await rm(partialPath, { force: true });
await rm(finalPath, { force: true });
// Never expose filesystem paths, attachment IDs, or raw OS errors through
// the export status API. Keep a small allowlist of actionable messages.
const raw = error instanceof Error ? error.message : "";
const safe = raw.startsWith("附件校验失败") || raw.includes("ENOENT")
? "导出失败:附件文件缺失或校验不通过"
: "导出失败:服务器无法生成导出文件";
sqlite.prepare("UPDATE export_jobs SET status='failed', error_message=? WHERE id=? AND status='building'").run(safe, jobId);
}
}
export function insertExportJob(
sqlite: Database.Database,
config: AppConfig,
input: { adminId: string; sessionHash: string; selection: unknown; snapshot: ExportSnapshot },
): string {
const id = randomUUID();
const now = Date.now();
sqlite.prepare(`
INSERT INTO export_jobs (
id, admin_id, session_hash, status, selection_json, snapshot_json,
file_name, created_at, expires_at
) VALUES (?, ?, ?, 'queued', ?, ?, ?, ?, ?)
`).run(
id,
input.adminId,
input.sessionHash,
JSON.stringify(input.selection),
JSON.stringify(input.snapshot),
`TallyNote_报销资料_${id.slice(0, 8)}.zip`,
now,
now + config.exportTtlMs,
);
return id;
}
export async function resumeExports(sqlite: Database.Database, config: AppConfig): Promise<void> {
const jobs = sqlite.prepare("SELECT id FROM export_jobs WHERE status IN ('queued','building') AND expires_at > ?").all(Date.now()) as Array<{ id: string }>;
for (const job of jobs) await buildExportJob(sqlite, config, job.id);
}
export async function expireExports(sqlite: Database.Database, config: AppConfig): Promise<void> {
const rows = sqlite.prepare("SELECT id, file_path AS filePath FROM export_jobs WHERE status != 'expired' AND expires_at <= ?").all(Date.now()) as Array<{ id: string; filePath: string | null }>;
for (const row of rows) {
if (row.filePath) {
await unlink(safeStoragePath(config.exportsDir, row.filePath)).catch((error: NodeJS.ErrnoException) => {
if (error.code !== "ENOENT") throw error;
});
}
sqlite.prepare("UPDATE export_jobs SET status='expired', file_path=NULL WHERE id=?").run(row.id);
}
}
export async function cleanupOrphanedExports(sqlite: Database.Database, config: AppConfig): Promise<void> {
const referenced = new Set((sqlite.prepare("SELECT file_path AS filePath FROM export_jobs WHERE status='ready' AND file_path IS NOT NULL AND expires_at > ?").all(Date.now()) as Array<{ filePath: string }>).map((row) => row.filePath));
const activeJobs = sqlite.prepare("SELECT id FROM export_jobs WHERE status IN ('queued','building') AND expires_at > ?").all(Date.now()) as Array<{ id: string }>;
for (const job of activeJobs) {
referenced.add(`${job.id}.zip`);
}
const cutoff = Date.now() - 10 * 60 * 1000;
for (const entry of await readdir(config.exportsDir, { withFileTypes: true })) {
if (!entry.isFile() && !entry.isSymbolicLink()) continue;
const target = path.join(config.exportsDir, entry.name);
const info = await stat(target).catch(() => null);
const belongsToActiveBuild = activeJobs.some((job) => entry.name.startsWith(`${job.id}.zip.part-`));
if (info && info.mtimeMs < cutoff && !referenced.has(entry.name) && !belongsToActiveBuild) await rm(target, { force: true });
}
}
+252
View File
@@ -0,0 +1,252 @@
import { createHash, randomUUID } from "node:crypto";
import { constants as fsConstants, createReadStream, createWriteStream } from "node:fs";
import { chmod, mkdir, open, readFile, readdir, rename, rm, stat, unlink } from "node:fs/promises";
import path from "node:path";
import { Transform } from "node:stream";
import { pipeline } from "node:stream/promises";
import type { MultipartFile } from "@fastify/multipart";
import type Database from "better-sqlite3";
import { XMLParser, XMLValidator } from "fast-xml-parser";
import { PDFDocument } from "pdf-lib";
import sharp from "sharp";
import yauzl from "yauzl";
import type { AttachmentKind } from "../shared/contracts.js";
import type { AppConfig } from "./config.js";
import { AppError } from "./errors.js";
export type StagedFile = {
id: string;
originalName: string;
stagingPath: string;
sizeBytes: number;
sha256: string;
mimeType: string;
extension: string;
kind: AttachmentKind;
};
const imageTypes = new Map([
["jpeg", { mimeType: "image/jpeg", extension: "jpg" }],
["png", { mimeType: "image/png", extension: "png" }],
["webp", { mimeType: "image/webp", extension: "webp" }],
]);
export function sanitizeOriginalName(value: string): string {
const normalized = path.basename(value.normalize("NFKC").replaceAll("\\", "/")).replace(/[\u0000-\u001f\u007f]/g, "").trim();
return (normalized || "未命名文件").slice(0, 200);
}
function detectBasic(buffer: Buffer): "jpeg" | "png" | "webp" | "pdf" | "ofd" | "xml" | null {
if (buffer.length >= 4 && buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) return "jpeg";
if (buffer.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) return "png";
if (buffer.subarray(0, 4).toString("ascii") === "RIFF" && buffer.subarray(8, 12).toString("ascii") === "WEBP") return "webp";
if (buffer.subarray(0, 5).toString("ascii") === "%PDF-") return "pdf";
if (buffer[0] === 0x50 && buffer[1] === 0x4b) return "ofd";
const prefix = buffer.subarray(0, 256).toString("utf8").trimStart();
if (prefix.startsWith("<?xml") || prefix.startsWith("<")) return "xml";
return null;
}
async function validateOfd(buffer: Buffer): Promise<void> {
await new Promise<void>((resolve, reject) => {
yauzl.fromBuffer(buffer, { lazyEntries: true, validateEntrySizes: true }, (error, zip) => {
if (error || !zip) return reject(error ?? new Error("无法读取 OFD"));
let entries = 0;
let total = 0;
let hasRoot = false;
let settled = false;
const fail = (reason: Error) => {
if (settled) return;
settled = true;
zip.close();
reject(reason);
};
zip.on("entry", (entry) => {
entries += 1;
total += entry.uncompressedSize;
const name = entry.fileName.replaceAll("\\", "/");
if (name === "OFD.xml") hasRoot = true;
if (entries > 2000 || total > 200 * 1024 * 1024 || name.startsWith("/") || name.split("/").includes("..")) {
fail(new Error("OFD 结构超出安全限制"));
return;
}
zip.readEntry();
});
zip.on("end", () => {
if (settled) return;
settled = true;
hasRoot ? resolve() : reject(new Error("缺少 OFD.xml"));
});
zip.on("error", fail);
zip.readEntry();
});
});
}
async function validateContent(buffer: Buffer, kind: AttachmentKind): Promise<{ mimeType: string; extension: string }> {
const detected = detectBasic(buffer);
if (!detected) throw new AppError(415, "UNSUPPORTED_MEDIA_TYPE", "无法识别文件格式");
if (imageTypes.has(detected)) {
const metadata = await sharp(buffer, { failOn: "error", limitInputPixels: 40_000_000 }).metadata();
if (!metadata.width || !metadata.height || !metadata.format || !imageTypes.has(metadata.format)) {
throw new AppError(415, "INVALID_IMAGE", "图片内容无效");
}
return imageTypes.get(metadata.format)!;
}
if (kind === "payment_proof") {
throw new AppError(415, "PAYMENT_PROOF_MUST_BE_IMAGE", "付款凭证仅支持 JPEG、PNG 或 WebP 图片");
}
if (detected === "pdf") {
// Inspect the binary token stream case-insensitively. PDF names are
// case-sensitive in theory, but rejecting common active-content aliases
// avoids browser/plugin execution surprises across viewers.
const pdfTokens = buffer.toString("latin1");
const suspicious = /\/(?:JavaScript|JS|Launch|EmbeddedFile|OpenAction|AA)\b/i.test(pdfTokens);
if (suspicious) throw new AppError(415, "UNSAFE_PDF", "PDF 包含不受支持的活动内容");
const document = await PDFDocument.load(buffer, { ignoreEncryption: false, throwOnInvalidObject: true });
if (document.getPageCount() < 1 || document.getPageCount() > 2000) throw new Error("PDF 页数无效");
return { mimeType: "application/pdf", extension: "pdf" };
}
if (detected === "ofd") {
await validateOfd(buffer);
return { mimeType: "application/ofd", extension: "ofd" };
}
const xml = buffer.toString("utf8");
if (/<!DOCTYPE|<!ENTITY/i.test(xml)) throw new AppError(415, "UNSAFE_XML", "XML 不允许 DTD 或实体声明");
if (XMLValidator.validate(xml) !== true) throw new AppError(415, "INVALID_XML", "XML 内容无效");
new XMLParser({ processEntities: false, ignoreAttributes: false }).parse(xml);
return { mimeType: "application/xml", extension: "xml" };
}
export async function stageMultipartFile(config: AppConfig, part: MultipartFile, kind: AttachmentKind): Promise<StagedFile> {
const id = randomUUID();
const stagingPath = path.join(config.stagingDir, `${id}.part`);
let sizeBytes = 0;
const hash = createHash("sha256");
const meter = new Transform({
transform(chunk: Buffer, _encoding, callback) {
sizeBytes += chunk.length;
if (sizeBytes > config.maxFileBytes) return callback(new AppError(413, "FILE_TOO_LARGE", "单个文件超过大小限制"));
hash.update(chunk);
callback(null, chunk);
},
});
try {
await pipeline(part.file, meter, createWriteStream(stagingPath, { flags: "wx", mode: 0o600 }));
if (part.file.truncated || sizeBytes === 0) throw new AppError(413, "FILE_TOO_LARGE", "文件为空或超过大小限制");
const buffer = await readFile(stagingPath);
const type = await validateContent(buffer, kind);
return {
id,
originalName: sanitizeOriginalName(part.filename),
stagingPath,
sizeBytes,
sha256: hash.digest("hex"),
mimeType: type.mimeType,
extension: type.extension,
kind,
};
} catch (error) {
await rm(stagingPath, { force: true });
if (error instanceof AppError) throw error;
throw new AppError(415, "INVALID_FILE", "文件内容校验失败");
}
}
export async function promoteStagedFile(config: AppConfig, file: StagedFile): Promise<string> {
const relative = path.join(file.id.slice(0, 2), `${file.id}.${file.extension}`);
const destination = safeStoragePath(config.filesDir, relative);
await mkdir(path.dirname(destination), { recursive: true, mode: 0o700 });
await chmod(path.dirname(destination), 0o700);
await rename(file.stagingPath, destination);
const directory = await open(path.dirname(destination), "r");
await directory.sync();
await directory.close();
return relative;
}
export function safeStoragePath(root: string, relative: string): string {
if (path.isAbsolute(relative)) throw new AppError(500, "INVALID_STORAGE_PATH", "附件路径无效");
const resolvedRoot = path.resolve(root);
const resolved = path.resolve(root, relative);
if (!resolved.startsWith(`${resolvedRoot}${path.sep}`)) throw new AppError(500, "INVALID_STORAGE_PATH", "附件路径无效");
return resolved;
}
export async function discardStaged(files: StagedFile[]): Promise<void> {
await Promise.all(files.map((file) => rm(file.stagingPath, { force: true })));
}
export async function processFileDeletions(sqlite: Database.Database, config: AppConfig): Promise<void> {
const rows = sqlite.prepare(`
SELECT id, storage_path AS storagePath FROM file_deletions
WHERE status IN ('pending','failed') AND attempts < 10 ORDER BY created_at LIMIT 100
`).all() as Array<{ id: string; storagePath: string }>;
for (const row of rows) {
try {
await unlink(safeStoragePath(config.filesDir, row.storagePath)).catch((error: NodeJS.ErrnoException) => {
if (error.code !== "ENOENT") throw error;
});
sqlite.prepare("UPDATE file_deletions SET status='complete', attempts=attempts+1, last_error=NULL, completed_at=? WHERE id=?").run(Date.now(), row.id);
} catch (error) {
sqlite.prepare("UPDATE file_deletions SET status='failed', attempts=attempts+1, last_error=? WHERE id=?").run(String(error).slice(0, 500), row.id);
}
}
}
export async function cleanupStaging(config: AppConfig): Promise<void> {
const cutoff = Date.now() - 24 * 60 * 60 * 1000;
for (const entry of await readdir(config.stagingDir, { withFileTypes: true })) {
const target = path.join(config.stagingDir, entry.name);
const info = await stat(target).catch(() => null);
if (info && info.mtimeMs < cutoff) await rm(target, { recursive: true, force: true });
}
}
export async function cleanupOrphanedFiles(sqlite: Database.Database, config: AppConfig): Promise<void> {
const referenced = new Set((sqlite.prepare("SELECT storage_path AS storagePath FROM attachments").all() as Array<{ storagePath: string }>).map((row) => row.storagePath));
const cutoff = Date.now() - 24 * 60 * 60 * 1000;
const walk = async (directory: string, prefix: string): Promise<void> => {
for (const entry of await readdir(directory, { withFileTypes: true })) {
const relative = path.join(prefix, entry.name);
const target = path.join(directory, entry.name);
if (entry.isDirectory()) {
await walk(target, relative);
continue;
}
if (!entry.isFile() && !entry.isSymbolicLink()) continue;
const info = await stat(target).catch(() => null);
if (info && info.mtimeMs < cutoff && !referenced.has(relative)) await rm(target, { force: true });
}
};
await walk(config.filesDir, "");
}
export async function fileReadStream(config: AppConfig, storagePath: string) {
return safeReadStream(config.filesDir, storagePath);
}
/** Open a private file by descriptor and keep the no-follow guarantee through
* the subsequent read. Used for both attachment and export downloads. */
export async function safeReadStream(root: string, relativePath: string) {
const handle = await open(safeStoragePath(root, relativePath), fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0));
try {
const info = await handle.stat();
if (!info.isFile()) throw new AppError(410, "ATTACHMENT_MISSING", "附件文件已不可用");
return handle.createReadStream({ autoClose: true });
} catch (error) {
await handle.close().catch(() => undefined);
throw error;
}
}
export async function readStorageFile(config: AppConfig, storagePath: string): Promise<Buffer> {
const handle = await open(safeStoragePath(config.filesDir, storagePath), fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0));
try {
const info = await handle.stat();
if (!info.isFile()) throw new AppError(410, "ATTACHMENT_MISSING", "附件文件已不可用");
return await handle.readFile();
} finally {
await handle.close();
}
}
+54
View File
@@ -0,0 +1,54 @@
import { cleanupOrphanedFiles, cleanupStaging, processFileDeletions } from "./files.js";
import { loadConfig, prepareDataDirectories, acquireInstanceLock } from "./config.js";
import { openDatabase } from "./db/index.js";
import { buildApp } from "./app.js";
import { cleanupOrphanedExports, expireExports, resumeExports } from "./exporter.js";
const config = loadConfig();
prepareDataDirectories(config);
const releaseLock = acquireInstanceLock(config);
const database = openDatabase(config);
async function start() {
await cleanupStaging(config);
await cleanupOrphanedFiles(database.sqlite, config);
database.sqlite.prepare("DELETE FROM login_attempts WHERE window_start < ? AND (blocked_until IS NULL OR blocked_until < ?)").run(Date.now() - 24 * 60 * 60 * 1000, Date.now());
database.sqlite.prepare("DELETE FROM sessions WHERE idle_expires_at <= ? OR absolute_expires_at <= ?").run(Date.now(), Date.now());
await processFileDeletions(database.sqlite, config);
await expireExports(database.sqlite, config);
await cleanupOrphanedExports(database.sqlite, config);
await resumeExports(database.sqlite, config);
const app = await buildApp(database, config);
const janitor = setInterval(() => {
void cleanupStaging(config);
void cleanupOrphanedFiles(database.sqlite, config);
database.sqlite.prepare("DELETE FROM login_attempts WHERE window_start < ? AND (blocked_until IS NULL OR blocked_until < ?)").run(Date.now() - 24 * 60 * 60 * 1000, Date.now());
database.sqlite.prepare("DELETE FROM sessions WHERE idle_expires_at <= ? OR absolute_expires_at <= ?").run(Date.now(), Date.now());
void processFileDeletions(database.sqlite, config);
void expireExports(database.sqlite, config);
void cleanupOrphanedExports(database.sqlite, config);
}, 60_000);
const shutdown = async () => {
clearInterval(janitor);
await app.close().catch(() => undefined);
database.sqlite.close();
releaseLock();
};
process.once("SIGINT", () => void shutdown().finally(() => process.exit(0)));
process.once("SIGTERM", () => void shutdown().finally(() => process.exit(0)));
try {
await app.listen({ host: config.host, port: config.port });
} catch (error) {
clearInterval(janitor);
await app.close().catch(() => undefined);
throw error;
}
app.log.info(`TallyNote running at ${config.publicOrigin}`);
}
start().catch((error) => {
console.error(error);
database.sqlite.close();
releaseLock();
process.exitCode = 1;
});
+52
View File
@@ -0,0 +1,52 @@
import argon2 from "argon2";
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
const ARGON_OPTIONS = {
type: argon2.argon2id,
memoryCost: 65_536,
timeCost: 3,
parallelism: 1,
hashLength: 32,
} as const;
export function normalizeUsername(username: string): string {
return username.normalize("NFKC").trim().toLocaleLowerCase("und");
}
export function validateNewPassword(password: string): string | null {
const length = [...password].length;
if (length < 12 || length > 128 || Buffer.byteLength(password, "utf8") > 512) {
return "密码长度需要为 12–128 个字符";
}
return null;
}
export function hashPassword(password: string): Promise<string> {
return argon2.hash(password, ARGON_OPTIONS);
}
export async function verifyPassword(hash: string, password: string): Promise<boolean> {
try {
return await argon2.verify(hash, password);
} catch {
return false;
}
}
export function randomToken(bytes = 32): string {
return randomBytes(bytes).toString("base64url");
}
export function sha256(value: string | Buffer): string {
return createHash("sha256").update(value).digest("hex");
}
export function constantTimeEqual(left: string, right: string): boolean {
const leftBuffer = Buffer.from(left);
const rightBuffer = Buffer.from(right);
return leftBuffer.length === rightBuffer.length && timingSafeEqual(leftBuffer, rightBuffer);
}
export function temporaryPassword(): string {
return `${randomToken(15)}A7!`;
}
+327
View File
@@ -0,0 +1,327 @@
import { chmod, mkdir, rename, writeFile } from "node:fs/promises";
import path from "node:path";
import { createPublicKey, randomUUID, verify as verifySignature } from "node:crypto";
import type Database from "better-sqlite3";
import { AppError } from "./errors.js";
import type { AppConfig } from "./config.js";
import {
detectPlatform,
fetchReleaseBytes,
fetchReleaseMetadata,
fetchReleaseText,
isNewerVersion,
parseSemver,
sanitizeAssetName,
selectReleaseAsset,
validateHttpsUrl,
type ReleaseAsset,
type ReleaseMetadata,
} from "./update.js";
import type { UpdateJobStatus } from "../shared/contracts.js";
export const UPDATE_CACHE_KEY = "update.release.v1";
export const ACTIVE_UPDATE_STATUSES: readonly UpdateJobStatus[] = [
"queued",
"downloading",
"verifying",
"staged",
"backing_up",
"applying",
];
export type CachedRelease = {
checkedAt: number;
metadataUrl: string;
version: string;
tagName?: string;
publishedAt?: string;
platform: string;
signatureVerified?: boolean;
asset?: {
name: string;
url: string;
size?: number;
sha256?: string;
};
};
export type UpdateCheckResult = {
configured: boolean;
currentVersion: string;
platform: ReturnType<typeof detectPlatform>;
checkedAt: number;
latest: {
version: string;
tagName?: string;
publishedAt?: string;
compatible: boolean;
integrityReady: boolean;
signatureReady: boolean;
isNewer: boolean;
assetName?: string;
assetSize?: number;
} | null;
};
export type UpdateRequest = {
jobId: string;
version: string;
metadataUrl: string;
assetUrl: string;
assetName: string;
expectedSha256: string;
requestedAt: number;
// These paths are derived from the server config and are included so the
// privileged runner does not need to infer a working directory from input.
currentLink: string;
releasesDir: string;
dataDir: string;
};
function setting(database: Database.Database, key: string): string | undefined {
return (database.prepare("SELECT value FROM system_settings WHERE key=?").get(key) as { value: string } | undefined)?.value;
}
function saveSetting(database: Database.Database, key: string, value: unknown): void {
database.prepare(`
INSERT INTO system_settings(key, value, updated_at) VALUES (?, ?, ?)
ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at
`).run(key, JSON.stringify(value), Date.now());
}
function sha256FromSums(text: string, assetName: string): string | undefined {
const wanted = sanitizeAssetName(assetName);
for (const line of text.split(/\r?\n/)) {
const match = /^\s*([a-f0-9]{64})\s+[* ]?(.+?)\s*$/.exec(line);
if (!match) continue;
const name = match[2]!.replaceAll("\\", "/").split("/").pop() ?? "";
if (name === wanted) return match[1]!.toLowerCase();
}
return undefined;
}
/** Verify an Ed25519 detached signature over the exact SHA256SUMS bytes.
* The signature sidecar is accepted as either base64 or a 64-byte hex value.
*/
export function verifyReleaseSignature(payload: string, encodedSignature: string | Uint8Array, publicKey: string): boolean {
try {
const signature = (() => {
if (encodedSignature instanceof Uint8Array) {
const bytes = Buffer.from(encodedSignature);
if (bytes.length === 64) return bytes;
encodedSignature = bytes.toString("utf8");
}
const compact = encodedSignature.trim().replace(/\s+/g, "");
return /^[a-f0-9]{128}$/i.test(compact)
? Buffer.from(compact, "hex")
: Buffer.from(compact, "base64");
})();
if (signature.length !== 64) return false;
return verifySignature(null, Buffer.from(payload, "utf8"), createPublicKey(publicKey), signature);
} catch {
return false;
}
}
function signatureAssetFor(metadata: ReleaseMetadata, sums: ReleaseAsset): ReleaseAsset | undefined {
const sumsName = sums.name.toLowerCase();
return metadata.assets.find((candidate) => {
const name = candidate.name.toLowerCase();
return name === `${sumsName}.sig` || name === `${sumsName}.asc`;
});
}
export async function attachSidecarHash(
metadata: ReleaseMetadata,
asset: ReleaseAsset,
options: { allowedHosts: readonly string[]; baseUrl: string; maxBytes: number; publicKey?: string | undefined; requireSignature?: boolean | undefined },
): Promise<{ asset: ReleaseAsset; signatureVerified: boolean }> {
let signatureVerified = false;
if (asset.sha256 && (!options.publicKey || !options.requireSignature)) return { asset, signatureVerified };
const sums = metadata.assets.find((candidate) => /^(?:sha256sums?|checksums?)(?:\.txt)?$/i.test(path.basename(candidate.name)));
if (!sums) return { asset, signatureVerified };
try {
const content = await fetchReleaseText(sums.url, { allowedHosts: options.allowedHosts, baseUrl: options.baseUrl, maxBytes: Math.min(options.maxBytes, 2 * 1024 * 1024) });
const sha256 = sha256FromSums(content, asset.name);
if (options.publicKey) {
const signatureAsset = signatureAssetFor(metadata, sums);
if (signatureAsset) {
const signature = await fetchReleaseBytes(signatureAsset.url, { allowedHosts: options.allowedHosts, baseUrl: options.baseUrl, maxBytes: 64 * 1024 });
signatureVerified = verifyReleaseSignature(content, signature, options.publicKey);
}
}
return { asset: sha256 ? { ...asset, sha256 } : asset, signatureVerified };
} catch {
// A missing/unreadable sidecar makes the update unavailable; it must not
// turn into an unverified download.
return { asset, signatureVerified };
}
}
function policy(config: AppConfig) {
return {
allowedHosts: config.updateAllowedHosts,
baseUrl: config.updateMetadataUrl,
maxRedirects: 3,
} as const;
}
function safeMetadataUrl(config: AppConfig): string {
try {
return validateHttpsUrl(config.updateMetadataUrl, policy(config)).toString();
} catch {
throw new AppError(503, "UPDATE_NOT_CONFIGURED", "更新源地址配置无效");
}
}
export async function checkForUpdate(database: Database.Database, config: AppConfig): Promise<UpdateCheckResult> {
const platform = detectPlatform();
const checkedAt = Date.now();
if (config.updateStrategy === "disabled" || !config.updateMetadataUrl) {
return { configured: false, currentVersion: config.appVersion, platform, checkedAt, latest: null };
}
const metadataUrl = safeMetadataUrl(config);
let metadata: ReleaseMetadata;
try {
metadata = await fetchReleaseMetadata(metadataUrl, policy(config));
} catch {
throw new AppError(502, "UPDATE_CHECK_FAILED", "暂时无法获取最新版本,请稍后重试");
}
let asset = selectReleaseAsset(metadata, platform);
let signatureVerified = false;
if (asset) {
const integrity = await attachSidecarHash(metadata, asset, {
allowedHosts: config.updateAllowedHosts,
baseUrl: metadataUrl,
maxBytes: config.updateMaxBytes,
publicKey: config.updatePublicKey,
requireSignature: config.updateRequireSignature,
});
asset = integrity.asset;
signatureVerified = integrity.signatureVerified;
}
const safeVersion = metadata.version;
const cached: CachedRelease = {
checkedAt,
metadataUrl,
version: safeVersion,
...(metadata.tagName ? { tagName: metadata.tagName } : {}),
...(metadata.publishedAt ? { publishedAt: metadata.publishedAt } : {}),
platform: platform.target,
signatureVerified,
...(asset ? {
asset: {
name: sanitizeAssetName(asset.name),
url: validateHttpsUrl(asset.url, policy(config)).toString(),
...(asset.size === undefined ? {} : { size: asset.size }),
...(asset.sha256 ? { sha256: asset.sha256 } : {}),
},
} : {}),
};
saveSetting(database, UPDATE_CACHE_KEY, cached);
return {
configured: true,
currentVersion: config.appVersion,
platform,
checkedAt,
latest: {
version: safeVersion,
...(metadata.tagName ? { tagName: metadata.tagName } : {}),
...(metadata.publishedAt ? { publishedAt: metadata.publishedAt } : {}),
compatible: Boolean(asset),
integrityReady: Boolean(asset?.sha256 && (!config.updateRequireSignature || signatureVerified)),
signatureReady: !config.updateRequireSignature || signatureVerified,
isNewer: isNewerVersion(config.appVersion, safeVersion),
...(asset ? { assetName: asset.name, ...(asset.size === undefined ? {} : { assetSize: asset.size }) } : {}),
},
};
}
export function readCachedRelease(database: Database.Database, config: AppConfig): CachedRelease | null {
const raw = setting(database, UPDATE_CACHE_KEY);
if (!raw) return null;
try {
const value = JSON.parse(raw) as CachedRelease;
if (!value || typeof value !== "object" || typeof value.version !== "string" || typeof value.metadataUrl !== "string" || typeof value.platform !== "string") return null;
parseSemver(value.version);
const metadataUrl = validateHttpsUrl(value.metadataUrl, policy(config)).toString();
if (value.signatureVerified !== undefined && typeof value.signatureVerified !== "boolean") return null;
if (value.asset) {
if (typeof value.asset.name !== "string" || typeof value.asset.url !== "string") return null;
sanitizeAssetName(value.asset.name);
validateHttpsUrl(value.asset.url, policy(config));
if (value.asset.sha256 !== undefined && !/^[a-f0-9]{64}$/i.test(value.asset.sha256)) return null;
}
return { ...value, metadataUrl };
} catch {
return null;
}
}
export function publicCheckFromCache(database: Database.Database, config: AppConfig): UpdateCheckResult {
const platform = detectPlatform();
const cached = readCachedRelease(database, config);
if (!cached || cached.platform !== platform.target) {
const compatible = Boolean(cached && cached.platform === platform.target && cached.asset);
return { configured: config.updateStrategy !== "disabled", currentVersion: config.appVersion, platform, checkedAt: cached?.checkedAt ?? 0, latest: cached ? {
version: cached.version,
...(cached.tagName ? { tagName: cached.tagName } : {}),
...(cached.publishedAt ? { publishedAt: cached.publishedAt } : {}),
compatible,
integrityReady: compatible && Boolean(cached.asset?.sha256) && (!config.updateRequireSignature || cached.signatureVerified === true),
signatureReady: !config.updateRequireSignature || cached.signatureVerified === true,
isNewer: isNewerVersion(config.appVersion, cached.version),
...(cached.asset ? { assetName: cached.asset.name, ...(cached.asset.size === undefined ? {} : { assetSize: cached.asset.size }) } : {}),
} : null };
}
return {
configured: config.updateStrategy !== "disabled",
currentVersion: config.appVersion,
platform,
checkedAt: cached.checkedAt,
latest: {
version: cached.version,
...(cached.tagName ? { tagName: cached.tagName } : {}),
...(cached.publishedAt ? { publishedAt: cached.publishedAt } : {}),
compatible: Boolean(cached.asset),
integrityReady: Boolean(cached.asset?.sha256) && (!config.updateRequireSignature || cached.signatureVerified === true),
signatureReady: !config.updateRequireSignature || cached.signatureVerified === true,
isNewer: isNewerVersion(config.appVersion, cached.version),
...(cached.asset ? { assetName: cached.asset.name, ...(cached.asset.size === undefined ? {} : { assetSize: cached.asset.size }) } : {}),
},
};
}
export async function writeUpdateRequest(config: AppConfig, request: UpdateRequest): Promise<void> {
const parent = path.dirname(config.updateRequestPath);
await mkdir(parent, { recursive: true, mode: 0o700 });
const temporary = `${config.updateRequestPath}.tmp-${randomUUID()}`;
await writeFile(temporary, JSON.stringify(request), { encoding: "utf8", mode: 0o600, flag: "wx" });
try {
await chmod(temporary, 0o600);
await rename(temporary, config.updateRequestPath);
} catch (error) {
await import("node:fs/promises").then(({ rm }) => rm(temporary, { force: true })).catch(() => undefined);
throw error;
}
}
export function publicUpdateJob(row: Record<string, unknown> | undefined): Record<string, unknown> | null {
if (!row) return null;
const hasError = typeof row.errorMessage === "string" && row.errorMessage.length > 0;
return {
id: row.id,
status: row.status,
version: row.version,
platform: row.platform,
assetName: row.assetName ?? null,
sizeBytes: row.sizeBytes ?? null,
// Do not expose filesystem paths, command output, or upstream response
// text through the authenticated status endpoint. Detailed diagnostics
// remain in the server journal for operators.
errorMessage: hasError ? "更新失败,请查看服务器日志或重试" : null,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
completedAt: row.completedAt ?? null,
};
}
+992
View File
@@ -0,0 +1,992 @@
import { createHash, randomUUID } from "node:crypto";
import { createReadStream, createWriteStream } from "node:fs";
import { chmod, mkdir, open, readdir, rename, lstat, readlink, symlink, rm } from "node:fs/promises";
import path from "node:path";
import { Readable, Transform } from "node:stream";
import { finished, pipeline } from "node:stream/promises";
import { createGzip, createGunzip } from "node:zlib";
import yauzl from "yauzl";
/** A small semver implementation so update checks do not depend on a runtime package. */
export type SemVer = {
major: number;
minor: number;
patch: number;
prerelease: string[];
build: string[];
};
export type UpdatePlatform = {
os: string;
arch: string;
target: string;
aliases: string[];
platform: string;
architecture: string;
};
export type ReleaseAsset = {
name: string;
url: string;
sha256?: string;
size?: number;
};
export type ReleaseMetadata = {
version: string;
tagName?: string;
publishedAt?: string;
assets: ReleaseAsset[];
};
export type UrlPolicy = {
/** Host names or HTTPS URLs which are allowed for requests. */
allowedHosts?: readonly string[] | undefined;
/** When allowedHosts is omitted, requests are constrained to this URL's host. */
baseUrl?: string | URL | undefined;
maxRedirects?: number | undefined;
};
function invalidVersion(): never {
throw new Error("更新版本号无效");
}
export function parseSemver(value: string): SemVer {
const input = value.trim().replace(/^v/i, "");
const match = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/.exec(input);
if (!match) return invalidVersion();
const prerelease = match[4] ? match[4].split(".") : [];
const build = match[5] ? match[5].split(".") : [];
if (prerelease.some((part) => /^0\d+$/.test(part))) return invalidVersion();
const major = Number(match[1]);
const minor = Number(match[2]);
const patch = Number(match[3]);
if (![major, minor, patch].every((part) => Number.isSafeInteger(part))) return invalidVersion();
return { major, minor, patch, prerelease, build };
}
export function compareSemver(left: string | SemVer, right: string | SemVer): number {
const a = typeof left === "string" ? parseSemver(left) : left;
const b = typeof right === "string" ? parseSemver(right) : right;
for (const key of ["major", "minor", "patch"] as const) {
if (a[key] !== b[key]) return a[key] > b[key] ? 1 : -1;
}
if (a.prerelease.length === 0 && b.prerelease.length > 0) return 1;
if (a.prerelease.length > 0 && b.prerelease.length === 0) return -1;
for (let i = 0; i < Math.max(a.prerelease.length, b.prerelease.length); i += 1) {
const x = a.prerelease[i];
const y = b.prerelease[i];
if (x === undefined) return -1;
if (y === undefined) return 1;
if (x === y) continue;
const xn = /^\d+$/.test(x);
const yn = /^\d+$/.test(y);
if (xn && yn) {
if (x.length !== y.length) return x.length > y.length ? 1 : -1;
return x > y ? 1 : -1;
}
if (xn !== yn) return xn ? -1 : 1;
return x > y ? 1 : -1;
}
return 0;
}
export function isNewerVersion(current: string, candidate: string): boolean {
return compareSemver(candidate, current) > 0;
}
export function detectPlatform(platform = process.platform, architecture = process.arch): UpdatePlatform {
const os = platform === "win32" ? "windows" : platform;
const arch = ({ amd64: "x64", x86_64: "x64", aarch64: "arm64" } as Record<string, string>)[architecture] ?? architecture;
const target = `${os}-${arch}`;
return {
os,
arch,
target,
aliases: [target, `${os}_${arch}`, `${platform}-${architecture}`, `${platform}_${architecture}`, os, platform],
platform: os,
architecture: arch,
};
}
function hostFromEntry(entry: string): string {
try {
const parsed = new URL(entry.includes("://") ? entry : `https://${entry}`);
if (entry.includes("://") && parsed.protocol !== "https:") throw new Error("scheme");
return parsed.hostname.toLowerCase();
} catch {
throw new Error("更新地址白名单无效");
}
}
export function validateHttpsUrl(value: string | URL, policy: UrlPolicy = {}): URL {
let parsed: URL;
try {
parsed = new URL(value.toString());
} catch {
throw new Error("更新地址无效");
}
if (parsed.protocol !== "https:") throw new Error("更新地址必须使用 HTTPS");
if (parsed.username || parsed.password) throw new Error("更新地址不允许携带凭据");
const configured = policy.allowedHosts?.map(hostFromEntry);
const allowed = configured && configured.length > 0
? configured
: policy.baseUrl
? [hostFromEntry(policy.baseUrl.toString())]
: [parsed.hostname.toLowerCase()];
if (!allowed.includes(parsed.hostname.toLowerCase())) throw new Error("更新地址主机不在允许列表中");
return parsed;
}
function metadataError(): Error {
return new Error("更新发布信息不可用");
}
const DEFAULT_METADATA_MAX_BYTES = 2 * 1024 * 1024;
/** Read a fetch body without ever buffering more than the caller's bound. */
async function readBoundedResponse(response: Response, maxBytes: number, tooLargeMessage: string): Promise<Buffer> {
if (!Number.isSafeInteger(maxBytes) || maxBytes <= 0) throw new Error("响应大小限制无效");
const contentLength = response.headers.get("content-length");
if (contentLength !== null) {
const declared = Number(contentLength);
if (Number.isFinite(declared) && declared > maxBytes) throw new Error(tooLargeMessage);
}
if (!response.body) return Buffer.alloc(0);
const reader = response.body.getReader();
const chunks: Buffer[] = [];
let total = 0;
try {
for (;;) {
const result = await reader.read();
if (result.done) break;
const chunk = Buffer.from(result.value);
if (chunk.length > maxBytes - total) {
await reader.cancel().catch(() => undefined);
throw new Error(tooLargeMessage);
}
total += chunk.length;
chunks.push(chunk);
}
} finally {
reader.releaseLock();
}
return Buffer.concat(chunks, total);
}
export async function fetchReleaseMetadata(
metadataUrl: string | URL,
options: UrlPolicy & { fetchImpl?: typeof fetch | undefined; maxBytes?: number | undefined } = {},
): Promise<ReleaseMetadata> {
const fetchImpl = options.fetchImpl ?? fetch;
let current = validateHttpsUrl(metadataUrl, options);
const maxRedirects = options.maxRedirects ?? 3;
let response: Response;
for (let redirects = 0; ; redirects += 1) {
try {
response = await fetchImpl(current, { method: "GET", redirect: "manual", headers: { accept: "application/json" } });
} catch {
throw metadataError();
}
if (response.status < 300 || response.status >= 400) break;
if (redirects >= maxRedirects) throw metadataError();
const location = response.headers.get("location");
if (!location) throw metadataError();
current = validateHttpsUrl(new URL(location, current), options.baseUrl ? options : { ...options, baseUrl: current });
}
if (response.status < 200 || response.status >= 300) throw metadataError();
let payload: unknown;
try {
const maxBytes = Math.min(options.maxBytes ?? DEFAULT_METADATA_MAX_BYTES, DEFAULT_METADATA_MAX_BYTES);
const body = await readBoundedResponse(response, maxBytes, "更新发布信息过大");
payload = JSON.parse(body.toString("utf8"));
} catch { throw metadataError(); }
if (!payload || typeof payload !== "object") throw metadataError();
const item = payload as Record<string, unknown>;
const rawVersion = typeof item.version === "string" ? item.version : typeof item.tag_name === "string" ? item.tag_name : typeof item.tagName === "string" ? item.tagName : undefined;
if (!rawVersion) throw metadataError();
const version = parseSemver(rawVersion);
if (typeof item.tag_name === "string") {
try {
if (compareSemver(version, item.tag_name) !== 0) throw metadataError();
} catch {
throw metadataError();
}
}
const assetsRaw = Array.isArray(item.assets) ? item.assets : [];
const assets: ReleaseAsset[] = [];
for (const raw of assetsRaw) {
if (!raw || typeof raw !== "object") continue;
const asset = raw as Record<string, unknown>;
const name = typeof asset.name === "string" ? asset.name : undefined;
const url = typeof asset.url === "string" ? asset.url : typeof asset.browser_download_url === "string" ? asset.browser_download_url : undefined;
if (!name || !url) continue;
let sha256: string | undefined;
const digest = typeof asset.sha256 === "string" ? asset.sha256 : typeof asset.digest === "string" ? asset.digest : undefined;
if (digest) {
const candidate = digest.replace(/^sha256:/i, "").toLowerCase();
if (/^[a-f0-9]{64}$/.test(candidate)) sha256 = candidate;
}
assets.push({ name, url: validateHttpsUrl(url, { ...options, baseUrl: current }).toString(), ...(sha256 ? { sha256 } : {}), ...(typeof asset.size === "number" && Number.isSafeInteger(asset.size) && asset.size >= 0 ? { size: asset.size } : {}) });
}
return {
version: `${version.major}.${version.minor}.${version.patch}${version.prerelease.length ? `-${version.prerelease.join(".")}` : ""}${version.build.length ? `+${version.build.join(".")}` : ""}`,
...(typeof item.tag_name === "string" ? { tagName: item.tag_name } : {}),
...(typeof item.published_at === "string" ? { publishedAt: item.published_at } : {}),
assets,
};
}
/** Fetch a small text sidecar (for example SHA256SUMS) with the same
* redirect, HTTPS and host policy used for release metadata. */
export async function fetchReleaseText(
textUrl: string | URL,
options: UrlPolicy & { fetchImpl?: typeof fetch | undefined; maxBytes?: number | undefined } = {},
): Promise<string> {
const fetchImpl = options.fetchImpl ?? fetch;
let current = validateHttpsUrl(textUrl, options);
const redirectPolicy: UrlPolicy & { fetchImpl?: typeof fetch | undefined; maxBytes?: number | undefined } = options.allowedHosts?.length || options.baseUrl
? options
: { ...options, baseUrl: current };
const maxRedirects = options.maxRedirects ?? 3;
let response: Response;
for (let redirects = 0; ; redirects += 1) {
try {
response = await fetchImpl(current, { method: "GET", redirect: "manual" });
} catch {
throw new Error("更新校验文件下载失败");
}
if (response.status < 300 || response.status >= 400) break;
if (redirects >= maxRedirects) throw new Error("更新校验文件下载失败");
const location = response.headers.get("location");
if (!location) throw new Error("更新校验文件下载失败");
current = validateHttpsUrl(new URL(location, current), redirectPolicy);
}
if (response.status < 200 || response.status >= 300) throw new Error("更新校验文件下载失败");
const declared = Number(response.headers.get("content-length") ?? 0);
const maxBytes = options.maxBytes ?? 1024 * 1024;
if (declared > maxBytes) throw new Error("更新校验文件过大");
try {
return (await readBoundedResponse(response, maxBytes, "更新校验文件过大")).toString("utf8");
} catch (error) {
if (error instanceof Error && error.message === "更新校验文件过大") throw error;
throw new Error("更新校验文件下载失败");
}
}
/** Fetch a bounded binary sidecar (for example an Ed25519 detached
* signature). Text decoding would corrupt arbitrary signature bytes, so keep
* this separate from fetchReleaseText. */
export async function fetchReleaseBytes(
bytesUrl: string | URL,
options: UrlPolicy & { fetchImpl?: typeof fetch | undefined; maxBytes?: number | undefined } = {},
): Promise<Buffer> {
const fetchImpl = options.fetchImpl ?? fetch;
let current = validateHttpsUrl(bytesUrl, options);
const redirectPolicy: UrlPolicy & { fetchImpl?: typeof fetch | undefined; maxBytes?: number | undefined } = options.allowedHosts?.length || options.baseUrl
? options
: { ...options, baseUrl: current };
const maxRedirects = options.maxRedirects ?? 3;
let response: Response;
for (let redirects = 0; ; redirects += 1) {
try {
response = await fetchImpl(current, { method: "GET", redirect: "manual" });
} catch {
throw new Error("更新签名下载失败");
}
if (response.status < 300 || response.status >= 400) break;
if (redirects >= maxRedirects) throw new Error("更新签名下载失败");
const location = response.headers.get("location");
if (!location) throw new Error("更新签名下载失败");
current = validateHttpsUrl(new URL(location, current), redirectPolicy);
}
if (response.status < 200 || response.status >= 300) throw new Error("更新签名下载失败");
const declared = Number(response.headers.get("content-length") ?? 0);
const maxBytes = options.maxBytes ?? 64 * 1024;
if (declared > maxBytes) throw new Error("更新签名文件过大");
try {
return await readBoundedResponse(response, maxBytes, "更新签名文件过大");
} catch (error) {
if (error instanceof Error && error.message === "更新签名文件过大") throw error;
throw new Error("更新签名下载失败");
}
}
export function selectReleaseAsset(release: ReleaseMetadata, platform = detectPlatform()): ReleaseAsset | undefined {
const platformCandidates = release.assets.filter((asset) => {
const name = asset.name.toLowerCase();
return platform.aliases.filter((alias) => alias.toLowerCase().includes(platform.arch.toLowerCase())).some((alias) => name.includes(alias.toLowerCase()));
});
const candidates = platformCandidates.length > 0
? platformCandidates
: (() => {
// A generic single-platform archive is useful for small private feeds,
// but never let an explicitly named foreign architecture through.
if (release.assets.length !== 1) return [];
const name = release.assets[0]!.name.toLowerCase();
const knownArchitecture = /(?:^|[-_.])(x64|amd64|x86_64|arm64|aarch64|armv7|armhf|i386|i686|ia32)(?:[-_.]|$)/.test(name);
return name.includes(platform.os.toLowerCase()) && !knownArchitecture ? [release.assets[0]!] : [];
})();
candidates.sort((a, b) => {
const target = platform.target.toLowerCase();
return Number(b.name.toLowerCase().includes(target)) - Number(a.name.toLowerCase().includes(target));
});
return candidates[0];
}
export function sanitizeAssetName(value: string): string {
const normalized = value.normalize("NFKC").replaceAll("\\", "/");
const name = path.posix.basename(normalized);
if (!name || name === "." || name === ".." || name !== normalized || name.includes("\0") || name.length > 200 || /[\u0000-\u001f\u007f]/.test(name)) throw new Error("更新文件名无效");
return name;
}
export async function sha256File(filePath: string): Promise<string> {
const hash = createHash("sha256");
await pipeline(createReadStream(filePath), new Transform({ transform(chunk, _encoding, callback) { hash.update(chunk); callback(null, chunk); } }), new Transform({ transform(_chunk, _encoding, callback) { callback(); } }));
return hash.digest("hex");
}
export async function verifySha256(filePath: string, expected: string): Promise<boolean> {
const normalized = expected.trim().toLowerCase();
if (!/^[a-f0-9]{64}$/.test(normalized)) throw new Error("SHA-256 校验值无效");
return (await sha256File(filePath)) === normalized;
}
export async function downloadReleaseAsset(
url: string | URL,
destination: string,
options: UrlPolicy & { fetchImpl?: typeof fetch | undefined; maxBytes?: number | undefined } = {},
): Promise<{ size: number; sha256: string }> {
const fetchImpl = options.fetchImpl ?? fetch;
let current = validateHttpsUrl(url, options);
const redirectPolicy: UrlPolicy & { fetchImpl?: typeof fetch | undefined; maxBytes?: number | undefined } = options.allowedHosts?.length || options.baseUrl
? options
: { ...options, baseUrl: current };
const maxRedirects = options.maxRedirects ?? 3;
let response: Response;
for (let redirects = 0; ; redirects += 1) {
try {
response = await fetchImpl(current, { method: "GET", redirect: "manual" });
} catch {
throw new Error("更新文件下载失败");
}
if (response.status < 300 || response.status >= 400) break;
if (redirects >= maxRedirects) throw new Error("更新文件下载失败");
const location = response.headers.get("location");
if (!location) throw new Error("更新文件下载失败");
current = validateHttpsUrl(new URL(location, current), redirectPolicy);
}
if (response.status < 200 || response.status >= 300 || !response.body) throw new Error("更新文件下载失败");
const declared = Number(response.headers.get("content-length") ?? 0);
const maxBytes = options.maxBytes ?? 512 * 1024 * 1024;
if (declared > maxBytes) throw new Error("更新文件超过大小限制");
await mkdir(path.dirname(destination), { recursive: true, mode: 0o700 });
const temporary = `${destination}.part-${randomUUID()}`;
let size = 0;
const hash = createHash("sha256");
const meter = new Transform({ transform(chunk: Buffer, _encoding, callback) {
size += chunk.length;
if (size > maxBytes) return callback(new Error("更新文件超过大小限制"));
hash.update(chunk);
callback(null, chunk);
} });
try {
await pipeline(Readable.fromWeb(response.body as import("node:stream/web").ReadableStream), meter, createWriteStream(temporary, { flags: "wx", mode: 0o600 }));
const fd = await open(temporary, "r");
await fd.sync();
await fd.close();
await rename(temporary, destination);
} catch (error) {
await import("node:fs/promises").then(({ rm }) => rm(temporary, { force: true })).catch(() => undefined);
throw error instanceof Error && error.message.startsWith("更新文件") ? error : new Error("更新文件下载失败");
}
return { size, sha256: hash.digest("hex") };
}
function tarField(value: string, length: number): Buffer {
const output = Buffer.alloc(length, 0);
Buffer.from(value, "utf8").copy(output, 0, 0, length);
return output;
}
function tarOctal(value: number, length: number): Buffer {
const text = value.toString(8).padStart(length - 1, "0").slice(-(length - 1));
return Buffer.from(`${text}\0`, "ascii");
}
function tarHeader(name: string, size: number, mode: number, directory: boolean): Buffer {
let nameField = name;
let prefixField = "";
if (Buffer.byteLength(name) > 100) {
const slash = name.lastIndexOf("/");
if (slash <= 0 || Buffer.byteLength(name.slice(0, slash)) > 155 || Buffer.byteLength(name.slice(slash + 1)) > 100) throw new Error("归档路径过长");
prefixField = name.slice(0, slash);
nameField = name.slice(slash + 1);
}
const header = Buffer.alloc(512, 0);
tarField(nameField, 100).copy(header, 0);
tarOctal(mode & 0o777, 8).copy(header, 100);
tarOctal(0, 8).copy(header, 108);
tarOctal(0, 8).copy(header, 116);
tarOctal(size, 12).copy(header, 124);
tarOctal(Math.floor(Date.now() / 1000), 12).copy(header, 136);
Buffer.from(" ", "ascii").copy(header, 148);
header[156] = directory ? 0x35 : 0x30;
tarField("ustar\0", 6).copy(header, 257);
tarField("00", 2).copy(header, 263);
tarField(prefixField, 155).copy(header, 345);
let checksum = 0;
for (const byte of header) checksum += byte;
tarOctal(checksum, 8).copy(header, 148);
return header;
}
export type SafeArchiveOptions = {
maxEntries?: number;
maxBytes?: number;
};
async function writeArchiveChunk(stream: Transform, chunk: Buffer): Promise<void> {
if (stream.write(chunk)) return;
await new Promise<void>((resolve, reject) => {
const onDrain = () => { cleanup(); resolve(); };
const onError = (error: Error) => { cleanup(); reject(error); };
const cleanup = () => {
stream.off("drain", onDrain);
stream.off("error", onError);
};
stream.once("drain", onDrain);
stream.once("error", onError);
});
}
export async function createSafeArchive(sourceDir: string, archivePath: string, options: SafeArchiveOptions = {}): Promise<void> {
const root = path.resolve(sourceDir);
const archiveResolved = path.resolve(archivePath);
if (archiveResolved === root || archiveResolved.startsWith(`${root}${path.sep}`)) throw new Error("归档目标不能位于源目录内");
const maxEntries = options.maxEntries ?? 100_000;
const maxBytes = options.maxBytes ?? 2 * 1024 * 1024 * 1024;
if (!Number.isSafeInteger(maxEntries) || maxEntries <= 0 || !Number.isSafeInteger(maxBytes) || maxBytes <= 0) throw new Error("归档限制无效");
let entries = 0;
let total = 0;
const archiveParent = path.resolve(path.dirname(archiveResolved));
const archiveInfo = await lstat(archiveResolved).catch(() => null);
if (archiveInfo?.isSymbolicLink() || (archiveInfo && !archiveInfo.isFile())) throw new Error("归档目标文件无效");
await mkdir(archiveParent, { recursive: true, mode: 0o700 });
await assertPrivateDirectory(archiveParent);
const temporary = `${archiveResolved}.part-${randomUUID()}`;
let gzip: Transform | undefined;
let output: ReturnType<typeof createWriteStream> | undefined;
let renamed = false;
const walk = async (directory: string, prefix: string): Promise<void> => {
const directoryEntries = await readdir(directory, { withFileTypes: true });
directoryEntries.sort((a, b) => a.name.localeCompare(b.name));
for (const entry of directoryEntries) {
const target = path.join(directory, entry.name);
const relative = prefix ? `${prefix}/${entry.name}` : entry.name;
const info = await lstat(target);
if (info.isSymbolicLink()) throw new Error("归档不允许符号链接");
entries += 1;
if (entries > maxEntries) throw new Error("归档条目过多");
if (info.isDirectory()) {
await assertPrivateDirectory(target);
await writeArchiveChunk(gzip!, tarHeader(`${relative}/`, 0, 0o700, true));
await walk(target, relative);
} else if (info.isFile()) {
const handle = await open(target, "r");
try {
const current = await handle.stat();
if (!current.isFile() || !Number.isSafeInteger(current.size) || current.size < 0) throw new Error("归档源文件无效");
if (current.size > maxBytes - total) throw new Error("归档超过大小限制");
total += current.size;
await writeArchiveChunk(gzip!, tarHeader(relative, current.size, 0o600, false));
let position = 0;
while (position < current.size) {
const chunk = Buffer.allocUnsafe(Math.min(64 * 1024, current.size - position));
const result = await handle.read(chunk, 0, chunk.length, position);
if (result.bytesRead <= 0) throw new Error("归档源文件读取失败");
position += result.bytesRead;
await writeArchiveChunk(gzip!, chunk.subarray(0, result.bytesRead));
}
const remainder = current.size % 512;
if (remainder) await writeArchiveChunk(gzip!, Buffer.alloc(512 - remainder));
} finally {
await handle.close().catch(() => undefined);
}
} else {
throw new Error("归档包含不受支持的文件类型");
}
}
};
const info = await lstat(root);
if (!info.isDirectory()) throw new Error("归档源目录无效");
await assertPrivateDirectory(root);
try {
output = createWriteStream(temporary, { flags: "wx", mode: 0o600 });
gzip = createGzip({ level: 6 });
gzip.pipe(output);
await walk(root, "");
await writeArchiveChunk(gzip, Buffer.alloc(1024));
gzip.end();
await finished(output);
const handle = await open(temporary, "r");
await handle.sync();
await handle.close();
await chmod(temporary, 0o600);
await rename(temporary, archiveResolved);
renamed = true;
} finally {
if (gzip && !gzip.destroyed) gzip.destroy();
if (output && !output.destroyed) output.destroy();
if (!renamed) await rm(temporary, { force: true }).catch(() => undefined);
}
}
function safeArchiveEntry(entryName: string): string {
const name = entryName.replaceAll("\\", "/");
if (!name || name.startsWith("/") || /^[A-Za-z]:\//.test(name) || name.includes("\0")) throw new Error("归档包含不安全路径");
const normalized = path.posix.normalize(name);
// GNU/BSD tar commonly emits a harmless `./` root directory entry.
if (normalized === "." || normalized === "./") return "";
if (normalized === ".." || normalized.startsWith("../") || normalized.includes("/../")) throw new Error("归档包含不安全路径");
return normalized.replace(/\/$/, "");
}
async function assertPrivateDirectory(directory: string): Promise<void> {
const info = await lstat(directory).catch(() => null);
if (!info || info.isSymbolicLink() || !info.isDirectory()) throw new Error("归档目标目录无效");
// A sticky world-writable parent such as /tmp is acceptable for a freshly
// created mkdtemp workspace. Non-sticky group/other writable directories
// are not: a local user could replace a path between validation and use.
if ((info.mode & 0o022) !== 0 && (info.mode & 0o1000) === 0) throw new Error("归档目标目录权限过宽");
}
async function ensureArchiveParent(root: string, target: string): Promise<void> {
await assertPrivateDirectory(root);
const relative = path.relative(root, path.dirname(target));
let current = root;
for (const component of relative ? relative.split(path.sep) : []) {
current = path.join(current, component);
const info = await lstat(current).catch(() => null);
if (info?.isSymbolicLink() || (info && !info.isDirectory())) throw new Error("归档目标目录无效");
if (!info) {
await mkdir(current, { mode: 0o700 });
await chmod(current, 0o700);
} else {
await assertPrivateDirectory(current);
}
}
}
async function extractSafeZip(archivePath: string, destinationDir: string, options: { maxEntries?: number; maxBytes?: number }): Promise<void> {
const maxEntries = options.maxEntries ?? 100_000;
const maxBytes = options.maxBytes ?? 512 * 1024 * 1024;
const root = path.resolve(destinationDir);
const rootInfo = await lstat(root).catch(() => null);
if (rootInfo?.isSymbolicLink() || (rootInfo && !rootInfo.isDirectory())) throw new Error("归档目标目录无效");
await mkdir(root, { recursive: true, mode: 0o700 });
await new Promise<void>((resolve, reject) => {
yauzl.open(archivePath, { lazyEntries: true, validateEntrySizes: true }, (error, zip) => {
if (error || !zip) return reject(new Error("归档结构无效"));
let entries = 0;
let total = 0;
let settled = false;
const fail = (reason: unknown) => { if (!settled) { settled = true; zip.close(); reject(reason instanceof Error ? reason : new Error("归档结构无效")); } };
zip.on("error", fail);
zip.on("entry", (entry) => {
if (settled) return;
entries += 1;
if (entries > maxEntries) return fail(new Error("归档条目过多"));
let name: string;
try { name = safeArchiveEntry(entry.fileName); } catch (reason) { return fail(reason); }
const mode = (entry.externalFileAttributes >>> 16) & 0xffff;
if ((mode & 0o170000) === 0o120000) return fail(new Error("归档不允许符号链接"));
const target = path.resolve(root, name);
if (name && !target.startsWith(`${root}${path.sep}`)) return fail(new Error("归档包含不安全路径"));
const directory = entry.fileName.endsWith("/") || (mode & 0o170000) === 0o040000;
if (directory) {
if (!Number.isSafeInteger(entry.uncompressedSize) || entry.uncompressedSize !== 0) return fail(new Error("归档目录条目结构无效"));
const prepare = name ? ensureArchiveParent(root, target) : Promise.resolve();
prepare.then(async () => {
const existing = await lstat(target).catch(() => null);
if (existing?.isSymbolicLink() || (existing && !existing.isDirectory())) throw new Error("归档目标目录无效");
if (!existing) await mkdir(target, { mode: 0o700 });
zip.readEntry();
}).catch(fail);
return;
}
if (!Number.isSafeInteger(entry.uncompressedSize) || entry.uncompressedSize < 0 || entry.uncompressedSize > maxBytes - total) return fail(new Error("归档超过大小限制"));
total += entry.uncompressedSize;
if (!name) return fail(new Error("归档文件名无效"));
ensureArchiveParent(root, target).then(() => new Promise<void>((resolveEntry, rejectEntry) => {
zip.openReadStream(entry, (streamError, stream) => {
if (streamError || !stream) return rejectEntry(new Error("归档结构无效"));
pipeline(stream, createWriteStream(target, { mode: 0o600, flags: "wx" })).then(resolveEntry).catch(rejectEntry);
});
})).then(() => { zip.readEntry(); }).catch(fail);
});
zip.readEntry();
zip.once("end", () => { if (!settled) { settled = true; resolve(); } });
});
});
}
/**
* Read an archive incrementally. The previous implementation read the whole
* gzip and then called gunzipSync, which let a tiny gzip bomb allocate an
* unbounded amount of memory before the expanded-size limit was checked.
*/
class ArchiveStreamReader {
private readonly iterator: AsyncIterator<Buffer | Uint8Array>;
private buffered = Buffer.alloc(0) as Buffer<ArrayBufferLike>;
private done = false;
constructor(private readonly stream: Readable) {
this.iterator = stream[Symbol.asyncIterator]();
}
private async fill(minimum: number): Promise<void> {
while (!this.done && this.buffered.length < minimum) {
const next = await this.iterator.next();
if (next.done) {
this.done = true;
break;
}
const chunk = Buffer.isBuffer(next.value) ? next.value : Buffer.from(next.value);
if (chunk.length === 0) continue;
this.buffered = this.buffered.length === 0 ? chunk : Buffer.concat([this.buffered, chunk]);
}
}
async read(length: number): Promise<Buffer | null> {
if (!Number.isSafeInteger(length) || length < 0) throw new Error("归档读取长度无效");
if (length === 0) return Buffer.alloc(0);
await this.fill(length);
if (this.buffered.length === 0 && this.done) return null;
if (this.buffered.length < length) throw new Error("归档结构无效");
const result = this.buffered.subarray(0, length);
this.buffered = this.buffered.subarray(length);
return result;
}
async discard(length: number): Promise<void> {
let remaining = length;
while (remaining > 0) {
const chunk = await this.read(Math.min(remaining, 64 * 1024));
if (!chunk) throw new Error("归档结构无效");
remaining -= chunk.length;
}
}
async copyToFile(length: number, target: string): Promise<void> {
const handle = await open(target, "wx", 0o600);
let complete = false;
try {
let remaining = length;
while (remaining > 0) {
const chunk = await this.read(Math.min(remaining, 64 * 1024));
if (!chunk) throw new Error("归档结构无效");
let written = 0;
while (written < chunk.length) {
const result = await handle.write(chunk, written, chunk.length - written);
if (result.bytesWritten <= 0) throw new Error("归档写入失败");
written += result.bytesWritten;
}
remaining -= chunk.length;
}
await handle.sync();
complete = true;
} finally {
await handle.close().catch(() => undefined);
if (!complete) await rm(target, { force: true }).catch(() => undefined);
}
}
}
function parsePaxPath(payload: Buffer): string | undefined {
let offset = 0;
let pathValue: string | undefined;
while (offset < payload.length) {
const space = payload.indexOf(0x20, offset);
if (space <= offset) throw new Error("归档扩展头无效");
const lengthText = payload.subarray(offset, space).toString("ascii");
if (!/^\d+$/.test(lengthText)) throw new Error("归档扩展头无效");
const length = Number(lengthText);
if (!Number.isSafeInteger(length) || length <= space - offset + 2 || offset + length > payload.length) throw new Error("归档扩展头无效");
const record = payload.subarray(offset, offset + length);
if (record[record.length - 1] !== 0x0a) throw new Error("归档扩展头无效");
const equals = record.indexOf(0x3d, space - offset + 1);
if (equals < 0) throw new Error("归档扩展头无效");
const key = record.subarray(space - offset + 1, equals).toString("utf8");
if (key === "path") pathValue = record.subarray(equals + 1, record.length - 1).toString("utf8");
offset += length;
}
return pathValue;
}
async function readTarMetadata(reader: ArchiveStreamReader, size: number, maxBytes: number): Promise<Buffer> {
// Extended headers only carry names and metadata. A small hard cap keeps a
// malformed header from turning into another allocation vector.
if (size > Math.min(maxBytes, 4 * 1024 * 1024)) throw new Error("归档扩展头过大");
const payload = await reader.read(size);
if (!payload) throw new Error("归档结构无效");
await reader.discard((512 - (size % 512)) % 512);
return payload;
}
async function extractSafeTar(stream: Readable, destinationDir: string, options: { maxEntries?: number; maxBytes?: number }): Promise<void> {
const maxEntries = options.maxEntries ?? 100_000;
const maxBytes = options.maxBytes ?? 512 * 1024 * 1024;
const root = path.resolve(destinationDir);
const rootInfo = await lstat(root).catch(() => null);
if (rootInfo?.isSymbolicLink() || (rootInfo && !rootInfo.isDirectory())) throw new Error("归档目标目录无效");
await mkdir(root, { recursive: true, mode: 0o700 });
const reader = new ArchiveStreamReader(stream);
let entries = 0;
let total = 0;
let globalPath: string | undefined;
let pendingPath: string | undefined;
let terminated = false;
try {
while (true) {
const header = await reader.read(512);
if (!header) throw new Error("归档结构无效");
if (header.every((value) => value === 0)) {
terminated = true;
break;
}
const storedChecksum = Number.parseInt(header.subarray(148, 156).toString("ascii").replace(/[\0 ]/g, ""), 8);
let checksum = 0;
for (let index = 0; index < header.length; index += 1) checksum += index >= 148 && index < 156 ? 0x20 : header[index]!;
if (!Number.isFinite(storedChecksum) || checksum !== storedChecksum) throw new Error("归档校验失败");
entries += 1;
if (entries > maxEntries) throw new Error("归档条目过多");
const sizeText = header.subarray(124, 136).toString("ascii").replace(/\0.*$/, "").trim();
const size = sizeText ? Number.parseInt(sizeText, 8) : 0;
if (!Number.isSafeInteger(size) || size < 0) throw new Error("归档结构无效");
const type = header[156];
if (type === 0x78 || type === 0x67 || type === 0x4c || type === 0x4b) {
total += size;
if (!Number.isSafeInteger(total) || total > maxBytes) throw new Error("归档超过大小限制");
const payload = await readTarMetadata(reader, size, maxBytes);
if (type === 0x4c) {
const end = payload.indexOf(0);
pendingPath = payload.subarray(0, end < 0 ? payload.length : end).toString("utf8");
} else if (type === 0x4b) {
// Hard/symbolic links are intentionally unsupported. Reject the
// GNU long-link record instead of carrying it into a later entry.
throw new Error("归档不允许链接");
} else {
const extendedPath = parsePaxPath(payload);
if (type === 0x67) globalPath = extendedPath;
else if (extendedPath !== undefined) pendingPath = extendedPath;
}
continue;
}
const namePart = header.subarray(0, 100).toString("utf8").replace(/\0.*$/, "");
const prefixPart = header.subarray(345, 500).toString("utf8").replace(/\0.*$/, "");
const rawName = pendingPath ?? globalPath ?? (prefixPart ? `${prefixPart}/${namePart}` : namePart);
pendingPath = undefined;
const name = safeArchiveEntry(rawName);
const target = path.resolve(root, name);
if (name && !target.startsWith(`${root}${path.sep}`)) throw new Error("归档包含不安全路径");
if (type === 0x35) {
if (size !== 0) throw new Error("归档目录条目结构无效");
if (name) await ensureArchiveParent(root, target);
const existing = await lstat(target).catch(() => null);
if (existing?.isSymbolicLink() || (existing && !existing.isDirectory())) throw new Error("归档目标目录无效");
if (!existing) await mkdir(target, { mode: 0o700 });
} else if (type === 0x30 || type === 0) {
if (!name) throw new Error("归档文件名无效");
await ensureArchiveParent(root, target);
const parent = await lstat(path.dirname(target));
if (!parent.isDirectory()) throw new Error("归档目标目录无效");
if (size > maxBytes - total) throw new Error("归档超过大小限制");
total += size;
await reader.copyToFile(size, target);
} else {
throw new Error("归档包含不受支持的文件类型");
}
await reader.discard((512 - (size % 512)) % 512);
}
} finally {
stream.destroy();
}
if (!terminated) throw new Error("归档结构无效");
}
async function readArchivePrefix(archivePath: string, length: number): Promise<Buffer> {
const handle = await open(archivePath, "r");
try {
const buffer = Buffer.alloc(length);
const result = await handle.read(buffer, 0, length, 0);
return buffer.subarray(0, result.bytesRead);
} finally {
await handle.close().catch(() => undefined);
}
}
/** Make a staged release readable by the unprivileged systemd service. */
export async function normalizeReleasePermissions(rootPath: string): Promise<void> {
const root = path.resolve(rootPath);
const rootInfo = await lstat(root).catch(() => null);
if (!rootInfo?.isDirectory() || rootInfo.isSymbolicLink()) throw new Error("发布目录无效");
const walk = async (directory: string): Promise<void> => {
await chmod(directory, 0o755);
const entries = await readdir(directory, { withFileTypes: true });
for (const entry of entries) {
const target = path.join(directory, entry.name);
if (entry.isSymbolicLink()) throw new Error("发布包不允许符号链接");
if (entry.isDirectory()) {
await walk(target);
} else if (entry.isFile()) {
const relative = path.relative(root, target).split(path.sep).join("/");
const executable = relative.startsWith("bin/") || relative.startsWith("scripts/") || relative.startsWith("runtime/bin/");
await chmod(target, executable ? 0o755 : 0o644);
} else {
throw new Error("发布包包含不受支持的文件类型");
}
}
};
await walk(root);
}
export async function extractSafeArchive(archivePath: string, destinationDir: string, options: { maxEntries?: number; maxBytes?: number } = {}): Promise<void> {
const archiveInfo = await lstat(archivePath).catch(() => null);
if (!archiveInfo?.isFile() || archiveInfo.isSymbolicLink()) throw new Error("归档文件无效");
const destinationInfo = await lstat(destinationDir).catch(() => null);
const prefix = await readArchivePrefix(archivePath, 512);
try {
if (prefix.subarray(0, 4).equals(Buffer.from([0x50, 0x4b, 0x03, 0x04]))) {
await extractSafeZip(archivePath, destinationDir, options);
return;
}
if (prefix.subarray(0, 2).equals(Buffer.from([0x1f, 0x8b]))) {
await extractSafeTar(createReadStream(archivePath).pipe(createGunzip()), destinationDir, options);
return;
}
if (prefix.subarray(257, 262).toString("ascii") !== "ustar") throw new Error("归档格式无效");
await extractSafeTar(createReadStream(archivePath), destinationDir, options);
} catch (error) {
// The updater normally uses a disposable workspace. Keep the public helper
// equally tidy when it created the destination itself.
if (!destinationInfo) await rm(destinationDir, { recursive: true, force: true }).catch(() => undefined);
throw error;
}
}
export const archiveDirectory = createSafeArchive;
export const backupDirectory = createSafeArchive;
export async function atomicSwitchDirectory(stagedDir: string, currentDir: string, backupDir?: string): Promise<string | undefined> {
const staged = path.resolve(stagedDir);
const current = path.resolve(currentDir);
if (staged === current) throw new Error("更新目录无效");
const stagedInfo = await lstat(staged).catch(() => null);
if (!stagedInfo?.isDirectory() || stagedInfo.isSymbolicLink()) throw new Error("更新暂存目录无效");
const currentInfo = await lstat(current).catch(() => null);
if (currentInfo && (!currentInfo.isDirectory() || currentInfo.isSymbolicLink())) throw new Error("当前安装目录无效");
const backup = backupDir ? path.resolve(backupDir) : path.join(path.dirname(current), `.backup-${Date.now()}-${randomUUID()}`);
if (await lstat(backup).catch(() => null)) throw new Error("备份目录已存在");
const backupParent = path.resolve(path.dirname(backup));
await mkdir(backupParent, { recursive: true, mode: 0o700 });
await assertPrivateDirectory(backupParent);
await assertPrivateDirectory(path.dirname(current));
if (currentInfo) await rename(current, backup);
try {
await rename(staged, current);
} catch (error) {
if (currentInfo) {
try {
await rename(backup, current);
} catch {
throw new Error("更新目录切换失败,旧版本恢复失败");
}
}
throw new Error("更新目录切换失败");
}
return currentInfo ? backup : undefined;
}
/** Atomically publish a release in the installer layout (`current` symlink).
* The old release is intentionally retained for rollback; only the link is
* replaced, so the active data directory is never moved or overwritten. */
export async function atomicSwitchRelease(
stagedDir: string,
currentLink: string,
releasesDir: string,
version: string,
): Promise<{ previousTarget?: string; publishedTarget: string }> {
const staged = path.resolve(stagedDir);
const link = path.resolve(currentLink);
const releases = path.resolve(releasesDir);
const parsed = parseSemver(version);
const normalizedVersion = `${parsed.major}.${parsed.minor}.${parsed.patch}${parsed.prerelease.length ? `-${parsed.prerelease.join(".")}` : ""}${parsed.build.length ? `+${parsed.build.join(".")}` : ""}`;
const target = path.join(releases, normalizedVersion);
if (!target.startsWith(`${releases}${path.sep}`)) throw new Error("更新版本目录无效");
const stagedInfo = await lstat(staged).catch(() => null);
if (!stagedInfo?.isDirectory() || stagedInfo.isSymbolicLink()) throw new Error("更新暂存目录无效");
const releasesInfo = await lstat(releases).catch(() => null);
if (releasesInfo?.isSymbolicLink() || (releasesInfo && !releasesInfo.isDirectory())) throw new Error("发布目录无效");
await mkdir(releases, { recursive: true, mode: 0o755 });
await assertPrivateDirectory(releases);
await assertPrivateDirectory(path.dirname(releases));
if (await lstat(target).catch(() => null)) throw new Error("该版本已经安装");
const currentInfo = await lstat(link).catch(() => null);
if (currentInfo && !currentInfo.isSymbolicLink()) throw new Error("当前安装目录无效");
await assertPrivateDirectory(path.dirname(link));
let previousTarget: string | undefined;
if (currentInfo?.isSymbolicLink()) {
const raw = await readlink(link);
const resolvedPrevious = path.resolve(path.dirname(link), raw);
if (!resolvedPrevious.startsWith(`${releases}${path.sep}`)) throw new Error("当前发布链接无效");
previousTarget = path.relative(path.dirname(link), resolvedPrevious) || ".";
}
await rename(staged, target);
const temporaryLink = path.join(path.dirname(link), `.current-${process.pid}-${randomUUID()}.tmp`);
let linkCommitted = false;
try {
await symlink(target, temporaryLink);
await rename(temporaryLink, link);
linkCommitted = true;
const parent = await open(path.dirname(link), "r");
try {
await parent.sync();
} finally {
await parent.close();
}
} catch (error) {
await rm(temporaryLink, { force: true }).catch(() => undefined);
if (linkCommitted) {
// The link may already be visible when the directory fsync fails. Put
// the old link back before removing the new target; otherwise a crash
// recovery path could leave `current` dangling.
try {
if (previousTarget) {
const rollbackLink = path.join(path.dirname(link), `.current-rollback-${process.pid}-${randomUUID()}.tmp`);
const previousAbsolute = path.resolve(path.dirname(link), previousTarget);
await symlink(previousAbsolute, rollbackLink);
await rename(rollbackLink, link);
} else {
await rm(link, { force: true });
}
} catch {
// Never delete a target which may still be referenced by `current`.
throw new Error("更新目录切换失败,旧版本恢复失败");
}
}
await rm(target, { recursive: true, force: true }).catch(() => undefined);
throw error instanceof Error && error.message === "当前发布链接无效" ? error : new Error("更新目录切换失败");
}
return { ...(previousTarget ? { previousTarget } : {}), publishedTarget: target };
}
// Compatibility aliases for callers that prefer verb-oriented names.
export const getReleaseMetadata = fetchReleaseMetadata;
export const compareVersions = compareSemver;
export const getCurrentPlatform = detectPlatform;
export const downloadFile = downloadReleaseAsset;
export const verifyFileSha256 = verifySha256;
export const safeExtractArchive = extractSafeArchive;
export const switchDirectoryAtomically = atomicSwitchDirectory;