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
15 changes: 13 additions & 2 deletions src/services/stats/v1/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ function aggregateStats(releases: Releases): StatsData {
async function getStats(
cache: ICache,
githubToken: string,
downloadBucket: R2Bucket,
updateCache: boolean = false
): Promise<{
stats: StatsData;
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Comment thread
admdly marked this conversation as resolved.
);

if (result.error && result.stats.releaseSizes.length === 0) {
Expand Down
14 changes: 8 additions & 6 deletions src/services/versions/v1/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
},
Expand Down Expand Up @@ -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:<hex>`), 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:<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.
61 changes: 41 additions & 20 deletions src/services/versions/v1/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -90,6 +91,7 @@ async function loadReleases(
return getReleases(
platform.getCache("CACHE_KV"),
platform.getEnv("GITHUB_TOKEN") || "",
c.env.DOWNLOAD_BUCKET,
updateCache
);
}
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -343,6 +346,7 @@ export function resetUpdateTokenCache() {
export async function getReleases(
cache: ICache,
githubToken: string,
downloadBucket: R2Bucket,
updateCache: boolean = false
): Promise<GetReleasesResult> {
const cachedReleases = await cache.get(RELEASE_CACHE_KEY);
Expand Down Expand Up @@ -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,
Comment thread
admdly marked this conversation as resolved.
size_bytes: zipAsset.size,
is_prerelease: Boolean(release.prerelease),
github_release_id: release.id ?? 0,
changelog: release.body || "",
digest: r2Object?.digest ?? zipAsset.digest ?? null
Comment thread
admdly marked this conversation as resolved.
};
return [tag, releaseDetails];
}
)
);

const sortedReleases = Object.fromEntries(
Expand Down
27 changes: 27 additions & 0 deletions src/services/versions/v1/r2.ts
Original file line number Diff line number Diff line change
@@ -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<ReleaseR2Object | null> {
const archiveName = `FOSSBilling-${version}.zip`;
const object = await bucket.head(`releases/${version}/${archiveName}`);
Comment thread
admdly marked this conversation as resolved.
if (!object) return null;

return {
downloadUrl: `${RELEASES_DOWNLOAD_BASE_URL}/${version}/${archiveName}`,
digest: object.customMetadata?.digest ?? null
};
}
42 changes: 42 additions & 0 deletions test/services/stats/v1/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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();
Expand Down
54 changes: 54 additions & 0 deletions test/services/versions/v1/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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",
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
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<VersionInfo | null> = 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<VersionInfo | null> = 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();
Expand Down
Loading