mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-07 07:42:13 +03:00
Co-authored-by: Ravi Tharuma <RaviTharuma@users.noreply.github.com> Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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<string | null> | null = null;
|
||||
let latestVersionRefresh: Promise<string | null> | 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<string | null> {
|
||||
* 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<unknown> {
|
||||
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<string | null> {
|
||||
@@ -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<string | null>;
|
||||
bypassCache?: boolean;
|
||||
storeResult?: boolean;
|
||||
now?: () => number;
|
||||
ttlMs?: number;
|
||||
}): Promise<string | null> {
|
||||
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<string | null>;
|
||||
registry?: () => Promise<string | null>;
|
||||
|
||||
114
tests/unit/system-version-cache-8278.test.ts
Normal file
114
tests/unit/system-version-cache-8278.test.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
clearLatestVersionCache,
|
||||
resolveLatestVersionCached,
|
||||
} from "@/lib/system/versionCheck";
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((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<string | null>();
|
||||
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<string | null>();
|
||||
const refresh = deferred<string | null>();
|
||||
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<string | null>();
|
||||
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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user