From 28b8f383ca02da3eab75fae68f1ff29fa9b705c0 Mon Sep 17 00:00:00 2001 From: MK Date: Thu, 16 Apr 2026 10:16:38 +0800 Subject: [PATCH 1/5] fix: replace GitHub API with npm registry for version resolution The GitHub API was hitting 403 Forbidden errors due to rate limiting. Since download URLs are deterministic (constructable from tag alone), the GitHub API is unnecessary. Now specific tags resolve instantly with zero network calls, and the latest version is resolved via the npm registry dist-tags. --- README.md | 7 +- routes/index.ts | 123 +++++------------------------ tests/index.test.ts | 188 +------------------------------------------- 3 files changed, 24 insertions(+), 294 deletions(-) diff --git a/README.md b/README.md index a286a24..901853f 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,6 @@ Deployed to [Cloudflare Workers](https://workers.cloudflare.com/) via [Void](htt ### Environment variables -| Variable | Required | Description | -| -------------- | -------- | ---------------------------------------------------------- | -| `GITHUB_TOKEN` | No | GitHub token for higher API rate limits (60/hr -> 5000/hr) | -| `VOID_TOKEN` | Yes (CI) | Void deployment token | +| Variable | Required | Description | +| ------------ | -------- | --------------------- | +| `VOID_TOKEN` | Yes (CI) | Void deployment token | diff --git a/routes/index.ts b/routes/index.ts index 677ef20..2b367aa 100644 --- a/routes/index.ts +++ b/routes/index.ts @@ -11,7 +11,6 @@ const ASSET_NAMES: Record = { arm64: "vp-setup-aarch64-pc-windows-msvc.exe", }; const LATEST_CACHE_TTL = 300; // 5 minutes -const TAGGED_CACHE_TTL = 86400; // 24 hours const DEFAULT_DIST_TAG = "latest"; type Arch = "x64" | "arm64"; @@ -21,11 +20,6 @@ interface CachedRelease { assets: Partial>; } -interface GitHubRelease { - tag_name: string; - assets: Array<{ name: string; browser_download_url: string }>; -} - export function detectArch( queryArch: string | undefined, userAgent: string | undefined, @@ -40,58 +34,6 @@ export function detectArch( return "x64"; } -export function parseRelease(release: GitHubRelease): CachedRelease | null { - const assets: Partial> = {}; - for (const asset of release.assets) { - if (asset.name === ASSET_NAMES.x64) assets.x64 = asset.browser_download_url; - if (asset.name === ASSET_NAMES.arm64) assets.arm64 = asset.browser_download_url; - } - if (!assets.x64 && !assets.arm64) return null; - return { tag: release.tag_name, assets }; -} - -async function fetchGitHub(path: string, githubToken: string | undefined): Promise { - const headers: Record = { - Accept: "application/vnd.github.v3+json", - "User-Agent": "vp-setup-exe-downloader", - }; - if (githubToken) headers.Authorization = `Bearer ${githubToken}`; - return fetch(`https://api.github.com${path}`, { headers }); -} - -// "not-found" means the tag definitively doesn't exist (GitHub 404). -// null means the API failed (rate limit, network error) — fallbacks may still work. -export async function fetchRelease( - tag: string | undefined, - githubToken: string | undefined, -): Promise { - if (tag) { - const res = await fetchGitHub( - `/repos/${GITHUB_OWNER}/${GITHUB_REPO}/releases/tags/${tag}`, - githubToken, - ); - if (!res.ok) { - console.error(`GitHub API error: ${res.status} ${res.statusText} for tag ${tag}`); - return res.status === 404 ? "not-found" : null; - } - return (await res.json()) as GitHubRelease; - } - - const version = await fetchNpmDistTagVersion(DEFAULT_DIST_TAG); - if (!version) return null; - const res = await fetchGitHub( - `/repos/${GITHUB_OWNER}/${GITHUB_REPO}/releases/tags/v${version}`, - githubToken, - ); - if (!res.ok) { - console.error(`GitHub API error: ${res.status} ${res.statusText} for default tag v${version}`); - // Treat all failures (incl. 404) as transient so getRelease doesn't cache a negative - // under `release:latest` when npm/GitHub are momentarily out of sync. - return null; - } - return (await res.json()) as GitHubRelease; -} - export function buildReleaseFromTag(tag: string): CachedRelease { const base = `https://github.com/${GITHUB_OWNER}/${GITHUB_REPO}/releases/download/${tag}`; return { @@ -120,55 +62,29 @@ async function fetchNpmDistTagVersion(distTag: string): Promise { } } -function cacheKey(tag: string | undefined): string { - return tag ? `release:tag:${tag}` : "release:latest"; -} +const LATEST_CACHE_KEY = "release:latest"; +const LATEST_STALE_KEY = "release:latest:stale"; -function staleCacheKey(tag: string | undefined): string { - return `${cacheKey(tag)}:stale`; -} +async function getRelease(tag: string | undefined): Promise { + // Tags are immutable — construct the download URL directly, no network or cache needed + if (tag) return buildReleaseFromTag(tag); -async function getRelease( - tag: string | undefined, - githubToken: string | undefined, -): Promise { - const key = cacheKey(tag); - const cached = await kv.get(key); + // "Latest" path: use KV cache to avoid hitting npm on every request + const cached = await kv.get(LATEST_CACHE_KEY); if (cached) return cached; - try { - const release = await fetchRelease(tag, githubToken); - if (release === "not-found") { - // Cache the negative result to avoid repeated API calls for the same bad tag - await kv.put(key, null, { ttl: LATEST_CACHE_TTL }); - return null; - } - if (release) { - const parsed = parseRelease(release); - if (parsed) { - const ttl = tag ? TAGGED_CACHE_TTL : LATEST_CACHE_TTL; - const staleTtl = ttl + 3600; - await Promise.all([ - kv.put(key, parsed, { ttl }), - kv.put(staleCacheKey(tag), parsed, { ttl: staleTtl }), - ]); - return parsed; - } - } - } catch (err) { - console.error("Failed to fetch release from GitHub:", err); - } - - // Fallback 1: stale KV cache - const stale = await kv.get(staleCacheKey(tag)); - if (stale) return stale; - - // Fallback 2: construct download URLs from tag or npm registry version - if (tag) return buildReleaseFromTag(tag); const version = await fetchNpmDistTagVersion(DEFAULT_DIST_TAG); - if (version) return buildReleaseFromTag(`v${version}`); + if (version) { + const release = buildReleaseFromTag(`v${version}`); + await Promise.all([ + kv.put(LATEST_CACHE_KEY, release, { ttl: LATEST_CACHE_TTL }), + kv.put(LATEST_STALE_KEY, release, { ttl: LATEST_CACHE_TTL + 3600 }), + ]); + return release; + } - return null; + // npm unreachable — fall back to stale cache + return kv.get(LATEST_STALE_KEY); } function escapeHtml(s: string): string { @@ -306,7 +222,6 @@ setupDownloadLink(); export const GET = defineHandler(async (c) => { const queryArch = c.req.query("arch"); const tag = c.req.query("tag"); - const githubToken = c.env.GITHUB_TOKEN as string | undefined; // When ?arch= is specified, redirect directly (backward-compatible for CLI/curl) if (queryArch) { @@ -314,7 +229,7 @@ export const GET = defineHandler(async (c) => { if (arch === null) { return c.json({ error: "Invalid architecture. Use 'x64' or 'arm64'" }, 400); } - const release = await getRelease(tag || undefined, githubToken); + const release = await getRelease(tag || undefined); if (!release) { return c.json({ error: tag ? `Release '${tag}' not found` : "No release found" }, 404); } @@ -326,7 +241,7 @@ export const GET = defineHandler(async (c) => { } // Serve the download page with client-side architecture detection - const release = await getRelease(tag || undefined, githubToken); + const release = await getRelease(tag || undefined); if (!release) { return c.json({ error: tag ? `Release '${tag}' not found` : "No release found" }, 404); } diff --git a/tests/index.test.ts b/tests/index.test.ts index f84ff3c..15090a6 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -1,5 +1,5 @@ -import { afterEach, describe, expect, it, vi } from "vite-plus/test"; -import { buildReleaseFromTag, detectArch, fetchRelease, parseRelease } from "../routes/index"; +import { describe, expect, it } from "vite-plus/test"; +import { buildReleaseFromTag, detectArch } from "../routes/index"; describe("detectArch", () => { it("defaults to x64 when no query param or user-agent", () => { @@ -64,62 +64,6 @@ describe("detectArch", () => { }); }); -describe("parseRelease", () => { - it("parses both x64 and arm64 assets", () => { - const result = parseRelease({ - tag_name: "v0.1.17-alpha.0", - assets: [ - { - name: "vp-setup-x86_64-pc-windows-msvc.exe", - browser_download_url: - "https://github.com/voidzero-dev/vite-plus/releases/download/v0.1.17-alpha.0/vp-setup-x86_64-pc-windows-msvc.exe", - }, - { - name: "vp-setup-aarch64-pc-windows-msvc.exe", - browser_download_url: - "https://github.com/voidzero-dev/vite-plus/releases/download/v0.1.17-alpha.0/vp-setup-aarch64-pc-windows-msvc.exe", - }, - ], - }); - expect(result).toEqual({ - tag: "v0.1.17-alpha.0", - assets: { - x64: "https://github.com/voidzero-dev/vite-plus/releases/download/v0.1.17-alpha.0/vp-setup-x86_64-pc-windows-msvc.exe", - arm64: - "https://github.com/voidzero-dev/vite-plus/releases/download/v0.1.17-alpha.0/vp-setup-aarch64-pc-windows-msvc.exe", - }, - }); - }); - - it("returns null when no matching assets exist", () => { - const result = parseRelease({ - tag_name: "v1.0.0", - assets: [ - { - name: "some-other-file.tar.gz", - browser_download_url: "https://example.com/other.tar.gz", - }, - ], - }); - expect(result).toBeNull(); - }); - - it("handles release with only x64 asset", () => { - const result = parseRelease({ - tag_name: "v0.1.0", - assets: [ - { - name: "vp-setup-x86_64-pc-windows-msvc.exe", - browser_download_url: "https://example.com/x64.exe", - }, - ], - }); - expect(result).not.toBeNull(); - expect(result!.assets.x64).toBe("https://example.com/x64.exe"); - expect(result!.assets.arm64).toBeUndefined(); - }); -}); - describe("buildReleaseFromTag", () => { it("constructs download URLs from a tag", () => { const result = buildReleaseFromTag("v0.1.17-alpha.0"); @@ -133,131 +77,3 @@ describe("buildReleaseFromTag", () => { }); }); }); - -describe("fetchRelease", () => { - afterEach(() => vi.unstubAllGlobals()); - - it('returns "not-found" when GitHub returns 404 for a tag', async () => { - vi.stubGlobal( - "fetch", - vi - .fn() - .mockResolvedValue(new Response("Not Found", { status: 404, statusText: "Not Found" })), - ); - const result = await fetchRelease("not-exists", undefined); - expect(result).toBe("not-found"); - }); - - it("returns null when GitHub returns 403 (rate limited) for a tag", async () => { - vi.stubGlobal( - "fetch", - vi - .fn() - .mockResolvedValue(new Response("Forbidden", { status: 403, statusText: "Forbidden" })), - ); - const result = await fetchRelease("v1.0.0", undefined); - expect(result).toBeNull(); - }); - - it("returns null when GitHub returns 500 for a tag", async () => { - vi.stubGlobal( - "fetch", - vi.fn().mockResolvedValue( - new Response("Internal Server Error", { - status: 500, - statusText: "Internal Server Error", - }), - ), - ); - const result = await fetchRelease("v1.0.0", undefined); - expect(result).toBeNull(); - }); - - it("returns the release when GitHub returns 200 for a tag", async () => { - const release = { - tag_name: "v1.0.0", - assets: [ - { - name: "vp-setup-x86_64-pc-windows-msvc.exe", - browser_download_url: "https://example.com/x64.exe", - }, - ], - }; - vi.stubGlobal( - "fetch", - vi.fn().mockResolvedValue(new Response(JSON.stringify(release), { status: 200 })), - ); - const result = await fetchRelease("v1.0.0", undefined); - expect(result).toEqual(release); - }); - - it("default path: resolves via npm latest dist-tag then fetches that GitHub tag", async () => { - const release = { - tag_name: "v0.1.17", - assets: [ - { - name: "vp-setup-x86_64-pc-windows-msvc.exe", - browser_download_url: - "https://github.com/voidzero-dev/vite-plus/releases/download/v0.1.17/vp-setup-x86_64-pc-windows-msvc.exe", - }, - ], - }; - const fetchMock = vi - .fn() - .mockResolvedValueOnce( - new Response(JSON.stringify({ "dist-tags": { latest: "0.1.17" } }), { status: 200 }), - ) - .mockResolvedValueOnce(new Response(JSON.stringify(release), { status: 200 })); - vi.stubGlobal("fetch", fetchMock); - - const result = await fetchRelease(undefined, undefined); - - expect(result).toEqual(release); - expect(fetchMock).toHaveBeenCalledTimes(2); - const secondCallUrl = fetchMock.mock.calls[1][0]; - expect(secondCallUrl).toContain("/releases/tags/v0.1.17"); - }); - - it("default path: returns null and skips GitHub when npm registry fails", async () => { - const fetchMock = vi - .fn() - .mockResolvedValueOnce( - new Response("Service Unavailable", { status: 503, statusText: "Service Unavailable" }), - ); - vi.stubGlobal("fetch", fetchMock); - - const result = await fetchRelease(undefined, undefined); - - expect(result).toBeNull(); - expect(fetchMock).toHaveBeenCalledTimes(1); - }); - - it("default path: returns null and skips GitHub when npm has no latest dist-tag", async () => { - const fetchMock = vi - .fn() - .mockResolvedValueOnce( - new Response(JSON.stringify({ "dist-tags": { alpha: "0.1.17-alpha.5" } }), { status: 200 }), - ); - vi.stubGlobal("fetch", fetchMock); - - const result = await fetchRelease(undefined, undefined); - - expect(result).toBeNull(); - expect(fetchMock).toHaveBeenCalledTimes(1); - }); - - it('default path: returns null (not "not-found") when GitHub 404s the resolved tag', async () => { - const fetchMock = vi - .fn() - .mockResolvedValueOnce( - new Response(JSON.stringify({ "dist-tags": { latest: "0.1.17" } }), { status: 200 }), - ) - .mockResolvedValueOnce(new Response("Not Found", { status: 404, statusText: "Not Found" })); - vi.stubGlobal("fetch", fetchMock); - - const result = await fetchRelease(undefined, undefined); - - expect(result).toBeNull(); - expect(fetchMock).toHaveBeenCalledTimes(2); - }); -}); From 8183f1ba627f2f8b1fbd439ff281a8130dc68ed8 Mon Sep 17 00:00:00 2001 From: MK Date: Thu, 16 Apr 2026 10:23:49 +0800 Subject: [PATCH 2/5] fix: update staging deploy PR comment instead of creating duplicates Include commit SHA in the comment body and reuse the existing comment on subsequent deploys instead of flooding the PR with new ones. --- .github/workflows/ci.yml | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8d29ab0..10ef496 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,9 +44,26 @@ jobs: uses: actions/github-script@v7 with: script: | - github.rest.issues.createComment({ + const marker = ''; + const body = `${marker}\n✅ Staging deployment successful!\n\nPreview: https://vp-setup-staging.void.app/\nCommit: ${context.sha}`; + const { data: comments } = await github.rest.issues.listComments({ owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number, - body: '✅ Staging deployment successful!\n\nPreview: https://vp-setup-staging.void.app/' - }) + }); + const existing = comments.find(c => c.body.includes(marker)); + if (existing) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body, + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body, + }); + } From aaf3c1b6ec49382fd87704c48098fb2b317bd990 Mon Sep 17 00:00:00 2001 From: MK Date: Thu, 16 Apr 2026 10:34:37 +0800 Subject: [PATCH 3/5] fix: address security and resilience review findings - Fix reflected XSS: replace innerHTML with DOM APIs in ARM64 link swap - Verify GitHub asset exists (HEAD probe) before caching npm-resolved version, preventing broken redirects during npm/GitHub sync windows - Make KV cache writes best-effort so transient KV errors don't fail otherwise valid requests - Use github.paginate for staging deploy PR comment lookup to handle PRs with many comments --- .github/workflows/ci.yml | 2 +- routes/index.ts | 28 +++++++++++++++++++++++----- 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 10ef496..369cb99 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,7 +46,7 @@ jobs: script: | const marker = ''; const body = `${marker}\n✅ Staging deployment successful!\n\nPreview: https://vp-setup-staging.void.app/\nCommit: ${context.sha}`; - const { data: comments } = await github.rest.issues.listComments({ + const comments = await github.paginate(github.rest.issues.listComments, { owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number, diff --git a/routes/index.ts b/routes/index.ts index 2b367aa..3d03e30 100644 --- a/routes/index.ts +++ b/routes/index.ts @@ -76,10 +76,23 @@ async function getRelease(tag: string | undefined): Promise(LATEST_STALE_KEY); + } + } catch { + // Network error checking asset — still serve the release, cache writes are best-effort below + } + try { + await Promise.all([ + kv.put(LATEST_CACHE_KEY, release, { ttl: LATEST_CACHE_TTL }), + kv.put(LATEST_STALE_KEY, release, { ttl: LATEST_CACHE_TTL + 3600 }), + ]); + } catch (err) { + console.error("KV write failed:", err); + } return release; } @@ -207,7 +220,12 @@ async function setupDownloadLink() { mainBtn.textContent = "Download for Windows (ARM64)"; if (altEl && x64Url) { - altEl.innerHTML = 'Also available: Windows x64'; + var link = document.createElement("a"); + link.href = x64Url; + link.download = ""; + link.textContent = "Windows x64"; + altEl.textContent = "Also available: "; + altEl.appendChild(link); } } From 0d98741294934acb57fd9bfa360d458297299b5f Mon Sep 17 00:00:00 2001 From: MK Date: Thu, 16 Apr 2026 10:35:24 +0800 Subject: [PATCH 4/5] refactor: remove unnecessary HEAD probe for asset verification --- routes/index.ts | 9 --------- 1 file changed, 9 deletions(-) diff --git a/routes/index.ts b/routes/index.ts index 3d03e30..d90dd51 100644 --- a/routes/index.ts +++ b/routes/index.ts @@ -76,15 +76,6 @@ async function getRelease(tag: string | undefined): Promise(LATEST_STALE_KEY); - } - } catch { - // Network error checking asset — still serve the release, cache writes are best-effort below - } try { await Promise.all([ kv.put(LATEST_CACHE_KEY, release, { ttl: LATEST_CACHE_TTL }), From 39f2f3a9ba9c9e3a6fe0d1f14fc362936bee5e50 Mon Sep 17 00:00:00 2001 From: MK Date: Thu, 16 Apr 2026 10:36:21 +0800 Subject: [PATCH 5/5] test: add escapeHtml tests covering XSS vectors Verify that HTML special characters and malicious tag payloads like attribute-breaking injections are properly escaped. --- routes/index.ts | 2 +- tests/index.test.ts | 22 +++++++++++++++++++++- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/routes/index.ts b/routes/index.ts index d90dd51..b1a73c6 100644 --- a/routes/index.ts +++ b/routes/index.ts @@ -91,7 +91,7 @@ async function getRelease(tag: string | undefined): Promise(LATEST_STALE_KEY); } -function escapeHtml(s: string): string { +export function escapeHtml(s: string): string { return s .replace(/&/g, "&") .replace(/"/g, """) diff --git a/tests/index.test.ts b/tests/index.test.ts index 15090a6..6599e73 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vite-plus/test"; -import { buildReleaseFromTag, detectArch } from "../routes/index"; +import { buildReleaseFromTag, detectArch, escapeHtml } from "../routes/index"; describe("detectArch", () => { it("defaults to x64 when no query param or user-agent", () => { @@ -64,6 +64,26 @@ describe("detectArch", () => { }); }); +describe("escapeHtml", () => { + it("escapes HTML special characters", () => { + expect(escapeHtml('')).toBe( + "<script>alert("xss")</script>", + ); + }); + + it("escapes ampersands", () => { + expect(escapeHtml("a&b")).toBe("a&b"); + }); + + it("escapes a malicious tag used in attribute context", () => { + const malicious = 'x">'; + const escaped = escapeHtml(malicious); + expect(escaped).not.toContain("<"); + expect(escaped).not.toContain(">"); + expect(escaped).not.toContain('"'); + }); +}); + describe("buildReleaseFromTag", () => { it("constructs download URLs from a tag", () => { const result = buildReleaseFromTag("v0.1.17-alpha.0");