From 8a97e5968c45133c08152a66c3910e7c43e70d94 Mon Sep 17 00:00:00 2001 From: wilfordgrimley <2397930+WilfordGrimley@users.noreply.github.com> Date: Sat, 11 Jul 2026 05:46:35 +0000 Subject: [PATCH 1/2] Fix missing CORS header on image-cdn's actual image responses The OPTIONS preflight handler set Access-Control-Allow-Origin, but the real GET responses never did. small/large (served via R2Service) had no CORS header at all, so cross-origin fetch() calls - both the PDF live preview and, from within the PDF render Worker, any consumer fetching these images - failed outright in every browser (confirmed via an isolated main-thread + Worker fetch test in real Firefox and Chromium, no test-harness CORS shim). full happened to "work" only because Google's own response carries a permissive CORS header that passes through unmodified - not something to depend on. Add the header to every actual image response, and add regression tests covering all three size tiers against a mocked upstream response that deliberately has no CORS header of its own. Co-Authored-By: Claude Sonnet 5 --- image-cdn/src/handler/image.ts | 45 ++++++++++++++++++--------- image-cdn/tests/handler/image.test.ts | 14 +++++++++ 2 files changed, 44 insertions(+), 15 deletions(-) diff --git a/image-cdn/src/handler/image.ts b/image-cdn/src/handler/image.ts index bdac1c417..4578939f6 100644 --- a/image-cdn/src/handler/image.ts +++ b/image-cdn/src/handler/image.ts @@ -24,19 +24,34 @@ export const handleImageRequest = async (url: URL, request: Request, env: Env, c const imageKey = R2Service.getImageKey(imageType, imageSize, imageIdentifier); - switch (request.method) { - case "GET": - switch (imageSize) { - case "small": - case "large": - return R2Service.getThumbnail(env, ctx, getImageURL(imageType, imageSize, undefined, jpgQuality, imageIdentifier), imageKey); - case "full": - const url = getImageURL(imageType, imageSize, dpi, jpgQuality, imageIdentifier); - return fetch(url); - default: - throw new Error(`Invalid image size ${imageSize}`); - } - default: - return new Response(`Invalid method ${request.method}. GET or PUT expected.`, { status: 400 }); - } + const response = await (async () => { + switch (request.method) { + case "GET": + switch (imageSize) { + case "small": + case "large": + return R2Service.getThumbnail(env, ctx, getImageURL(imageType, imageSize, undefined, jpgQuality, imageIdentifier), imageKey); + case "full": + const url = getImageURL(imageType, imageSize, dpi, jpgQuality, imageIdentifier); + return fetch(url); + default: + throw new Error(`Invalid image size ${imageSize}`); + } + default: + return new Response(`Invalid method ${request.method}. GET or PUT expected.`, { status: 400 }); + } + })(); + + // Callers (the browser's main thread, and the PDF renderer's Worker context) + // fetch() these images cross-origin, which requires an explicit CORS header + // on the actual response - the OPTIONS preflight handler alone isn't enough. + // The "full" tier previously worked by accident because Google's own response + // happens to carry a permissive CORS header; don't rely on that. + const headers = new Headers(response.headers); + headers.set("Access-Control-Allow-Origin", "*"); + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers, + }); }; diff --git a/image-cdn/tests/handler/image.test.ts b/image-cdn/tests/handler/image.test.ts index e98f8609d..64673e404 100644 --- a/image-cdn/tests/handler/image.test.ts +++ b/image-cdn/tests/handler/image.test.ts @@ -103,6 +103,20 @@ describe("worker image routing", () => { expect(fetchMock).toHaveBeenCalledWith("https://lh4.googleusercontent.com/d/image-id=h2220-rj-l95"); }); + it.each(["small", "large", "full"] as const)( + "sets Access-Control-Allow-Origin on %s image responses regardless of the upstream source's own headers", + async (imageSize) => { + // the upstream response deliberately carries no CORS header of its own, so this + // only passes if the worker adds one itself rather than relying on a passthrough. + const fetchMock = vi.fn(async () => new Response("image-bytes", { headers: { "content-length": "11" } })); + vi.stubGlobal("fetch", fetchMock); + + const response = await fetchWorker(`http://example.com/images/google_drive/${imageSize}/image-id.jpg`); + + expect(response.headers.get("Access-Control-Allow-Origin")).toBe("*"); + } + ); + it("throws for invalid dpi or JPG quality query parameters", async () => { await expect(fetchWorker("http://example.com/images/google_drive/full/image-id.jpg?dpi=0")).rejects.toThrow("invalid DPI 0"); await expect(fetchWorker("http://example.com/images/google_drive/full/image-id.jpg?jpgQuality=101")).rejects.toThrow( From 08b9d2be49a81103e9af6f53497c42c85484978c Mon Sep 17 00:00:00 2001 From: wilfordgrimley <2397930+WilfordGrimley@users.noreply.github.com> Date: Sat, 11 Jul 2026 06:36:10 +0000 Subject: [PATCH 2/2] Add a readme for image-cdn/ There was no documentation at all for what this Worker does, which endpoints it serves, or which secrets are actually load-bearing versus only used by the scheduled cache-refresh workflow. Co-Authored-By: Claude Sonnet 5 --- image-cdn/readme.md | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 image-cdn/readme.md diff --git a/image-cdn/readme.md b/image-cdn/readme.md new file mode 100644 index 000000000..64d1e7f81 --- /dev/null +++ b/image-cdn/readme.md @@ -0,0 +1,35 @@ +# image-cdn + +Cloudflare Worker that proxies and caches card images fetched from Google Drive, +so the frontend (and PDF generator) never fetch large images directly from Drive. + +## Endpoints + +`GET /images/google_drive/{small|large|full}/{driveFileId}.jpg?dpi=&jpgQuality=` + +- `small`/`large` are served through an R2 cache (binding `thumbnails`): cache hit + serves straight from R2, cache miss proxies from Drive and populates the cache + in the background. +- `full` is always a live proxy (no caching) so callers can vary `dpi`/`jpgQuality` + per request without unbounded cache growth. + +No authentication is required to fetch images - Drive files just need to be +publicly shared, same as the rest of this project's card sourcing. + +## Google OAuth secrets + +`GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` / `GOOGLE_REFRESH_TOKEN` are only used +by the scheduled `ThumbnailRefreshWorkflow` (see `wrangler.toml`'s +`[[workflows]]`/`schedules`), which checks Drive's `modifiedTime` to invalidate +stale R2 cache entries once a day. They are **not** used by the request-serving +path in `src/index.ts` - a fresh deploy works for serving images even before +real OAuth credentials are wired up; only the daily refresh job will no-op until +then. + +## Deploying + +Deployed via `.github/workflows/cloudflare-workers-ci.yml` (`publish-image-cdn` +job) on every push to `master` that touches this directory, using +`CLOUDFLARE_API_TOKEN`, `CLOUDFLARE_ACCOUNT_ID`, and the three +`IMAGE_CDN_GOOGLE_*` repo secrets. The R2 bucket (`thumbnails`) must already +exist in the target Cloudflare account before the first deploy.