fix issue where polaroids come in with a different URL, pull from other bucket

This commit is contained in:
Devin Zuczek
2026-08-04 14:08:39 -04:00
parent db003d54ef
commit dbc6d15ef5
4 changed files with 86 additions and 4 deletions
+6
View File
@@ -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/<date>/<uuid>` and requested from this worker by the bare name.
*/
CDN_ASSETS: R2Bucket
/** Static assets (fallback images) served from `static/`. */
ASSETS: Fetcher
/**
+33 -4
View File
@@ -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/<date>/<uuid>` and the client references it by the bare `<date>/<uuid>`
* name it got back. That makes the extension a reliable discriminator —
* `/2028-06-01/<uuid>` here is `recflare-cdn`'s `image/2028-06-01/<uuid>`.
*/
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<CryptoKey | null> | 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/<uuid>` here serves `image/2028-06-01/<uuid>`',
'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) {
+39
View File
@@ -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)
+8
View File
@@ -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