diff --git a/src/app/api/system/version/route.ts b/src/app/api/system/version/route.ts index 6032f70159..6e2f1e2f36 100644 --- a/src/app/api/system/version/route.ts +++ b/src/app/api/system/version/route.ts @@ -5,6 +5,7 @@ * Security: Requires admin authentication (same as other management routes). * Safety: Update only runs if a newer version is available on npm. */ +import { createHash } from "node:crypto"; import { NextRequest, NextResponse } from "next/server"; import { execFile } from "child_process"; import { promisify } from "util"; @@ -17,7 +18,11 @@ import { PROJECT_ROOT, } from "@/lib/system/autoUpdate"; import { NEWS_JSON_URL, parseActiveNewsPayload } from "@/shared/utils/releaseNotes"; -import { isNewer, resolveLatestVersion } from "@/lib/system/versionCheck"; +import { + clearLatestVersionCache, + isNewer, + resolveLatestVersionCached, +} from "@/lib/system/versionCheck"; import { resolveGlobalOmniroutePath } from "@/lib/system/globalPackagePath"; // #5542 — On Windows npm is `npm.cmd`; Node ≥24 refuses to execFile a `.cmd` without // a shell (nodejs/node#52554 → "spawn npm ENOENT"). buildNpmExecOptions enables the @@ -56,21 +61,34 @@ export async function GET(req: NextRequest) { const config = getAutoUpdateConfig(); const [latest, news, validation] = await Promise.all([ - resolveLatestVersion(), + resolveLatestVersionCached({ + bypassCache: /(?:^|,)\s*(?:no-cache|no-store)\b/i.test( + req.headers.get("Cache-Control") ?? "" + ), + storeResult: !/(?:^|,)\s*no-store\b/i.test(req.headers.get("Cache-Control") ?? ""), + }), getNews(), validateAutoUpdateRuntime(config), ]); - const updateAvailable = isNewer(latest, current); - - return NextResponse.json({ + const body = { current, latest: latest ?? "unavailable", - updateAvailable, + updateAvailable: isNewer(latest, current), channel: config.mode, autoUpdateSupported: validation.supported, autoUpdateError: validation.reason, news, + }; + const serialized = JSON.stringify(body); + const etag = `"${createHash("sha256").update(serialized).digest("base64url")}"`; + const headers = { "Cache-Control": "private, no-cache, must-revalidate", ETag: etag }; + const validators = req.headers.get("If-None-Match")?.split(",").map((value) => value.trim()); + if (validators?.some((value) => value === etag || value === `W/${etag}`)) { + return new NextResponse(null, { status: 304, headers }); + } + return new NextResponse(serialized, { + headers: { ...headers, "Content-Type": "application/json" }, }); } @@ -80,7 +98,7 @@ export async function POST(req: NextRequest) { } const current = getCurrentVersion(); - const latest = await resolveLatestVersion(); + const latest = await resolveLatestVersionCached({ bypassCache: true }); if (!latest) { return NextResponse.json( @@ -128,6 +146,7 @@ export async function POST(req: NextRequest) { ); } + clearLatestVersionCache(); return NextResponse.json({ success: true, message: `Update to v${latest} started. Docker rebuild is running in the background.`, @@ -339,6 +358,7 @@ export async function POST(req: NextRequest) { }); } + clearLatestVersionCache(); send({ step: "complete", status: "done", diff --git a/src/lib/system/versionCheck.ts b/src/lib/system/versionCheck.ts index a89eddb53a..3e0dfd83d9 100644 --- a/src/lib/system/versionCheck.ts +++ b/src/lib/system/versionCheck.ts @@ -36,6 +36,16 @@ const GITHUB_RELEASES_LATEST_URL = "https://api.github.com/repos/diegosouzapw/OmniRoute/releases/latest"; const LOOKUP_TIMEOUT_MS = 10_000; +const MAX_VERSION_RESPONSE_BYTES = 16 * 1024; +const LATEST_VERSION_CACHE_TTL_MS = 10 * 60_000; +const MAX_LATEST_VERSION_CACHE_TTL_MS = 10 * 60_000; + +type LatestVersionCacheEntry = { value: string; expiresAt: number }; + +let latestVersionCache: LatestVersionCacheEntry | null = null; +let latestVersionLookup: Promise | null = null; +let latestVersionRefresh: Promise | null = null; +let latestVersionCacheGeneration = 0; // The pure semver helpers live in `./versionCompare` (dependency-free) so // client-reachable modules can import them without pulling this file's @@ -64,6 +74,40 @@ export async function getLatestVersionFromNpmCli(): Promise { * Latest published version via the npm registry HTTP API. Needs only network access — no * `npm` binary — so it works in Docker / desktop / locked-down installs. */ +async function readBoundedJson(response: Response): Promise { + const declaredLength = Number(response.headers.get("Content-Length")); + if (Number.isFinite(declaredLength) && declaredLength > MAX_VERSION_RESPONSE_BYTES) { + await response.body?.cancel(); + throw new Error("Version metadata response is too large"); + } + + if (!response.body) return response.json(); + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let totalBytes = 0; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + totalBytes += value.byteLength; + if (totalBytes > MAX_VERSION_RESPONSE_BYTES) { + await reader.cancel(); + throw new Error("Version metadata response is too large"); + } + chunks.push(value); + } + } finally { + reader.releaseLock(); + } + const body = new Uint8Array(totalBytes); + let offset = 0; + for (const chunk of chunks) { + body.set(chunk, offset); + offset += chunk.byteLength; + } + return JSON.parse(new TextDecoder().decode(body)); +} + export async function getLatestVersionFromRegistry( fetchImpl: typeof fetch = fetch ): Promise { @@ -72,7 +116,7 @@ export async function getLatestVersionFromRegistry( signal: AbortSignal.timeout(LOOKUP_TIMEOUT_MS), }); if (!res.ok) return null; - const data = (await res.json()) as { version?: unknown }; + const data = (await readBoundedJson(res)) as { version?: unknown }; return typeof data?.version === "string" && data.version ? data.version : null; } catch { return null; @@ -99,7 +143,7 @@ export async function getLatestVersionFromGitHub( }, }); if (!res.ok) return null; - const data = (await res.json()) as { tag_name?: unknown }; + const data = (await readBoundedJson(res)) as { tag_name?: unknown }; return typeof data?.tag_name === "string" && data.tag_name ? data.tag_name : null; } catch { return null; @@ -112,6 +156,51 @@ export async function getLatestVersionFromGitHub( * warning — instead of silently degrading to "no update available" — when ALL sources fail. * Thunks are injectable for tests. */ +export function clearLatestVersionCache(): void { + latestVersionCache = null; + latestVersionCacheGeneration += 1; +} + +/** Coalesce and briefly cache successful latest-version lookups. */ +export async function resolveLatestVersionCached(opts?: { + lookup?: () => Promise; + bypassCache?: boolean; + storeResult?: boolean; + now?: () => number; + ttlMs?: number; +}): Promise { + const now = opts?.now ?? Date.now; + if (!opts?.bypassCache && latestVersionCache?.expiresAt > now()) { + return latestVersionCache.value; + } + + const inFlight = opts?.bypassCache ? latestVersionRefresh : latestVersionLookup; + if (inFlight) return inFlight; + if (opts?.bypassCache) clearLatestVersionCache(); + + const generation = latestVersionCacheGeneration; + const lookup = opts?.lookup ?? resolveLatestVersion; + const ttlMs = Math.min( + Math.max(opts?.ttlMs ?? LATEST_VERSION_CACHE_TTL_MS, 0), + MAX_LATEST_VERSION_CACHE_TTL_MS + ); + const pending = lookup().then((value) => { + if (value && opts?.storeResult !== false && latestVersionCacheGeneration === generation) { + latestVersionCache = { value, expiresAt: now() + ttlMs }; + } + return value; + }); + if (opts?.bypassCache) latestVersionRefresh = pending; + else latestVersionLookup = pending; + + try { + return await pending; + } finally { + if (latestVersionLookup === pending) latestVersionLookup = null; + if (latestVersionRefresh === pending) latestVersionRefresh = null; + } +} + export async function resolveLatestVersion(opts?: { npmCli?: () => Promise; registry?: () => Promise; diff --git a/tests/unit/system-version-cache-8278.test.ts b/tests/unit/system-version-cache-8278.test.ts new file mode 100644 index 0000000000..72a79e5900 --- /dev/null +++ b/tests/unit/system-version-cache-8278.test.ts @@ -0,0 +1,114 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + clearLatestVersionCache, + resolveLatestVersionCached, +} from "@/lib/system/versionCheck"; + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + +test.beforeEach(() => clearLatestVersionCache()); + +test("coalesces concurrent latest-version resolution into one lookup", async () => { + let calls = 0; + const pending = deferred(); + const lookup = () => { + calls += 1; + return pending.promise; + }; + + const requests = Array.from({ length: 10 }, () => resolveLatestVersionCached({ lookup })); + assert.equal(calls, 1); + pending.resolve("3.8.50"); + assert.deepEqual(await Promise.all(requests), Array(10).fill("3.8.50")); +}); + +test("reuses a successful result only inside the bounded TTL", async () => { + let now = 1_000; + let calls = 0; + const lookup = async () => `3.8.${++calls + 49}`; + + assert.equal(await resolveLatestVersionCached({ lookup, now: () => now, ttlMs: 500 }), "3.8.50"); + now = 1_499; + assert.equal(await resolveLatestVersionCached({ lookup, now: () => now, ttlMs: 500 }), "3.8.50"); + now = 1_500; + assert.equal(await resolveLatestVersionCached({ lookup, now: () => now, ttlMs: 500 }), "3.8.51"); + assert.equal(calls, 2); +}); + +test("explicit refresh bypasses an ordinary in-flight lookup and coalesces with refreshes", async () => { + let calls = 0; + const ordinary = deferred(); + const refresh = deferred(); + const lookup = () => { + calls += 1; + return calls === 1 ? ordinary.promise : refresh.promise; + }; + + const stale = resolveLatestVersionCached({ lookup }); + const firstRefresh = resolveLatestVersionCached({ lookup, bypassCache: true }); + const secondRefresh = resolveLatestVersionCached({ lookup, bypassCache: true }); + assert.equal(calls, 2); + + ordinary.resolve("3.8.49"); + refresh.resolve("3.8.50"); + assert.equal(await stale, "3.8.49"); + assert.deepEqual(await Promise.all([firstRefresh, secondRefresh]), ["3.8.50", "3.8.50"]); + assert.equal(await resolveLatestVersionCached({ lookup }), "3.8.50"); + assert.equal(calls, 2); +}); + +test("no-store refresh does not populate the process cache", async () => { + let calls = 0; + const lookup = async () => `3.8.${++calls + 49}`; + + assert.equal( + await resolveLatestVersionCached({ lookup, bypassCache: true, storeResult: false }), + "3.8.50" + ); + assert.equal(await resolveLatestVersionCached({ lookup }), "3.8.51"); + assert.equal(calls, 2); +}); + +test("cache invalidation prevents an older in-flight result from repopulating the cache", async () => { + let calls = 0; + const pending = deferred(); + const first = resolveLatestVersionCached({ + lookup: () => { + calls += 1; + return pending.promise; + }, + }); + + clearLatestVersionCache(); + pending.resolve("3.8.50"); + assert.equal(await first, "3.8.50"); + assert.equal( + await resolveLatestVersionCached({ lookup: async () => `3.8.${++calls + 49}` }), + "3.8.51" + ); +}); + +test("failed and unavailable lookups are not cached and leave no stale singleflight", async () => { + let calls = 0; + const lookup = async () => { + calls += 1; + if (calls === 1) throw new Error("lookup failed"); + if (calls === 2) return null; + return "3.8.50"; + }; + + await assert.rejects(resolveLatestVersionCached({ lookup }), /lookup failed/); + assert.equal(await resolveLatestVersionCached({ lookup }), null); + assert.equal(await resolveLatestVersionCached({ lookup }), "3.8.50"); + assert.equal(await resolveLatestVersionCached({ lookup }), "3.8.50"); + assert.equal(calls, 3); +}); diff --git a/tests/unit/system-version-check-4100.test.ts b/tests/unit/system-version-check-4100.test.ts index b903302180..9f25b2806a 100644 --- a/tests/unit/system-version-check-4100.test.ts +++ b/tests/unit/system-version-check-4100.test.ts @@ -17,6 +17,7 @@ import { isNewer, resolveLatestVersion, getLatestVersionFromGitHub, + getLatestVersionFromRegistry, } from "@/lib/system/versionCheck"; test("normalizeVersion strips v-prefix, pre-release/build, returns numeric tuple", () => { @@ -120,3 +121,37 @@ test("getLatestVersionFromGitHub returns null on a non-OK response", async () => new Response("rate limited", { status: 403 })) as unknown as typeof fetch; assert.equal(await getLatestVersionFromGitHub(fakeFetch), null); }); + +test("HTTP version sources reject oversized bodies without parsing them", async () => { + let registryCancelled = false; + let githubCancelled = false; + const oversizedResponse = (onCancel: () => void) => + new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(32 * 1024)); + }, + cancel() { + onCancel(); + }, + }), + { status: 200 } + ); + + assert.equal( + await getLatestVersionFromRegistry((async () => + oversizedResponse(() => { + registryCancelled = true; + })) as unknown as typeof fetch), + null + ); + assert.equal( + await getLatestVersionFromGitHub((async () => + oversizedResponse(() => { + githubCancelled = true; + })) as unknown as typeof fetch), + null + ); + assert.equal(registryCancelled, true); + assert.equal(githubCancelled, true); +});