Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/services/versions/v1/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<hex>`). 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.
31 changes: 23 additions & 8 deletions src/services/versions/v1/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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 = {
Expand Down
23 changes: 23 additions & 0 deletions test/mocks/github-releases.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
28 changes: 19 additions & 9 deletions test/services/stats/v1/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ import {
import app from "../../../../src/app";
import {
mockGitHubReleases,
mockComposerJson
mockComposerJson,
mockMirroredRelease
} from "../../../mocks/github-releases";
import {
suppressConsole,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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"
}
}
);
Expand All @@ -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"
);
});
Expand Down
Loading
Loading