import { randomUUID } from "node:crypto"; import { cp, 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, applicationUpdateRuntimeHash, compareSemver, createSafeArchive, detectPlatform, downloadReleaseAsset, extractSafeArchive, fetchReleaseMetadata, isNewerVersion, normalizeReleasePermissions, parseSemver, runtimeHashFromLockfile, selectReleaseAsset, sanitizeAssetName, validateHttpsUrl, type ReleaseAsset, type ReleaseMetadata, type UrlPolicy, } from "../update.js"; import { ACTIVE_UPDATE_STATUSES, attachSidecarHash } from "../update-service.js"; import type { UpdateJobStatus } from "../../shared/contracts.js"; const updateRequestFileSchema = z.object({ jobId: z.string().uuid(), operation: z.enum(["download", "apply"]).default("apply"), 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; /** 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 | undefined; jobId?: string | undefined; publicKey?: string | undefined; requireSignature?: boolean | undefined; operation?: "download" | "apply" | undefined; stagedPath?: string | 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; operation?: "download" | "apply" | undefined; }): void { if (!sqlite) return; const now = Date.now(); const effectiveOperation = values.operation ?? (sqlite.prepare("SELECT operation FROM update_jobs WHERE id=?").get(jobId) as { operation?: "download" | "apply" } | undefined)?.operation ?? "apply"; sqlite.prepare(` INSERT INTO update_jobs(id, admin_id, session_hash, request_id, requested_at, started_at, operation, 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), operation=excluded.operation, status=CASE WHEN update_jobs.status='cancelled' THEN update_jobs.status ELSE excluded.status END, 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, effectiveOperation, 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[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): 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 runtimeHash: string | undefined; try { runtimeHash = runtimeHashFromLockfile(await readFile(path.join(options.currentDir, "pnpm-lock.yaml"))); } catch { // Fall back to the full archive when the current installation predates // runtime fingerprints or is missing deployment provenance. } 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, runtimeHash); 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 { 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; } /** Validate a queued staged directory before a root process consumes it. */ async function validateStagedWorkspacePath(candidate: string, workspaceRoot: string): Promise { const rootResolved = path.resolve(workspaceRoot); const rootInfo = await lstat(rootResolved).catch(() => null); const uid = typeof process.getuid === "function" ? process.getuid() : -1; if (!rootInfo?.isDirectory() || rootInfo.isSymbolicLink() || (rootInfo.mode & 0o077) !== 0 || rootInfo.uid !== 0 || uid !== 0) { throw new Error("更新工作目录权限无效"); } const root = await realpath(rootResolved).catch(() => { throw new Error("更新工作目录无效"); }); const resolved = path.resolve(candidate); if (resolved === rootResolved || !resolved.startsWith(`${rootResolved}${path.sep}`)) throw new Error("更新暂存路径无效"); const info = await lstat(resolved).catch(() => null); if (!info?.isDirectory() || info.isSymbolicLink() || (info.mode & 0o077) !== 0 || info.uid !== 0) throw new Error("更新暂存目录权限无效"); const real = await realpath(resolved).catch(() => { throw new Error("更新暂存目录无效"); }); if (real !== resolved || !real.startsWith(`${root}${path.sep}`)) throw new Error("更新暂存路径无效"); return real; } export async function runUpdate(options: UpdateRunOptions): Promise { const platform = options.platform ?? detectPlatform(); const jobId = options.jobId ?? randomUUID(); const operation = options.operation ?? "apply"; const sqlite = options.sqlite; let resolved: Awaited> | 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, { operation, 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 }); let keepWorkspace = false; const workspace = operation === "download" ? path.join(path.resolve(options.stagingDir), `update-${jobId}`) : await mkdtemp(path.join(path.resolve(options.stagingDir), `update-${jobId}-`)); if (operation === "download") await mkdir(workspace, { recursive: false, mode: 0o700 }); const archivePath = path.join(workspace, resolved.asset.name.endsWith(".gz") || resolved.asset.name.endsWith(".zip") ? resolved.asset.name : `${resolved.asset.name}.tar.gz`); try { if (sqlite) { const claim = sqlite.prepare("UPDATE update_jobs SET status='downloading', download_started_at=?, started_at=?, download_path=?, updated_at=? WHERE id=? AND status='queued'").run(Date.now(), Date.now(), path.basename(archivePath), Date.now(), jobId); if (claim.changes !== 1) throw new Error("更新任务已取消或已被其他进程接管"); } else { updateJob(options.sqlite, jobId, { operation, 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 progressStartedAt = Date.now(); let lastProgressWrite = 0; const downloaded = await downloadReleaseAsset(resolved.asset.url, archivePath, { ...options, onProgress: (downloadedBytes, totalBytes) => { const now = Date.now(); if (!options.sqlite || now - lastProgressWrite < 250) return; lastProgressWrite = now; const elapsed = Math.max(1, now - progressStartedAt); const speedBps = Math.round(downloadedBytes * 1000 / elapsed); options.sqlite.prepare("UPDATE update_jobs SET downloaded_bytes=?, size_bytes=COALESCE(?, size_bytes), download_started_at=?, download_speed_bps=?, updated_at=? WHERE id=? AND status='downloading'").run(downloadedBytes, totalBytes, progressStartedAt, speedBps, now, jobId); }, }); if (options.sqlite) { const finishedAt = Date.now(); const elapsed = Math.max(1, finishedAt - progressStartedAt); options.sqlite.prepare("UPDATE update_jobs SET downloaded_bytes=?, size_bytes=?, download_started_at=?, download_speed_bps=?, updated_at=? WHERE id=? AND status='downloading'").run(downloaded.size, downloaded.size, progressStartedAt, Math.round(downloaded.size * 1000 / elapsed), finishedAt, jobId); } if (expectedSha256 && downloaded.sha256 !== expectedSha256) throw new Error("更新文件 SHA-256 校验失败"); updateJob(options.sqlite, jobId, { operation, 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 }); if (applicationUpdateRuntimeHash(resolved.asset.name)) { const currentRelease = await realpath(options.currentDir).catch(() => { throw new Error("当前安装目录无效"); }); const currentInfo = await lstat(currentRelease).catch(() => null); if (!currentInfo?.isDirectory() || currentInfo.isSymbolicLink()) throw new Error("当前安装目录无效"); for (const entry of ["node_modules", "runtime", "pnpm-lock.yaml"] as const) { const source = path.join(currentRelease, entry); const sourceInfo = await lstat(source).catch(() => null); if (!sourceInfo || sourceInfo.isSymbolicLink()) throw new Error("当前运行时不完整,无法应用轻量更新"); await cp(source, path.join(stagedDir, entry), { recursive: sourceInfo.isDirectory(), errorOnExist: true, force: false }); } } await normalizeReleasePermissions(stagedDir); const payloadInfo = await lstat(path.join(stagedDir, "dist")).catch(() => null); if (!payloadInfo?.isDirectory() || payloadInfo.isSymbolicLink()) throw new Error("发布包缺少 dist 目录"); if (sqlite) { const staged = sqlite.prepare("UPDATE update_jobs SET status='staged', actual_sha256=?, size_bytes=?, download_path=?, updated_at=? WHERE id=? AND status IN ('verifying', 'downloading')").run(downloaded.sha256, downloaded.size, workspace, Date.now(), jobId); if (staged.changes !== 1) throw new Error("更新任务已取消,已停止继续处理"); } else { updateJob(options.sqlite, jobId, { operation, 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: workspace }); } if (operation === "download") { keepWorkspace = true; return { jobId, version: resolved.version, asset: resolved.asset, 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 { if (!keepWorkspace) { 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("更新任务不存在"); const canComplete = row.status === "applying" || row.status === "completed"; const canFail = ACTIVE_UPDATE_STATUSES.includes(row.status) || row.status === "completed" || row.status === "failed"; if (status === "completed" ? !canComplete : !canFail) 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" } : {}) }, }); })(); } export async function applyStagedUpdate(options: { sqlite: Database.Database; jobId: string; version: string; stagedPath: string; currentDir: string; currentLink: string; releasesDir: string; backupArchivePath?: string; dataBackupArchivePath?: string; dataBackupSource?: string; maxBytes?: number; dataBackupMaxBytes?: number; workspaceRoot?: string; }): Promise { const row = options.sqlite.prepare(`SELECT status, operation, version, platform, release_url AS releaseUrl, asset_name AS assetName, asset_url AS assetUrl, expected_sha256 AS expectedSha256, actual_sha256 AS actualSha256, size_bytes AS sizeBytes FROM update_jobs WHERE id=?`).get(options.jobId) as Record | undefined; if (!row || row.status !== "staged" || row.operation !== "apply") throw new Error("更新任务未处于待应用状态"); if (typeof row.version === "string" && row.version !== options.version) throw new Error("更新版本不一致"); const stagedPath = options.workspaceRoot ? await validateStagedWorkspacePath(options.stagedPath, options.workspaceRoot) : options.stagedPath; const payload = path.join(stagedPath, "payload"); const payloadInfo = await lstat(payload).catch(() => null); if (!payloadInfo?.isDirectory() || payloadInfo.isSymbolicLink()) throw new Error("更新暂存内容无效"); await normalizeReleasePermissions(payload); let switchedBackup: string | undefined; let committed = false; try { if (options.dataBackupArchivePath && options.dataBackupSource) { updateJob(options.sqlite, options.jobId, { operation: "apply", status: "backing_up", version: options.version, platform: String(row.platform), releaseUrl: row.releaseUrl as string | undefined, assetName: row.assetName as string | undefined, assetUrl: String(row.assetUrl), expectedSha256: row.expectedSha256 as string | undefined, actualSha256: row.actualSha256 as string | undefined, sizeBytes: row.sizeBytes as number | undefined, downloadPath: stagedPath, backupPath: options.dataBackupArchivePath }); await createSafeArchive(options.dataBackupSource, options.dataBackupArchivePath, { maxBytes: options.dataBackupMaxBytes ?? 2 * 1024 * 1024 * 1024 }); } if (options.backupArchivePath) { updateJob(options.sqlite, options.jobId, { operation: "apply", status: "backing_up", version: options.version, platform: String(row.platform), releaseUrl: row.releaseUrl as string | undefined, assetName: row.assetName as string | undefined, assetUrl: String(row.assetUrl), expectedSha256: row.expectedSha256 as string | undefined, actualSha256: row.actualSha256 as string | undefined, sizeBytes: row.sizeBytes as number | undefined, downloadPath: stagedPath, backupPath: options.backupArchivePath }); const source = await realpath(options.currentDir).catch(() => options.currentDir); await createSafeArchive(source, options.backupArchivePath, { maxBytes: options.maxBytes ?? 512 * 1024 * 1024 }); } updateJob(options.sqlite, options.jobId, { operation: "apply", status: "applying", version: options.version, platform: String(row.platform), releaseUrl: row.releaseUrl as string | undefined, assetName: row.assetName as string | undefined, assetUrl: String(row.assetUrl), expectedSha256: row.expectedSha256 as string | undefined, actualSha256: row.actualSha256 as string | undefined, sizeBytes: row.sizeBytes as number | undefined, downloadPath: stagedPath, backupPath: options.backupArchivePath, startedAt: Date.now() }); switchedBackup = (await atomicSwitchRelease(payload, options.currentLink, options.releasesDir, options.version)).previousTarget; committed = true; await rm(stagedPath, { recursive: true, force: true }).catch(() => undefined); } catch (error) { if (!committed) { await rm(stagedPath, { recursive: true, force: true }).catch(() => undefined); updateJob(options.sqlite, options.jobId, { operation: "apply", status: "failed", version: options.version, platform: String(row.platform), assetUrl: String(row.assetUrl), errorMessage: safeErrorMessage(error) }); clearTransientJobPath(options.sqlite, options.jobId); } throw error; } updateJob(options.sqlite, options.jobId, { operation: "apply", status: "applying", version: options.version, platform: String(row.platform), assetUrl: String(row.assetUrl), expectedSha256: row.expectedSha256 as string | undefined, actualSha256: row.actualSha256 as string | undefined, sizeBytes: row.sizeBytes as number | undefined, backupPath: switchedBackup ?? options.backupArchivePath }); clearTransientJobPath(options.sqlite, options.jobId); } 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 { 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 }); // The download phase intentionally runs beside the live app so users keep // access while the archive is fetched and staged. SQLite WAL plus the // configured busy timeout serializes writes; the exclusive process lock is // reserved for apply/rollback, when the service is stopped by systemd. const release = request?.operation === "download" ? () => undefined : acquireInstanceLock(config); const database = openDatabase(config); try { if (request?.operation === "apply") { const staged = database.sqlite.prepare("SELECT status, operation, download_path AS downloadPath, version FROM update_jobs WHERE id=?").get(request.jobId) as { status: UpdateJobStatus; operation: "download" | "apply"; downloadPath: string | null; version: string } | undefined; if (staged?.status === "staged" && staged.operation === "apply") { if (!staged.downloadPath || staged.version !== request.version) throw new Error("更新暂存任务无效"); const root = path.resolve(config.updateWorkspaceDir); const candidate = await validateStagedWorkspacePath(staged.downloadPath, root); await applyStagedUpdate({ sqlite: database.sqlite, jobId: request.jobId, version: request.version, stagedPath: candidate, currentDir, currentLink: request.currentLink, releasesDir: request.releasesDir, workspaceRoot: root, ...(backupArchive ? { backupArchivePath: backupArchive } : {}), ...(dataBackupArchive ? { dataBackupArchivePath: dataBackupArchive } : {}), dataBackupSource: config.dataDir, maxBytes: config.updateMaxBytes, dataBackupMaxBytes: config.maxTotalBytes, }); console.log(`更新已切换:${request.version}`); return; } if (staged && !(staged.status === "queued" && staged.operation === "apply")) throw new Error("更新任务状态无效"); // A direct one-click request starts in queued/apply. Older clients do // not have a separate download step, so fall through to runUpdate, // which downloads, verifies, backs up, and switches the release in one // transaction. A staged request still takes the branch above. } 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?.operation === "download" ? { operation: "download" as const } : {}), ...(request ? { jobId: request.jobId } : {}), publicKey: config.updatePublicKey, requireSignature: 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; }); }