From 4d307b941e61f14dfb05a4d587d00a004a41f489 Mon Sep 17 00:00:00 2001 From: Adam Daley Date: Sun, 30 Aug 2026 19:09:47 +0100 Subject: [PATCH 1/4] Serve release downloads from R2 instead of GitHub for IPv6 hosts github.com and api.github.com have no AAAA record, so IPv6-only hosts (no NAT64) can check /versions/v1/latest for an update but can never download it. FOSSBilling/FOSSBilling now mirrors every release archive to R2 under download.fossbilling.org, which is IPv6-reachable. getReleases() now looks up each release's R2 mirror and prefers its download_url and digest (the digest is R2 custom metadata set by CI from the exact uploaded file, so it's read rather than recomputed). Releases without a mirror - published before this existed, or on a failed R2 lookup - fall back to the GitHub asset as before. Fixes FOSSBilling/FOSSBilling#2479 --- src/services/stats/v1/index.ts | 4 +- src/services/versions/v1/index.ts | 63 +++++++++++++++++-------- src/services/versions/v1/r2.ts | 27 +++++++++++ test/services/versions/v1/index.test.ts | 54 +++++++++++++++++++++ 4 files changed, 127 insertions(+), 21 deletions(-) create mode 100644 src/services/versions/v1/r2.ts diff --git a/src/services/stats/v1/index.ts b/src/services/stats/v1/index.ts index 9dd90739..6f3783fe 100644 --- a/src/services/stats/v1/index.ts +++ b/src/services/stats/v1/index.ts @@ -171,7 +171,9 @@ async function getStats( } } - const result = await getReleases(cache, githubToken, updateCache); + // Stats only aggregates sizes/PHP versions/dates, not download_url or + // digest, so there's no need to pay for an R2 lookup per release here. + const result = await getReleases(cache, githubToken, undefined, updateCache); if (hasNoReleases(result.releases) && result.error) { return { diff --git a/src/services/versions/v1/index.ts b/src/services/versions/v1/index.ts index b7d66041..765a4cf4 100644 --- a/src/services/versions/v1/index.ts +++ b/src/services/versions/v1/index.ts @@ -13,6 +13,7 @@ import { valid as semverValid } from "semver"; import { Releases, ReleaseDetails } from "./interfaces"; +import { getReleaseR2Object } from "./r2"; import { getPlatform } from "../../../lib/middleware"; import { ICache } from "../../../lib/interfaces"; import { logError, logWarn, logInfo } from "../../../lib/logger"; @@ -90,6 +91,7 @@ async function loadReleases( return getReleases( platform.getCache("CACHE_KV"), platform.getEnv("GITHUB_TOKEN") || "", + c.env.DOWNLOAD_BUCKET, updateCache ); } @@ -172,6 +174,7 @@ versionsV1.get( const result = await getReleases( platform.getCache("CACHE_KV"), platform.getEnv("GITHUB_TOKEN") || "", + c.env.DOWNLOAD_BUCKET, true ); const releaseCount = Object.keys(result.releases).length; @@ -343,6 +346,7 @@ export function resetUpdateTokenCache() { export async function getReleases( cache: ICache, githubToken: string, + downloadBucket?: R2Bucket, updateCache: boolean = false ): Promise { const cachedReleases = await cache.get(RELEASE_CACHE_KEY); @@ -469,26 +473,45 @@ export async function getReleases( } } - const releaseEntries: [string, ReleaseDetails][] = releasesToProcess.map( - ({ tag, release, zipAsset, cachedPhpVersion }) => { - const phpVersion = - cachedPhpVersion !== undefined - ? cachedPhpVersion - : (batchPhpVersions.get(tag) ?? ""); - - const releaseDetails: ReleaseDetails = { - version: release.name || tag, - released_on: release.published_at ?? "", - minimum_php_version: phpVersion, - download_url: zipAsset.browser_download_url, - size_bytes: zipAsset.size, - is_prerelease: Boolean(release.prerelease), - github_release_id: release.id ?? 0, - changelog: release.body || "", - digest: zipAsset.digest ?? null - }; - return [tag, releaseDetails]; - } + // Prefer the R2 mirror over the GitHub asset - github.com has no AAAA + // record, so IPv6-only hosts can't reach it. Fall back to GitHub for + // releases that predate mirroring or if the R2 lookup fails. + const releaseEntries: [string, ReleaseDetails][] = await Promise.all( + releasesToProcess.map( + async ({ tag, release, zipAsset, cachedPhpVersion }) => { + const phpVersion = + cachedPhpVersion !== undefined + ? cachedPhpVersion + : (batchPhpVersions.get(tag) ?? ""); + + let r2Object = null; + if (downloadBucket) { + try { + r2Object = await getReleaseR2Object(downloadBucket, tag); + } catch (r2Error) { + logWarn("versions", "Failed to look up release in R2", { + tag, + error: + r2Error instanceof Error ? r2Error.message : String(r2Error) + }); + } + } + + const releaseDetails: ReleaseDetails = { + version: release.name || tag, + released_on: release.published_at ?? "", + minimum_php_version: phpVersion, + download_url: + r2Object?.downloadUrl ?? zipAsset.browser_download_url, + size_bytes: zipAsset.size, + is_prerelease: Boolean(release.prerelease), + github_release_id: release.id ?? 0, + changelog: release.body || "", + digest: r2Object?.digest ?? zipAsset.digest ?? null + }; + return [tag, releaseDetails]; + } + ) ); const sortedReleases = Object.fromEntries( diff --git a/src/services/versions/v1/r2.ts b/src/services/versions/v1/r2.ts new file mode 100644 index 00000000..8d084be6 --- /dev/null +++ b/src/services/versions/v1/r2.ts @@ -0,0 +1,27 @@ +// FOSSBilling/FOSSBilling's `create-release.yml` mirrors every release +// archive to R2 in its "Upload Release Archive to R2" step - github.com +// has no AAAA record, so this mirror is what lets IPv6-only hosts (no +// NAT64) actually download an update. See FOSSBilling/FOSSBilling#2479. +const RELEASES_DOWNLOAD_BASE_URL = "https://download.fossbilling.org/releases"; + +export interface ReleaseR2Object { + downloadUrl: string; + digest: string | null; +} + +// `digest` is custom metadata the release workflow sets explicitly on the +// R2 upload (already carrying the "sha256:" prefix), computed once in CI +// from the exact uploaded file - see that repo's create-release.yml. +export async function getReleaseR2Object( + bucket: R2Bucket, + version: string +): Promise { + const archiveName = `FOSSBilling-${version}.zip`; + const object = await bucket.head(`releases/${version}/${archiveName}`); + if (!object) return null; + + return { + downloadUrl: `${RELEASES_DOWNLOAD_BASE_URL}/${version}/${archiveName}`, + digest: object.customMetadata?.digest ?? null + }; +} diff --git a/test/services/versions/v1/index.test.ts b/test/services/versions/v1/index.test.ts index 9ef45b03..11811095 100644 --- a/test/services/versions/v1/index.test.ts +++ b/test/services/versions/v1/index.test.ts @@ -51,6 +51,8 @@ describe("Versions API v1", () => { beforeEach(async () => { restoreConsole = suppressConsole(); await env.CACHE_KV.delete("gh-fossbilling-releases"); + await env.DOWNLOAD_BUCKET.delete("releases/0.5.0/FOSSBilling-0.5.0.zip"); + await env.DOWNLOAD_BUCKET.delete("releases/0.6.0/FOSSBilling-0.6.0.zip"); resetUpdateTokenCache(); const testUpdateToken = "test-update-token-12345"; @@ -217,6 +219,58 @@ describe("Versions API v1", () => { }); }); + describe("R2 release mirror", () => { + // github.com has no AAAA record, so IPv6-only hosts must download + // from the R2 mirror instead - see FOSSBilling/FOSSBilling#2479. + it("prefers the R2 mirror's download_url and digest when mirrored", async () => { + await env.DOWNLOAD_BUCKET.put( + "releases/0.6.0/FOSSBilling-0.6.0.zip", + "mirrored archive contents", + { + customMetadata: { + digest: + "sha256:deadbeefcafe0000000000000000000000000000000000000000000000000", + version: "0.6.0" + } + } + ); + + const ctx = createExecutionContext(); + const response = await app.request("/versions/v1/latest", {}, env, ctx); + await waitOnExecutionContext(ctx); + + expect(response.status).toBe(200); + const data: ApiResponse = await response.json(); + if (!data.result) { + throw new Error("Expected latest release data"); + } + expect(data.result.download_url).toBe( + "https://download.fossbilling.org/releases/0.6.0/FOSSBilling-0.6.0.zip" + ); + expect(data.result.digest).toBe( + "sha256:deadbeefcafe0000000000000000000000000000000000000000000000000" + ); + }); + + it("falls back to the GitHub asset when a release hasn't been mirrored to R2", async () => { + const ctx = createExecutionContext(); + const response = await app.request("/versions/v1/latest", {}, env, ctx); + await waitOnExecutionContext(ctx); + + expect(response.status).toBe(200); + const data: ApiResponse = await response.json(); + if (!data.result) { + throw new Error("Expected latest release data"); + } + expect(data.result.download_url).toBe( + "https://github.com/FOSSBilling/FOSSBilling/releases/download/0.6.0/FOSSBilling.zip" + ); + expect(data.result.digest).toBe( + "sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08" + ); + }); + }); + describe("GET /:version", () => { it("should return specific version", async () => { const ctx = createExecutionContext(); From eccba2fc0dac858930fb3e114e9d9ef429e5316f Mon Sep 17 00:00:00 2001 From: Adam Daley Date: Sun, 30 Aug 2026 19:31:18 +0100 Subject: [PATCH 2/4] Update test/services/versions/v1/index.test.ts Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> --- test/services/versions/v1/index.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/services/versions/v1/index.test.ts b/test/services/versions/v1/index.test.ts index 11811095..d097d460 100644 --- a/test/services/versions/v1/index.test.ts +++ b/test/services/versions/v1/index.test.ts @@ -229,7 +229,7 @@ describe("Versions API v1", () => { { customMetadata: { digest: - "sha256:deadbeefcafe0000000000000000000000000000000000000000000000000", + "sha256:deadbeefcafe0000000000000000000000000000000000000000000000000000", version: "0.6.0" } } From 7edd9ff4803613ff416e1b157619e3607e1495fa Mon Sep 17 00:00:00 2001 From: Adam Daley Date: Sun, 30 Aug 2026 20:06:06 +0100 Subject: [PATCH 3/4] Address PR review: shared cache poisoning, stale README, test typo - getReleases now requires downloadBucket instead of it being optional. stats/v1 was passing undefined, and since it writes to the exact same RELEASE_CACHE_KEY the versions service reads, a stats-triggered cache refresh could overwrite that shared cache with GitHub-only download URLs for up to 24h - silently undoing the R2 preference for IPv6-only hosts until something refreshed it again. stats/v1 now threads c.env.DOWNLOAD_BUCKET through so every writer resolves R2 consistently. - Fixed a test assertion left out of sync with a manually-edited mock digest (61 vs 64 hex chars) from the previous review round. - Updated the versions/v1 README: download_url/digest now documented as R2-preferred with a GitHub fallback, and DOWNLOAD_BUCKET is listed as a required binding. --- src/services/stats/v1/index.ts | 17 +++++++++++++---- src/services/versions/v1/README.md | 6 ++++-- src/services/versions/v1/index.ts | 20 +++++++++----------- test/services/versions/v1/index.test.ts | 2 +- 4 files changed, 27 insertions(+), 18 deletions(-) diff --git a/src/services/stats/v1/index.ts b/src/services/stats/v1/index.ts index 6f3783fe..9be5cd0d 100644 --- a/src/services/stats/v1/index.ts +++ b/src/services/stats/v1/index.ts @@ -142,6 +142,7 @@ function aggregateStats(releases: Releases): StatsData { async function getStats( cache: ICache, githubToken: string, + downloadBucket: R2Bucket, updateCache: boolean = false ): Promise<{ stats: StatsData; @@ -171,9 +172,16 @@ async function getStats( } } - // Stats only aggregates sizes/PHP versions/dates, not download_url or - // digest, so there's no need to pay for an R2 lookup per release here. - const result = await getReleases(cache, githubToken, undefined, updateCache); + // getReleases shares its cache with the versions service (same + // RELEASE_CACHE_KEY), so a fresh fetch here must still resolve R2 + // download_url/digest - otherwise a stats-triggered refresh would + // overwrite that cache with GitHub-only URLs for up to a day. + const result = await getReleases( + cache, + githubToken, + downloadBucket, + updateCache + ); if (hasNoReleases(result.releases) && result.error) { return { @@ -210,7 +218,8 @@ registerCachedRoute("/data", async (c) => { const platform = getPlatform(c); const result = await getStats( platform.getCache("CACHE_KV"), - platform.getEnv("GITHUB_TOKEN") || "" + platform.getEnv("GITHUB_TOKEN") || "", + c.env.DOWNLOAD_BUCKET ); if (result.error && result.stats.releaseSizes.length === 0) { diff --git a/src/services/versions/v1/README.md b/src/services/versions/v1/README.md index fb479cc5..d2ec8647 100644 --- a/src/services/versions/v1/README.md +++ b/src/services/versions/v1/README.md @@ -29,7 +29,7 @@ GET /versions/v1 "version": "0.5.0", "released_on": "2023-01-15T12:00:00Z", "minimum_php_version": "8.1", - "download_url": "https://github.com/FOSSBilling/FOSSBilling/releases/download/0.5.0/FOSSBilling.zip", + "download_url": "https://download.fossbilling.org/releases/0.5.0/FOSSBilling-0.5.0.zip", "size_bytes": 15485760, "is_prerelease": false, "github_release_id": 987654321, @@ -140,5 +140,7 @@ When GitHub is unavailable and no cached data exists: - All responses include a `stale` field that is `true` when cached data is served after a failed fetch. - `details` includes the GitHub HTTP status and error code when available. - `GITHUB_TOKEN` is required for GitHub API access. +- `DOWNLOAD_BUCKET` (R2 binding, shared with `previews/v1`) backs `download_url`/`digest` below. - Releases before 0.5.0 read `src/composer.json`; newer releases use `composer.json`. -- `digest` is the SHA-256 digest of the release zip asset (`sha256:`), computed by GitHub; `null` if GitHub has not computed one for that asset. +- `download_url` is `download.fossbilling.org` when the release has been mirrored to R2 - github.com has no AAAA record, so IPv6-only hosts can't reach a GitHub asset URL (see FOSSBilling/FOSSBilling#2479). It falls back to the GitHub asset URL for releases that predate mirroring. +- `digest` is the SHA-256 digest of the release zip (`sha256:`). It's read from the R2 object's metadata when mirrored (set by CI from the exact uploaded file); otherwise it's GitHub's asset digest, or `null` if GitHub hasn't computed one. diff --git a/src/services/versions/v1/index.ts b/src/services/versions/v1/index.ts index 765a4cf4..59e0db81 100644 --- a/src/services/versions/v1/index.ts +++ b/src/services/versions/v1/index.ts @@ -346,7 +346,7 @@ export function resetUpdateTokenCache() { export async function getReleases( cache: ICache, githubToken: string, - downloadBucket?: R2Bucket, + downloadBucket: R2Bucket, updateCache: boolean = false ): Promise { const cachedReleases = await cache.get(RELEASE_CACHE_KEY); @@ -485,16 +485,14 @@ export async function getReleases( : (batchPhpVersions.get(tag) ?? ""); let r2Object = null; - if (downloadBucket) { - try { - r2Object = await getReleaseR2Object(downloadBucket, tag); - } catch (r2Error) { - logWarn("versions", "Failed to look up release in R2", { - tag, - error: - r2Error instanceof Error ? r2Error.message : String(r2Error) - }); - } + try { + r2Object = await getReleaseR2Object(downloadBucket, tag); + } catch (r2Error) { + logWarn("versions", "Failed to look up release in R2", { + tag, + error: + r2Error instanceof Error ? r2Error.message : String(r2Error) + }); } const releaseDetails: ReleaseDetails = { diff --git a/test/services/versions/v1/index.test.ts b/test/services/versions/v1/index.test.ts index d097d460..98a34f1d 100644 --- a/test/services/versions/v1/index.test.ts +++ b/test/services/versions/v1/index.test.ts @@ -248,7 +248,7 @@ describe("Versions API v1", () => { "https://download.fossbilling.org/releases/0.6.0/FOSSBilling-0.6.0.zip" ); expect(data.result.digest).toBe( - "sha256:deadbeefcafe0000000000000000000000000000000000000000000000000" + "sha256:deadbeefcafe0000000000000000000000000000000000000000000000000000" ); }); From 09b394f4da5fb399b957187c2d14d14ec4e3f42a Mon Sep 17 00:00:00 2001 From: Adam Daley Date: Sun, 30 Aug 2026 20:14:34 +0100 Subject: [PATCH 4/4] Address second review round: README example, regression test - The GET / example in the versions README keyed its sample release as 0.5.0, which (per the fallback note added last commit) would predate R2 mirroring and show a GitHub URL - contradicting the R2 download_url shown right next to it. Swapped the example to 0.6.0. - Added a regression test for the stats/v1 cache-poisoning fix: seeds DOWNLOAD_BUCKET, forces /stats/v1/data through a fresh release fetch, and asserts the shared gh-fossbilling-releases cache it writes carries the R2 download_url/digest rather than the GitHub asset. Confirmed it fails against the pre-fix code (undefined bucket) before restoring the real fix. --- src/services/versions/v1/README.md | 10 +++---- test/services/stats/v1/index.test.ts | 42 ++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 5 deletions(-) diff --git a/src/services/versions/v1/README.md b/src/services/versions/v1/README.md index d2ec8647..13b61e4f 100644 --- a/src/services/versions/v1/README.md +++ b/src/services/versions/v1/README.md @@ -25,15 +25,15 @@ GET /versions/v1 ```json { "result": { - "0.5.0": { - "version": "0.5.0", - "released_on": "2023-01-15T12:00:00Z", + "0.6.0": { + "version": "0.6.0", + "released_on": "2023-04-01T00:00:00Z", "minimum_php_version": "8.1", - "download_url": "https://download.fossbilling.org/releases/0.5.0/FOSSBilling-0.5.0.zip", + "download_url": "https://download.fossbilling.org/releases/0.6.0/FOSSBilling-0.6.0.zip", "size_bytes": 15485760, "is_prerelease": false, "github_release_id": 987654321, - "changelog": "## 0.5.0\n- Major feature updates...", + "changelog": "## 0.6.0\n- New features...", "digest": "sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08" } }, diff --git a/test/services/stats/v1/index.test.ts b/test/services/stats/v1/index.test.ts index fae49f32..d2e5dfa4 100644 --- a/test/services/stats/v1/index.test.ts +++ b/test/services/stats/v1/index.test.ts @@ -54,6 +54,7 @@ describe("Stats API v1", () => { restoreConsole = suppressConsole(); await env.CACHE_KV.delete("gh-fossbilling-releases"); await env.CACHE_KV.delete("fossbilling-stats-data"); + await env.DOWNLOAD_BUCKET.delete("releases/0.6.0/FOSSBilling-0.6.0.zip"); const testUpdateToken = "test-update-token-12345"; await env.AUTH_KV.put("UPDATE_TOKEN", testUpdateToken); @@ -258,6 +259,47 @@ describe("Stats API v1", () => { }); }); + describe("Shared release cache", () => { + // getReleases writes gh-fossbilling-releases - the same cache key the + // versions service reads - so a stats-triggered fresh fetch must still + // resolve R2 download_url/digest. Otherwise stats would overwrite that + // cache with GitHub-only URLs for up to 24h, silently undoing the R2 + // preference for IPv6-only hosts. See FOSSBilling/FOSSBilling#2479. + it("resolves R2 download_url/digest when it triggers the shared release fetch", async () => { + await env.DOWNLOAD_BUCKET.put( + "releases/0.6.0/FOSSBilling-0.6.0.zip", + "mirrored archive contents", + { + customMetadata: { + digest: + "sha256:deadbeefcafe0000000000000000000000000000000000000000000000000000", + version: "0.6.0" + } + } + ); + + const ctx = createExecutionContext(); + const response = await app.fetch( + new Request("http://localhost/stats/v1/data"), + env, + ctx + ); + await waitOnExecutionContext(ctx); + expect(response.status).toBe(200); + + const cached = await env.CACHE_KV.get("gh-fossbilling-releases"); + expect(cached).toBeTruthy(); + const releases = JSON.parse(cached!); + + expect(releases["0.6.0"].download_url).toBe( + "https://download.fossbilling.org/releases/0.6.0/FOSSBilling-0.6.0.zip" + ); + expect(releases["0.6.0"].digest).toBe( + "sha256:deadbeefcafe0000000000000000000000000000000000000000000000000000" + ); + }); + }); + describe("GET /stats/v1/", () => { it("should return HTML page", async () => { const ctx = createExecutionContext();