feat: add TallyNote local reimbursement ledger
TallyNote release / linux-x64 (push) Failing after 2m41s
TallyNote release / linux-x64 (push) Failing after 2m41s
This commit is contained in:
+1654
File diff suppressed because it is too large
Load Diff
@@ -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),
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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 }) };
|
||||
}
|
||||
@@ -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),
|
||||
]);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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!`;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user