diff --git a/src/services/versions/v1/README.md b/src/services/versions/v1/README.md index 13b61e4..59ef666 100644 --- a/src/services/versions/v1/README.md +++ b/src/services/versions/v1/README.md @@ -142,5 +142,5 @@ When GitHub is unavailable and no cached data exists: - `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`. -- `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. +- `download_url` is `download.fossbilling.org` when the release has been mirrored to R2 and the requesting client's own version trusts that host (see `Update::$allowedDownloadPrefixes` in FOSSBilling/FOSSBilling) - github.com has no AAAA record, so IPv6-only hosts can't reach a GitHub asset URL. It falls back to the GitHub asset URL otherwise. Mirroring began at 0.8.0, so releases before that are never looked up in R2 at all. - `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 2948dbc..3d195f3 100644 --- a/src/services/versions/v1/index.ts +++ b/src/services/versions/v1/index.ts @@ -138,6 +138,14 @@ const USER_AGENT_VERSION_PATTERN = /^FOSSBilling\/(.+)$/; // incident following #4255/#2479. const MIRROR_TRUST_MIN_VERSION = "0.8.7"; +// First release actually mirrored to R2 - create-release.yml only started +// uploading archives there from 0.8.0 onward, and that's a one-time step +// run at release-publish time, so nothing before it will ever appear in +// the bucket. Not to be confused with MIRROR_TRUST_MIN_VERSION above: this +// is "does a mirror exist to look up", that's "does the requesting client +// trust it". +const R2_MIRROR_MIN_VERSION = "0.8.0"; + function clientTrustsMirror(userAgent: string | undefined | null): boolean { if (!userAgent) return false; @@ -552,15 +560,22 @@ export async function getReleases( ? cachedPhpVersion : (batchPhpVersions.get(tag) ?? ""); + // Releases before R2_MIRROR_MIN_VERSION were never uploaded to R2 + // and never will be, so skip the lookup rather than issuing a + // HeadObject that's guaranteed to miss - Cloudflare's own R2 + // binding instrumentation logs every miss as an error-level span, + // regardless of how we handle the resulting null here. 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) - }); + if (semverGte(tag, R2_MIRROR_MIN_VERSION)) { + 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/mocks/github-releases.ts b/test/mocks/github-releases.ts index f50614a..e06c2de 100644 --- a/test/mocks/github-releases.ts +++ b/test/mocks/github-releases.ts @@ -67,6 +67,29 @@ export const mockGitHubReleases = [ } ]; +// A release at/after R2_MIRROR_MIN_VERSION (0.8.0, where mirroring began) - +// mockGitHubReleases above tops out at 0.6.0, below that cutoff, so R2 +// mirror tests in versions/v1 and stats/v1 spread this on top of it rather +// than duplicating the literal in each file. +export const mockMirroredRelease = { + id: 1010, + tag_name: "0.8.0", + name: "0.8.0", + published_at: "2023-05-01T00:00:00Z", + prerelease: false, + body: "## 0.8.0\n- First mirrored release", + assets: [ + { + name: "FOSSBilling.zip", + browser_download_url: + "https://github.com/FOSSBilling/FOSSBilling/releases/download/0.8.0/FOSSBilling.zip", + size: 1040000, + digest: + "sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08" + } + ] +}; + export const mockComposerJson = { require: { php: "^8.1" diff --git a/test/services/stats/v1/index.test.ts b/test/services/stats/v1/index.test.ts index 2350f98..2241a83 100644 --- a/test/services/stats/v1/index.test.ts +++ b/test/services/stats/v1/index.test.ts @@ -7,7 +7,8 @@ import { import app from "../../../../src/app"; import { mockGitHubReleases, - mockComposerJson + mockComposerJson, + mockMirroredRelease } from "../../../mocks/github-releases"; import { suppressConsole, @@ -54,7 +55,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"); + await env.DOWNLOAD_BUCKET.delete("releases/0.8.0/FOSSBilling-0.8.0.zip"); const testUpdateToken = "test-update-token-12345"; await env.AUTH_KV.put("UPDATE_TOKEN", testUpdateToken); @@ -272,14 +273,23 @@ describe("Stats API v1", () => { // service based on that client's own reported version, not baked into // this shared cache - see resolveReleaseForClient() there. it("resolves both the GitHub and R2 mirror URLs when it triggers the shared release fetch", async () => { + // Mirroring began at 0.8.0 (R2_MIRROR_MIN_VERSION in versions/v1) - + // nothing in the shared mockGitHubReleases fixture (which tops out at + // 0.6.0) is eligible, so inject mockMirroredRelease on top of it. + setupGitHubApiMock( + vi.mocked(ghRequest) as MockGitHubRequest, + vi.mocked(graphql) as unknown as MockGitHubGraphQL, + [...mockGitHubReleases, mockMirroredRelease], + mockComposerJson + ); await env.DOWNLOAD_BUCKET.put( - "releases/0.6.0/FOSSBilling-0.6.0.zip", + "releases/0.8.0/FOSSBilling-0.8.0.zip", "mirrored archive contents", { customMetadata: { digest: "sha256:deadbeefcafe0000000000000000000000000000000000000000000000000000", - version: "0.6.0" + version: "0.8.0" } } ); @@ -297,13 +307,13 @@ describe("Stats API v1", () => { expect(cached).toBeTruthy(); const releases = JSON.parse(cached!); - expect(releases["0.6.0"].download_url).toBe( - "https://github.com/FOSSBilling/FOSSBilling/releases/download/0.6.0/FOSSBilling.zip" + expect(releases["0.8.0"].download_url).toBe( + "https://github.com/FOSSBilling/FOSSBilling/releases/download/0.8.0/FOSSBilling.zip" ); - expect(releases["0.6.0"].mirror_download_url).toBe( - "https://download.fossbilling.org/releases/0.6.0/FOSSBilling-0.6.0.zip" + expect(releases["0.8.0"].mirror_download_url).toBe( + "https://download.fossbilling.org/releases/0.8.0/FOSSBilling-0.8.0.zip" ); - expect(releases["0.6.0"].mirror_digest).toBe( + expect(releases["0.8.0"].mirror_digest).toBe( "sha256:deadbeefcafe0000000000000000000000000000000000000000000000000000" ); }); diff --git a/test/services/versions/v1/index.test.ts b/test/services/versions/v1/index.test.ts index 712173b..13462d3 100644 --- a/test/services/versions/v1/index.test.ts +++ b/test/services/versions/v1/index.test.ts @@ -9,7 +9,8 @@ import app from "../../../../src/app"; import { mockGitHubReleases, - mockComposerJson + mockComposerJson, + mockMirroredRelease } from "../../../mocks/github-releases"; import { suppressConsole, @@ -53,6 +54,7 @@ describe("Versions API v1", () => { 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"); + await env.DOWNLOAD_BUCKET.delete("releases/0.8.0/FOSSBilling-0.8.0.zip"); resetUpdateTokenCache(); const testUpdateToken = "test-update-token-12345"; @@ -227,22 +229,38 @@ describe("Versions API v1", () => { // should ever be sent that URL - anything older rejects it outright // with "Update canceled for security reasons" (the incident this // describe block guards against). - async function mirrorRelease060() { + // + // R2_MIRROR_MIN_VERSION gates the R2 lookup itself at 0.8.0 - mirroring + // began there, so nothing older is ever eligible. The shared + // mockGitHubReleases fixture tops out at 0.6.0 (below that cutoff, and + // relied on as "latest" by describe blocks outside this one), so these + // tests inject mockMirroredRelease on top of it rather than changing + // the shared fixture. + beforeEach(() => { + setupGitHubApiMock( + vi.mocked(ghRequest) as MockGitHubRequest, + vi.mocked(graphql) as unknown as MockGitHubGraphQL, + [...mockGitHubReleases, mockMirroredRelease], + mockComposerJson + ); + }); + + async function mirrorRelease080() { await env.DOWNLOAD_BUCKET.put( - "releases/0.6.0/FOSSBilling-0.6.0.zip", + "releases/0.8.0/FOSSBilling-0.8.0.zip", "mirrored archive contents", { customMetadata: { digest: "sha256:deadbeefcafe0000000000000000000000000000000000000000000000000000", - version: "0.6.0" + version: "0.8.0" } } ); } it("prefers the R2 mirror's download_url and digest for a client that trusts it", async () => { - await mirrorRelease060(); + await mirrorRelease080(); const ctx = createExecutionContext(); const response = await app.request( @@ -260,7 +278,7 @@ describe("Versions API v1", () => { 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" + "https://download.fossbilling.org/releases/0.8.0/FOSSBilling-0.8.0.zip" ); expect(data.result.digest).toBe( "sha256:deadbeefcafe0000000000000000000000000000000000000000000000000000" @@ -282,6 +300,44 @@ describe("Versions API v1", () => { if (!data.result) { throw new Error("Expected latest release data"); } + expect(data.result.download_url).toBe( + "https://github.com/FOSSBilling/FOSSBilling/releases/download/0.8.0/FOSSBilling.zip" + ); + expect(data.result.digest).toBe( + "sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08" + ); + }); + + it("skips the R2 lookup entirely for a release older than mirroring began, even if something exists at that R2 key", async () => { + // Mirroring began at 0.8.0 (R2_MIRROR_MIN_VERSION); nothing older was + // ever uploaded there. Seed the R2 key anyway (e.g. a stray/manual + // object) to prove the version gate skips the lookup outright rather + // than happening to not find anything. + await env.DOWNLOAD_BUCKET.put( + "releases/0.6.0/FOSSBilling-0.6.0.zip", + "should never be looked up", + { + customMetadata: { + digest: + "sha256:00000000000000000000000000000000000000000000000000000000000000", + version: "0.6.0" + } + } + ); + + const ctx = createExecutionContext(); + const response = await app.request( + "/versions/v1/0.6.0", + { headers: { "User-Agent": "FOSSBilling/0.8.7" } }, + env, + ctx + ); + await waitOnExecutionContext(ctx); + + const data: ApiResponse = await response.json(); + if (!data.result) { + throw new Error("Expected release data"); + } expect(data.result.download_url).toBe( "https://github.com/FOSSBilling/FOSSBilling/releases/download/0.6.0/FOSSBilling.zip" ); @@ -291,7 +347,7 @@ describe("Versions API v1", () => { }); it("falls back to the GitHub asset for a client older than the mirror-trust cutoff, even when mirrored", async () => { - await mirrorRelease060(); + await mirrorRelease080(); const ctx = createExecutionContext(); const response = await app.request( @@ -307,7 +363,7 @@ describe("Versions API v1", () => { 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" + "https://github.com/FOSSBilling/FOSSBilling/releases/download/0.8.0/FOSSBilling.zip" ); expect(data.result.digest).toBe( "sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08" @@ -317,7 +373,7 @@ describe("Versions API v1", () => { it("falls back to the GitHub asset for a client sending no User-Agent, even when mirrored", async () => { // Versions <=0.8.3 predate FOSSBilling's own User-Agent header entirely // (added in 0.8.4) and send none of their own. - await mirrorRelease060(); + await mirrorRelease080(); const ctx = createExecutionContext(); const response = await app.request("/versions/v1/latest", {}, env, ctx); @@ -328,12 +384,12 @@ describe("Versions API v1", () => { 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" + "https://github.com/FOSSBilling/FOSSBilling/releases/download/0.8.0/FOSSBilling.zip" ); }); it("falls back to the GitHub asset for an unparseable User-Agent, even when mirrored", async () => { - await mirrorRelease060(); + await mirrorRelease080(); const ctx = createExecutionContext(); const response = await app.request( @@ -349,12 +405,12 @@ describe("Versions API v1", () => { 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" + "https://github.com/FOSSBilling/FOSSBilling/releases/download/0.8.0/FOSSBilling.zip" ); }); it("never exposes the internal mirror_download_url/mirror_digest fields in the response", async () => { - await mirrorRelease060(); + await mirrorRelease080(); const ctx = createExecutionContext(); const response = await app.request( @@ -378,7 +434,7 @@ describe("Versions API v1", () => { // the URL choice into the stored cache (using the first requester's // trust) would still pass them all. it("serves the R2 mirror to a trusting client even when an older client warmed the shared cache", async () => { - await mirrorRelease060(); + await mirrorRelease080(); const ctx1 = createExecutionContext(); const warmingResponse = await app.request( @@ -394,7 +450,7 @@ describe("Versions API v1", () => { throw new Error("Expected latest release data"); } expect(warmingData.result.download_url).toBe( - "https://github.com/FOSSBilling/FOSSBilling/releases/download/0.6.0/FOSSBilling.zip" + "https://github.com/FOSSBilling/FOSSBilling/releases/download/0.8.0/FOSSBilling.zip" ); expect(vi.mocked(ghRequest)).toHaveBeenCalledTimes(1); @@ -415,7 +471,7 @@ describe("Versions API v1", () => { 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" + "https://download.fossbilling.org/releases/0.8.0/FOSSBilling-0.8.0.zip" ); expect(data.result.digest).toBe( "sha256:deadbeefcafe0000000000000000000000000000000000000000000000000000" @@ -423,7 +479,7 @@ describe("Versions API v1", () => { }); it("falls back to the GitHub asset for an older client even when a trusting client warmed the shared cache", async () => { - await mirrorRelease060(); + await mirrorRelease080(); const ctx1 = createExecutionContext(); const warmingResponse = await app.request( @@ -439,7 +495,7 @@ describe("Versions API v1", () => { throw new Error("Expected latest release data"); } expect(warmingData.result.download_url).toBe( - "https://download.fossbilling.org/releases/0.6.0/FOSSBilling-0.6.0.zip" + "https://download.fossbilling.org/releases/0.8.0/FOSSBilling-0.8.0.zip" ); expect(vi.mocked(ghRequest)).toHaveBeenCalledTimes(1); @@ -458,7 +514,7 @@ describe("Versions API v1", () => { 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" + "https://github.com/FOSSBilling/FOSSBilling/releases/download/0.8.0/FOSSBilling.zip" ); expect(data.result.digest).toBe( "sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08" @@ -472,6 +528,10 @@ describe("Versions API v1", () => { // fresh fetch, not serve it (backfilling the missing keys to `null` // isn't enough - see the next test for why). it("invalidates a legacy-shaped cache entry and rebuilds it via a fresh fetch", async () => { + // Deliberately below R2_MIRROR_MIN_VERSION and requested by exact + // version rather than "latest" (now 0.8.0, from the injected release + // above) - this proves invalidation/rebuild works independently of + // R2 mirror eligibility. const legacyCachedReleases = { "0.6.0": { version: "0.6.0", @@ -494,7 +554,7 @@ describe("Versions API v1", () => { const ctx = createExecutionContext(); const response = await app.request( - "/versions/v1/latest", + "/versions/v1/0.6.0", { headers: { "User-Agent": "FOSSBilling/0.8.7" } }, env, ctx @@ -525,18 +585,18 @@ describe("Versions API v1", () => { // 24h cache TTL and that #207 shipped over 24h before this fix, this is // what's actually sitting in the production cache right now. it("invalidates a legacy cache entry poisoned with the R2 URL as download_url, for clients of any trust", async () => { - await mirrorRelease060(); + await mirrorRelease080(); const poisonedCachedReleases = { - "0.6.0": { - version: "0.6.0", - released_on: "2023-04-01T00:00:00Z", + "0.8.0": { + version: "0.8.0", + released_on: "2023-05-01T00:00:00Z", minimum_php_version: "8.1", download_url: - "https://download.fossbilling.org/releases/0.6.0/FOSSBilling-0.6.0.zip", + "https://download.fossbilling.org/releases/0.8.0/FOSSBilling-0.8.0.zip", size_bytes: 15485760, is_prerelease: false, github_release_id: 987654321, - changelog: "## 0.6.0", + changelog: "## 0.8.0", digest: "sha256:deadbeefcafe0000000000000000000000000000000000000000000000000000" } @@ -562,7 +622,7 @@ describe("Versions API v1", () => { throw new Error("Expected latest release data"); } expect(oldClientData.result.download_url).toBe( - "https://github.com/FOSSBilling/FOSSBilling/releases/download/0.6.0/FOSSBilling.zip" + "https://github.com/FOSSBilling/FOSSBilling/releases/download/0.8.0/FOSSBilling.zip" ); // The rebuilt cache is now correctly shaped, so a trusting client @@ -584,7 +644,7 @@ describe("Versions API v1", () => { throw new Error("Expected latest release data"); } expect(newClientData.result.download_url).toBe( - "https://download.fossbilling.org/releases/0.6.0/FOSSBilling-0.6.0.zip" + "https://download.fossbilling.org/releases/0.8.0/FOSSBilling-0.8.0.zip" ); expect(newClientData.result.digest).toBe( "sha256:deadbeefcafe0000000000000000000000000000000000000000000000000000" @@ -599,22 +659,22 @@ describe("Versions API v1", () => { // mirror_digest with a missing mirror_download_url loses download_url // instead, the same failure mode as the fully-legacy case above). it("invalidates a cache entry with only one of the two mirror fields present", async () => { - await mirrorRelease060(); + await mirrorRelease080(); const partialCachedReleases = { - "0.6.0": { - version: "0.6.0", - released_on: "2023-04-01T00:00:00Z", + "0.8.0": { + version: "0.8.0", + released_on: "2023-05-01T00:00:00Z", minimum_php_version: "8.1", download_url: - "https://github.com/FOSSBilling/FOSSBilling/releases/download/0.6.0/FOSSBilling.zip", + "https://github.com/FOSSBilling/FOSSBilling/releases/download/0.8.0/FOSSBilling.zip", size_bytes: 15485760, is_prerelease: false, github_release_id: 987654321, - changelog: "## 0.6.0", + changelog: "## 0.8.0", digest: "sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08", mirror_download_url: - "https://download.fossbilling.org/releases/0.6.0/FOSSBilling-0.6.0.zip" + "https://download.fossbilling.org/releases/0.8.0/FOSSBilling-0.8.0.zip" // mirror_digest is missing entirely } }; @@ -640,7 +700,7 @@ describe("Versions API v1", () => { 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" + "https://download.fossbilling.org/releases/0.8.0/FOSSBilling-0.8.0.zip" ); expect(data.result.digest).toBe( "sha256:deadbeefcafe0000000000000000000000000000000000000000000000000000"