Skip to content
Open
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
35 changes: 35 additions & 0 deletions image-cdn/readme.md
Original file line number Diff line number Diff line change
@@ -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=<n>&jpgQuality=<n>`

- `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.
45 changes: 30 additions & 15 deletions image-cdn/src/handler/image.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
};
14 changes: 14 additions & 0 deletions image-cdn/tests/handler/image.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading