mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
fix issue where polaroids come in with a different URL, pull from other bucket
This commit is contained in:
@@ -6,6 +6,12 @@ export type Env = SharedHonoEnv & {
|
|||||||
DB: D1Database
|
DB: D1Database
|
||||||
/** R2 bucket holding the served image objects, keyed by filename. */
|
/** R2 bucket holding the served image objects, keyed by filename. */
|
||||||
IMAGES: R2Bucket
|
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/`. */
|
/** Static assets (fallback images) served from `static/`. */
|
||||||
ASSETS: Fetcher
|
ASSETS: Fetcher
|
||||||
/**
|
/**
|
||||||
|
|||||||
+33
-4
@@ -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. */
|
/** Static asset served (200) when the requested key is missing from R2. */
|
||||||
const FALLBACK_ASSET_PATH = '/DefaultProfileImage.jpg'
|
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,
|
* 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
|
* 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
|
// Import the signing key once per isolate. The key material is constant for the
|
||||||
// lifetime of the Worker, so caching the promise is safe.
|
// lifetime of the Worker, so caching the promise is safe.
|
||||||
let signingKey: Promise<CryptoKey | null> | undefined
|
let signingKey: Promise<CryptoKey | null> | undefined
|
||||||
@@ -231,9 +251,11 @@ app.get(
|
|||||||
description: [
|
description: [
|
||||||
'Image hosting for recflare, a private-server reimplementation of the Rec Room',
|
'Image hosting for recflare, a private-server reimplementation of the Rec Room',
|
||||||
'backend. Serves every image the client renders — profile photos, room thumbnails,',
|
'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',
|
'(`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`',
|
'run through the Photon WASM codec; `?sig=p1` adds the RSA-SHA1 `Content-Signature`',
|
||||||
'header the client verifies against `KEY:RSA:p1.rec.net`.',
|
'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',
|
'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.',
|
'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',
|
'Responses carry `Cache-Control: public, max-age=31536000, immutable` — an uploaded',
|
||||||
'image is never rewritten in place, a new image gets a new key.',
|
'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
|
// resized response carries no etag, so the client can never send a matching
|
||||||
// one. Skip the precondition when a transform is requested.
|
// one. Skip the precondition when a transform is requested.
|
||||||
const ifNoneMatch = transform ? undefined : c.req.header('if-none-match')?.replace(/"/g, '')
|
const ifNoneMatch = transform ? undefined : c.req.header('if-none-match')?.replace(/"/g, '')
|
||||||
const object = await c.env.IMAGES.get(
|
const { bucket, objectKey } = resolveObject(c.env, key)
|
||||||
key,
|
const object = await bucket.get(
|
||||||
|
objectKey,
|
||||||
ifNoneMatch ? { onlyIf: { etagDoesNotMatch: ifNoneMatch } } : undefined
|
ifNoneMatch ? { onlyIf: { etagDoesNotMatch: ifNoneMatch } } : undefined
|
||||||
)
|
)
|
||||||
if (!object) {
|
if (!object) {
|
||||||
|
|||||||
@@ -34,10 +34,17 @@ const PUBLIC_SPKI_B64 =
|
|||||||
// bucket path rather than a static asset.
|
// bucket path rather than a static asset.
|
||||||
const R2_KEY = 'user-photo.jpg'
|
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 () => {
|
beforeAll(async () => {
|
||||||
await env.IMAGES.put(R2_KEY, IMAGE_BYTES, {
|
await env.IMAGES.put(R2_KEY, IMAGE_BYTES, {
|
||||||
httpMetadata: { contentType: 'image/jpeg' },
|
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.
|
// Seed R2 with a key that ALSO exists in `static/` to prove static wins.
|
||||||
await env.IMAGES.put('3DCharades.jpg', IMAGE_BYTES, {
|
await env.IMAGES.put('3DCharades.jpg', IMAGE_BYTES, {
|
||||||
httpMetadata: { contentType: 'image/jpeg' },
|
httpMetadata: { contentType: 'image/jpeg' },
|
||||||
@@ -58,6 +65,38 @@ describe('img endpoints', () => {
|
|||||||
expect(new Uint8Array(await res.arrayBuffer())).toEqual(IMAGE_BYTES)
|
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 () => {
|
it('serves a static asset in preference to an R2 object of the same key', async () => {
|
||||||
const res = await SELF.fetch(`${ORIGIN}/3DCharades.jpg`)
|
const res = await SELF.fetch(`${ORIGIN}/3DCharades.jpg`)
|
||||||
expect(res.status).toBe(200)
|
expect(res.status).toBe(200)
|
||||||
|
|||||||
@@ -17,10 +17,18 @@
|
|||||||
"run_worker_first": true
|
"run_worker_first": true
|
||||||
},
|
},
|
||||||
// Images are stored as objects in an R2 bucket and streamed back by key.
|
// 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": [
|
"r2_buckets": [
|
||||||
{
|
{
|
||||||
"binding": "IMAGES",
|
"binding": "IMAGES",
|
||||||
"bucket_name": "recflare-img"
|
"bucket_name": "recflare-img"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"binding": "CDN_ASSETS",
|
||||||
|
"bucket_name": "recflare-cdn"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
// Shared `recflare` D1 — the `img` worker owns the `images` metadata table
|
// Shared `recflare` D1 — the `img` worker owns the `images` metadata table
|
||||||
|
|||||||
Reference in New Issue
Block a user