This commit is contained in:
+34
-10
@@ -154,19 +154,19 @@ function signatureAssetFor(metadata: ReleaseMetadata, sums: ReleaseAsset): Relea
|
||||
export async function attachSidecarHash(
|
||||
metadata: ReleaseMetadata,
|
||||
asset: ReleaseAsset,
|
||||
options: { allowedHosts: readonly string[]; baseUrl: string; maxBytes: number; publicKey?: string | undefined; requireSignature?: boolean | undefined },
|
||||
options: { allowedHosts: readonly string[]; baseUrl: string; maxBytes: number; timeoutMs?: number | undefined; 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 content = await fetchReleaseText(sums.url, { allowedHosts: options.allowedHosts, baseUrl: options.baseUrl, maxBytes: Math.min(options.maxBytes, 2 * 1024 * 1024), timeoutMs: options.timeoutMs });
|
||||
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 });
|
||||
const signature = await fetchReleaseBytes(signatureAsset.url, { allowedHosts: options.allowedHosts, baseUrl: options.baseUrl, maxBytes: 64 * 1024, timeoutMs: options.timeoutMs });
|
||||
signatureVerified = verifyReleaseSignature(content, signature, options.publicKey);
|
||||
}
|
||||
}
|
||||
@@ -183,6 +183,7 @@ function policy(config: AppConfig) {
|
||||
allowedHosts: config.updateAllowedHosts,
|
||||
baseUrl: config.updateMetadataUrl,
|
||||
maxRedirects: 3,
|
||||
timeoutMs: config.updateTimeoutMs,
|
||||
} as const;
|
||||
}
|
||||
|
||||
@@ -222,6 +223,7 @@ export async function checkForUpdate(database: Database.Database, config: AppCon
|
||||
allowedHosts: config.updateAllowedHosts,
|
||||
baseUrl: metadataUrl,
|
||||
maxBytes: config.updateMaxBytes,
|
||||
timeoutMs: config.updateTimeoutMs,
|
||||
publicKey: config.updatePublicKey,
|
||||
requireSignature: config.updateRequireSignature,
|
||||
});
|
||||
@@ -426,6 +428,17 @@ function requestJobId(filePath: string): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
function recoveryStateJobId(filePath: string): string | null {
|
||||
try {
|
||||
const info = lstatSync(filePath);
|
||||
if (!info.isFile() || info.isSymbolicLink()) return null;
|
||||
const match = /^job_id=([0-9a-f-]{36})$/m.exec(readFileSync(filePath, "utf8"));
|
||||
return match?.[1] ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function currentReleaseVersion(config: AppConfig): string | null {
|
||||
try {
|
||||
const target = realpathSync(config.currentLink);
|
||||
@@ -460,6 +473,12 @@ export function reconcileOrphanedUpdateJobs(database: Database.Database, config:
|
||||
const statePresent = stateMtime !== null;
|
||||
const requestFresh = requestPresent && now - (requestMtime ?? 0) < ORPHANED_UPDATE_TIMEOUT_MS;
|
||||
const stateFresh = statePresent && now - (stateMtime ?? 0) < ORPHANED_UPDATE_TIMEOUT_MS;
|
||||
// The request marker is the hand-off contract between the web process and
|
||||
// the privileged runner. A queued row with a matching, unexpired marker is
|
||||
// still owned by that hand-off even when the runner has not written its
|
||||
// recovery state yet (for example while systemd is starting it).
|
||||
const requestMarkerJobId = requestPresent ? requestJobId(config.updateRequestPath) : null;
|
||||
const stateMarkerJobId = statePresent ? recoveryStateJobId(statePath) : null;
|
||||
// A staged download is normally kept for an explicit apply. The one
|
||||
// exception is the hand-off window where the API has already changed the
|
||||
// operation to `apply` but crashed before writing the request file. That
|
||||
@@ -472,7 +491,10 @@ export function reconcileOrphanedUpdateJobs(database: Database.Database, config:
|
||||
// the UI as an endless "queued" task. Once the short hand-off window has
|
||||
// elapsed and no recovery marker exists, release the queue explicitly;
|
||||
// a fresh state marker proves that the runner has already claimed it.
|
||||
if (row.status === "queued" && typeof row.updatedAt === "number" && !stateFresh && now - row.updatedAt >= QUEUED_UPDATE_TIMEOUT_MS) {
|
||||
const matchingFreshRequest = requestMarkerJobId === row.id && requestFresh;
|
||||
const matchingFreshState = stateMarkerJobId === row.id && stateFresh;
|
||||
if (row.status === "queued" && typeof row.updatedAt === "number" && !matchingFreshState && now - row.updatedAt >= QUEUED_UPDATE_TIMEOUT_MS) {
|
||||
if (matchingFreshRequest) continue;
|
||||
const changed = database.transaction(() => {
|
||||
const result = database.prepare(`
|
||||
UPDATE update_jobs
|
||||
@@ -503,7 +525,7 @@ export function reconcileOrphanedUpdateJobs(database: Database.Database, config:
|
||||
// A stale request/state marker therefore no longer protects an orphaned
|
||||
// row forever, while a fresh marker remains owned by the runner.
|
||||
if (row.status === "staged") {
|
||||
if (row.operation !== "apply" || requestFresh || stateFresh) continue;
|
||||
if (row.operation !== "apply" || matchingFreshRequest || matchingFreshState) continue;
|
||||
const changed = database.transaction(() => {
|
||||
const result = database.prepare(`
|
||||
UPDATE update_jobs
|
||||
@@ -529,7 +551,7 @@ export function reconcileOrphanedUpdateJobs(database: Database.Database, config:
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (requestFresh || stateFresh) continue;
|
||||
if (matchingFreshRequest || matchingFreshState) continue;
|
||||
const status: "completed" | "failed" = row.status === "applying" && releaseVersion === row.version ? "completed" : "failed";
|
||||
const errorMessage = status === "failed" ? "更新任务超时,已释放更新队列" : null;
|
||||
const changed = database.transaction(() => {
|
||||
@@ -586,15 +608,15 @@ export function cancelUpdateJob(
|
||||
jobId?: string,
|
||||
): { cancelled: boolean; message?: string } {
|
||||
const job = jobId
|
||||
? database.prepare("SELECT id, status, operation, version, admin_id AS adminId, download_path AS downloadPath FROM update_jobs WHERE id=?").get(jobId) as { id: string; status: UpdateJobStatus; operation: string; version: string; adminId: string | null; downloadPath: string | null } | undefined
|
||||
: database.prepare("SELECT id, status, operation, version, admin_id AS adminId, download_path AS downloadPath FROM update_jobs WHERE status IN ('queued', 'downloading') ORDER BY created_at DESC LIMIT 1").get() as { id: string; status: UpdateJobStatus; operation: string; version: string; adminId: string | null; downloadPath: string | null } | undefined;
|
||||
? database.prepare("SELECT id, status, operation, version, admin_id AS adminId, download_path AS downloadPath FROM update_jobs WHERE id=? AND admin_id=?").get(jobId, adminId) as { id: string; status: UpdateJobStatus; operation: string; version: string; adminId: string | null; downloadPath: string | null } | undefined
|
||||
: database.prepare("SELECT id, status, operation, version, admin_id AS adminId, download_path AS downloadPath FROM update_jobs WHERE admin_id=? AND status IN ('queued', 'downloading') ORDER BY created_at DESC LIMIT 1").get(adminId) as { id: string; status: UpdateJobStatus; operation: string; version: string; adminId: string | null; downloadPath: string | null } | undefined;
|
||||
|
||||
if (!job) return { cancelled: false, message: "当前没有处于等待调度或下载中的更新任务" };
|
||||
if (job.status !== "queued" && job.status !== "downloading") return { cancelled: false, message: "任务已进入就绪或切换阶段,无法取消" };
|
||||
|
||||
const now = Date.now();
|
||||
const changed = database.transaction(() => {
|
||||
const result = database.prepare("UPDATE update_jobs SET status='cancelled', error_message='已手动取消更新', completed_at=?, updated_at=? WHERE id=? AND status IN ('queued', 'downloading')").run(now, now, job.id);
|
||||
const result = database.prepare("UPDATE update_jobs SET status='cancelled', error_message='已手动取消更新', completed_at=?, updated_at=? WHERE id=? AND admin_id=? AND status IN ('queued', 'downloading')").run(now, now, job.id, adminId);
|
||||
if (result.changes !== 1) return false;
|
||||
writeAudit(database, {
|
||||
requestId,
|
||||
@@ -610,7 +632,9 @@ export function cancelUpdateJob(
|
||||
})();
|
||||
|
||||
if (changed) {
|
||||
forceRemoveRequest(config.updateRequestPath);
|
||||
// The request marker is shared by the privileged runner. Never remove a
|
||||
// newer/different administrator's request while cancelling this row.
|
||||
if (requestJobId(config.updateRequestPath) === job.id) forceRemoveRequest(config.updateRequestPath);
|
||||
if (job.downloadPath) {
|
||||
const target = path.isAbsolute(job.downloadPath) ? job.downloadPath : path.join(config.stagingDir, job.downloadPath);
|
||||
import("node:fs/promises").then(({ rm }) => rm(target, { recursive: true, force: true })).catch(() => {});
|
||||
|
||||
Reference in New Issue
Block a user