From dbc6d15ef5381be9b60673f1f5983e30134e59c4 Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Tue, 4 Aug 2026 14:08:39 -0400 Subject: [PATCH] fix issue where polaroids come in with a different URL, pull from other bucket --- apps/img/src/context.ts | 6 ++++ apps/img/src/img.app.ts | 37 ++++++++++++++++++--- apps/img/src/test/integration/api.test.ts | 39 +++++++++++++++++++++++ apps/img/wrangler.jsonc | 8 +++++ 4 files changed, 86 insertions(+), 4 deletions(-) diff --git a/apps/img/src/context.ts b/apps/img/src/context.ts index f9021ba..7a7a591 100644 --- a/apps/img/src/context.ts +++ b/apps/img/src/context.ts @@ -6,6 +6,12 @@ export type Env = SharedHonoEnv & { DB: D1Database /** R2 bucket holding the served image objects, keyed by filename. */ IMAGES: R2Bucket + /** + * Shared `recflare-cdn` bucket. Only its `image/` prefix is read here: images + * uploaded through the `storage` worker are stored extensionless under + * `image//` and requested from this worker by the bare name. + */ + CDN_ASSETS: R2Bucket /** Static assets (fallback images) served from `static/`. */ ASSETS: Fetcher /** diff --git a/apps/img/src/img.app.ts b/apps/img/src/img.app.ts index 8779a54..508df0a 100644 --- a/apps/img/src/img.app.ts +++ b/apps/img/src/img.app.ts @@ -15,6 +15,9 @@ const SIGNATURE_KEY_ID = 'KEY:RSA:p1.rec.net' /** Static asset served (200) when the requested key is missing from R2. */ const FALLBACK_ASSET_PATH = '/DefaultProfileImage.jpg' +/** Prefix extensionless keys resolve under in the shared `recflare-cdn` bucket. */ +const CDN_IMAGE_PREFIX = 'image/' + /** * Cache-Control for served images. Uploaded images are immutable once written, * so cache for a year and mark `immutable` so browsers never revalidate. A new @@ -101,6 +104,23 @@ function resizeImage(input: Uint8Array, transform: Transform): Uint8Array { } } +/** + * Which bucket (and under which key) a requested path resolves in. + * + * Every object the `api` worker writes to `recflare-img` keeps a file extension + * (`.jpg` is forced when the upload has none), so an extensionless key can only be + * a `storage` upload: FileType 3 lands in the shared `recflare-cdn` bucket as + * `image//` and the client references it by the bare `/` + * name it got back. That makes the extension a reliable discriminator — + * `/2028-06-01/` here is `recflare-cdn`'s `image/2028-06-01/`. + */ +function resolveObject(env: Env, key: string): { bucket: R2Bucket; objectKey: string } { + const filename = key.slice(key.lastIndexOf('/') + 1) + return filename.includes('.') + ? { bucket: env.IMAGES, objectKey: key } + : { bucket: env.CDN_ASSETS, objectKey: CDN_IMAGE_PREFIX + key } +} + // Import the signing key once per isolate. The key material is constant for the // lifetime of the Worker, so caching the promise is safe. let signingKey: Promise | undefined @@ -231,9 +251,11 @@ app.get( description: [ 'Image hosting for recflare, a private-server reimplementation of the Rec Room', 'backend. Serves every image the client renders — profile photos, room thumbnails,', - 'club banners and the photo feed — from an R2 bucket, with bundled static assets', + 'club banners and the photo feed — out of R2, with bundled static assets', '(`static/`) taking precedence over the bucket and `DefaultProfileImage.jpg` served', - 'as the fallback when a key is missing. Optional on-the-fly center-crop and resize', + 'as the fallback when a key is missing. Keys with an extension come from the', + '`recflare-img` bucket; extensionless ones are `storage` uploads and come from the', + 'shared `recflare-cdn` bucket under its `image/` prefix. Optional center-crop and resize', 'run through the Photon WASM codec; `?sig=p1` adds the RSA-SHA1 `Content-Signature`', 'header the client verifies against `KEY:RSA:p1.rec.net`.', '', @@ -266,6 +288,12 @@ app.get( 'the same key; when neither exists the bundled `DefaultProfileImage.jpg` is served', 'with a 200 rather than a 404, so the client never renders a broken image.', '', + 'Which bucket the key resolves in depends on its extension. A key with one (always', + 'the case for an `api` image upload) comes from `recflare-img`. A key WITHOUT one is', + 'a `storage` upload and comes from the shared `recflare-cdn` bucket under its', + '`image/` prefix, so `/2028-06-01/` here serves `image/2028-06-01/`', + 'there.', + '', 'Responses carry `Cache-Control: public, max-age=31536000, immutable` — an uploaded', 'image is never rewritten in place, a new image gets a new key.', '', @@ -360,8 +388,9 @@ app.get( // resized response carries no etag, so the client can never send a matching // one. Skip the precondition when a transform is requested. const ifNoneMatch = transform ? undefined : c.req.header('if-none-match')?.replace(/"/g, '') - const object = await c.env.IMAGES.get( - key, + const { bucket, objectKey } = resolveObject(c.env, key) + const object = await bucket.get( + objectKey, ifNoneMatch ? { onlyIf: { etagDoesNotMatch: ifNoneMatch } } : undefined ) if (!object) { diff --git a/apps/img/src/test/integration/api.test.ts b/apps/img/src/test/integration/api.test.ts index 43f34bc..c7b4d53 100644 --- a/apps/img/src/test/integration/api.test.ts +++ b/apps/img/src/test/integration/api.test.ts @@ -34,10 +34,17 @@ const PUBLIC_SPKI_B64 = // bucket path rather than a static asset. const R2_KEY = 'user-photo.jpg' +// An extensionless name, as returned by the `storage` worker for a FileType 3 +// upload — served from `recflare-cdn` under `image/`, not `recflare-img`. +const CDN_NAME = '2028-06-01/12345-67890-12345' + beforeAll(async () => { await env.IMAGES.put(R2_KEY, IMAGE_BYTES, { httpMetadata: { contentType: 'image/jpeg' }, }) + await env.CDN_ASSETS.put(`image/${CDN_NAME}`, IMAGE_BYTES, { + httpMetadata: { contentType: 'image/jpeg' }, + }) // Seed R2 with a key that ALSO exists in `static/` to prove static wins. await env.IMAGES.put('3DCharades.jpg', IMAGE_BYTES, { httpMetadata: { contentType: 'image/jpeg' }, @@ -58,6 +65,38 @@ describe('img endpoints', () => { expect(new Uint8Array(await res.arrayBuffer())).toEqual(IMAGE_BYTES) }) + it('serves an extensionless key from the cdn bucket under image/', async () => { + const res = await SELF.fetch(`${ORIGIN}/${CDN_NAME}`) + expect(res.status).toBe(200) + expect(res.headers.get('content-type')).toBe('image/jpeg') + expect(new Uint8Array(await res.arrayBuffer())).toEqual(IMAGE_BYTES) + }) + + it('does not look for an extensionless key in the image bucket', async () => { + // Same bare name seeded into `recflare-img` instead: extensionless keys only + // ever resolve against `recflare-cdn`, so this falls through to the default. + await env.IMAGES.put('2028-06-02/only-in-img', IMAGE_BYTES) + const res = await SELF.fetch(`${ORIGIN}/2028-06-02/only-in-img`) + expect(res.status).toBe(200) + const body = new Uint8Array(await res.arrayBuffer()) + expect(body.length).toBeGreaterThan(IMAGE_BYTES.length) + }) + + it('resizes an extensionless cdn image', async () => { + // Exercises the transform path against the cdn bucket, not just the stream-through. + // Needs a decodable JPEG, so reuse a bundled static asset's bytes. + const real = await (await SELF.fetch(`${ORIGIN}/3DCharades.jpg`)).arrayBuffer() + await env.CDN_ASSETS.put('image/2028-06-03/real-photo', real, { + httpMetadata: { contentType: 'image/jpeg' }, + }) + + const res = await SELF.fetch(`${ORIGIN}/2028-06-03/real-photo?width=128`) + expect(res.status).toBe(200) + expect(res.headers.get('content-type')).toBe('image/jpeg') + expect(res.headers.get('etag')).toBeNull() + expect(jpegSize(new Uint8Array(await res.arrayBuffer())).width).toBe(128) + }) + it('serves a static asset in preference to an R2 object of the same key', async () => { const res = await SELF.fetch(`${ORIGIN}/3DCharades.jpg`) expect(res.status).toBe(200) diff --git a/apps/img/wrangler.jsonc b/apps/img/wrangler.jsonc index e18291f..6822164 100644 --- a/apps/img/wrangler.jsonc +++ b/apps/img/wrangler.jsonc @@ -17,10 +17,18 @@ "run_worker_first": true }, // Images are stored as objects in an R2 bucket and streamed back by key. + // `recflare-cdn` (owned by the `cdn` worker, written by `storage`) is bound + // alongside it: uploads posted to `storage` as FileType 3 land under its + // `image/` prefix with no extension, and the client asks THIS worker for them + // by the bare name — see the extensionless-key branch in src/img.app.ts. "r2_buckets": [ { "binding": "IMAGES", "bucket_name": "recflare-img" + }, + { + "binding": "CDN_ASSETS", + "bucket_name": "recflare-cdn" } ], // Shared `recflare` D1 — the `img` worker owns the `images` metadata table