diff --git a/src/services/stats/v1/index.ts b/src/services/stats/v1/index.ts index 9dd90739..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,7 +172,16 @@ async function getStats( } } - const result = await getReleases(cache, githubToken, 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 { @@ -208,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..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://github.com/FOSSBilling/FOSSBilling/releases/download/0.5.0/FOSSBilling.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" } }, @@ -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 b7d66041..59e0db81 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,43 @@ 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; + 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/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(); diff --git a/test/services/versions/v1/index.test.ts b/test/services/versions/v1/index.test.ts index 9ef45b03..98a34f1d 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:deadbeefcafe0000000000000000000000000000000000000000000000000000", + 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:deadbeefcafe0000000000000000000000000000000000000000000000000000" + ); + }); + + 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();