Files
TallyNote/server/cli/update.ts
T
Qiufeng 9719429f4a
TallyNote release / linux-x64 (push) Failing after 2m41s
feat: add TallyNote local reimbursement ledger
2026-08-29 01:02:29 +08:00

419 lines
23 KiB
TypeScript

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;
});
}