diff --git a/apps/img/README.md b/apps/img/README.md index b12f1c8..41f69a8 100644 --- a/apps/img/README.md +++ b/apps/img/README.md @@ -9,7 +9,10 @@ key: - `GET /` — streams the matching R2 object (e.g. `GET /DefaultProfileImage.jpg`). The key may contain slashes for nested objects. Content-Type comes from the object's stored HTTP metadata. Supports - conditional requests via `If-None-Match` (returns `304`). Missing keys `404`. + conditional requests via `If-None-Match` (returns `304`). Missing keys fall + back to the bundled `static/DefaultProfileImage.jpg` asset (served `200` via + the `ASSETS` binding), so clients always get a valid image. The fallback also + honours `?sig=p1` and returns a `Content-Signature` header. - `GET /?sig=p1` — same, but the response body is RSA-SHA1 signed and the signature returned in a `Content-Signature: key-id=KEY:RSA:p1.rec.net; data=` header. The client uses this to verify image integrity. Signing buffers the diff --git a/apps/img/src/context.ts b/apps/img/src/context.ts index 6a33d03..f9021ba 100644 --- a/apps/img/src/context.ts +++ b/apps/img/src/context.ts @@ -6,6 +6,8 @@ export type Env = SharedHonoEnv & { DB: D1Database /** R2 bucket holding the served image objects, keyed by filename. */ IMAGES: R2Bucket + /** Static assets (fallback images) served from `static/`. */ + ASSETS: Fetcher /** * RSA-2048 private key (PKCS8 DER, base64) used to sign image responses * requested with `?sig=p1`. Optional — when absent, responses are unsigned. diff --git a/apps/img/src/img.app.ts b/apps/img/src/img.app.ts index 8f600a3..678449c 100644 --- a/apps/img/src/img.app.ts +++ b/apps/img/src/img.app.ts @@ -8,6 +8,9 @@ import type { App, Env } from './context' /** Key id the client uses to look up the public half of the signing key. */ 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' + // 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 @@ -39,6 +42,32 @@ async function signImage(env: Env, bytes: ArrayBuffer): Promise { return btoa(binary) } +/** + * Serve a static asset `Response` with our standard cache headers, honouring + * `?sig=p1` by RSA-SHA1 signing the (buffered) body into `Content-Signature`. + */ +async function serveStaticAsset( + env: Env, + asset: Response, + wantsSignature: boolean +): Promise { + const headers = new Headers() + const contentType = asset.headers.get('content-type') + if (contentType) headers.set('content-type', contentType) + headers.set('cache-control', 'public, max-age=3600') + + if (wantsSignature) { + const bytes = await asset.arrayBuffer() + const signature = await signImage(env, bytes) + if (signature) { + headers.set('content-signature', `key-id=${SIGNATURE_KEY_ID}; data=${signature}`) + } + return new Response(bytes, { headers }) + } + + return new Response(asset.body, { headers }) +} + const app = new Hono() .use( '*', @@ -66,12 +95,28 @@ const app = new Hono() const key = c.req.param('key') if (key.includes('..')) return c.body(null, 400) + const wantsSignature = c.req.query('sig') === 'p1' + + // Prefer a bundled static asset when one exists for this key, before hitting + // R2. This lets us ship canonical images (e.g. room thumbnails in `static/`) + // that always win over whatever, if anything, is in the bucket. + const staticAsset = await c.env.ASSETS.fetch(new URL(`/${key}`, c.req.url)) + if (staticAsset.ok) { + return serveStaticAsset(c.env, staticAsset, wantsSignature) + } + const ifNoneMatch = c.req.header('if-none-match')?.replace(/"/g, '') const object = await c.env.IMAGES.get( key, ifNoneMatch ? { onlyIf: { etagDoesNotMatch: ifNoneMatch } } : undefined ) - if (!object) return c.notFound() + if (!object) { + // Missing from both static and R2 → serve the bundled DefaultProfileImage.jpg + // static asset so clients still get a valid image instead of a 404. Honour + // `?sig=p1` the same way so signed clients can verify the fallback. + const asset = await c.env.ASSETS.fetch(new URL(FALLBACK_ASSET_PATH, c.req.url)) + return serveStaticAsset(c.env, asset, wantsSignature) + } const headers = new Headers() object.writeHttpMetadata(headers) @@ -81,7 +126,7 @@ const app = new Hono() // Precondition matched (If-None-Match) → R2 returns no body. if (!('body' in object)) return new Response(null, { status: 304, headers }) - if (c.req.query('sig') === 'p1') { + if (wantsSignature) { const bytes = await object.arrayBuffer() const signature = await signImage(c.env, bytes) if (signature) { diff --git a/apps/img/src/test/integration/api.test.ts b/apps/img/src/test/integration/api.test.ts index affae3e..90efcd6 100644 --- a/apps/img/src/test/integration/api.test.ts +++ b/apps/img/src/test/integration/api.test.ts @@ -19,8 +19,16 @@ const IMAGE_BYTES = new Uint8Array([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x const PUBLIC_SPKI_B64 = 'MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA1EIcBzPCvOFRy3WYuG8ICaRyr/OpotABJBpiMq2zcZHsSPXQw7NC+N082JDqYLy627oB9qJ+wC3idtbzFTANLkIYIEWMWJC9hjWl56vBVXOIroji2+lOpR4hV9JRdgmJfBYXmJPtHRP4GAl8np9xcnZpbMJdauR+HIJiQT3QHc2RomLXWCUfOb564cW8Ks7CLlmXPWf4M77DufHhY+788uWq6bI0+QSJ1qrUi3gaou0HPj7YPTl7pUTwX4VOmHKN5Nw+/jB9f2JNpRKp9niylCVUgdHnmHz5iqMW86HRf7EJcalSyYn7cC6b1ng9GPYryybipZ7QuTgl52qu2GQDaQIDAQAB' +// An R2-only key that has no matching file in `static/`, so it exercises the +// bucket path rather than a static asset. +const R2_KEY = 'user-photo.jpg' + beforeAll(async () => { - await env.IMAGES.put('DefaultProfileImage.jpg', IMAGE_BYTES, { + await env.IMAGES.put(R2_KEY, 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' }, }) }) @@ -33,29 +41,78 @@ describe('img endpoints', () => { }) it('streams an image stored in R2 with its content type', async () => { - const res = await SELF.fetch(`${ORIGIN}/DefaultProfileImage.jpg`) + const res = await SELF.fetch(`${ORIGIN}/${R2_KEY}`) expect(res.status).toBe(200) expect(res.headers.get('content-type')).toBe('image/jpeg') expect(new Uint8Array(await res.arrayBuffer())).toEqual(IMAGE_BYTES) }) + 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) + expect(res.headers.get('content-type')).toMatch(/^image\/jpeg/) + // The bundled static JPEG, not the tiny IMAGE_BYTES stub seeded into R2. + const body = new Uint8Array(await res.arrayBuffer()) + expect(body.length).toBeGreaterThan(IMAGE_BYTES.length) + expect(body[0]).toBe(0xff) + expect(body[1]).toBe(0xd8) + }) + + it('serves a nested static asset', async () => { + const res = await SELF.fetch(`${ORIGIN}/Base/Clearcut.jpg`) + expect(res.status).toBe(200) + expect(res.headers.get('content-type')).toMatch(/^image\/jpeg/) + const body = new Uint8Array(await res.arrayBuffer()) + expect(body.length).toBeGreaterThan(0) + expect(body[0]).toBe(0xff) + expect(body[1]).toBe(0xd8) + }) + it('returns 304 when If-None-Match matches the etag', async () => { - const first = await SELF.fetch(`${ORIGIN}/DefaultProfileImage.jpg`) + const first = await SELF.fetch(`${ORIGIN}/${R2_KEY}`) const etag = first.headers.get('etag') expect(etag).toBeTruthy() - const res = await SELF.fetch(`${ORIGIN}/DefaultProfileImage.jpg`, { + const res = await SELF.fetch(`${ORIGIN}/${R2_KEY}`, { headers: { 'If-None-Match': etag! }, }) expect(res.status).toBe(304) }) - it('404 for a missing image', async () => { + it('serves the DefaultProfileImage.jpg fallback for a missing image', async () => { const res = await SELF.fetch(`${ORIGIN}/missing.png`) - expect(res.status).toBe(404) + expect(res.status).toBe(200) + expect(res.headers.get('content-type')).toMatch(/^image\/jpeg/) + const body = new Uint8Array(await res.arrayBuffer()) + // Real JPEG static asset: SOI marker + non-empty body. + expect(body.length).toBeGreaterThan(0) + expect(body[0]).toBe(0xff) + expect(body[1]).toBe(0xd8) + }) + + it('signs the DefaultProfileImage.jpg fallback with ?sig=p1', async () => { + const res = await SELF.fetch(`${ORIGIN}/missing.png?sig=p1`) + expect(res.status).toBe(200) + + const header = res.headers.get('content-signature') + expect(header).toMatch(/^key-id=KEY:RSA:p1\.rec\.net; data=/) + + const signatureB64 = header!.split('data=')[1] + const signature = Uint8Array.from(atob(signatureB64), (ch) => ch.charCodeAt(0)) + const body = new Uint8Array(await res.arrayBuffer()) + + const publicKey = await crypto.subtle.importKey( + 'spki', + Uint8Array.from(atob(PUBLIC_SPKI_B64), (ch) => ch.charCodeAt(0)), + { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-1' }, + false, + ['verify'] + ) + const ok = await crypto.subtle.verify('RSASSA-PKCS1-v1_5', publicKey, signature, body) + expect(ok).toBe(true) }) it('signs the response with ?sig=p1 and the signature verifies', async () => { - const res = await SELF.fetch(`${ORIGIN}/DefaultProfileImage.jpg?sig=p1`) + const res = await SELF.fetch(`${ORIGIN}/${R2_KEY}?sig=p1`) expect(res.status).toBe(200) const header = res.headers.get('content-signature') @@ -77,7 +134,7 @@ describe('img endpoints', () => { }) it('does not sign without ?sig=p1', async () => { - const res = await SELF.fetch(`${ORIGIN}/DefaultProfileImage.jpg`) + const res = await SELF.fetch(`${ORIGIN}/${R2_KEY}`) expect(res.headers.get('content-signature')).toBeNull() }) }) diff --git a/apps/img/static/3DCharades.jpg b/apps/img/static/3DCharades.jpg new file mode 100644 index 0000000..b1969e2 Binary files /dev/null and b/apps/img/static/3DCharades.jpg differ diff --git a/apps/img/static/ARRoom.jpg b/apps/img/static/ARRoom.jpg new file mode 100644 index 0000000..6919297 Binary files /dev/null and b/apps/img/static/ARRoom.jpg differ diff --git a/apps/img/static/Base/Clearcut.jpg b/apps/img/static/Base/Clearcut.jpg new file mode 100644 index 0000000..cee7332 Binary files /dev/null and b/apps/img/static/Base/Clearcut.jpg differ diff --git a/apps/img/static/Base/CyberJunkCity.jpg b/apps/img/static/Base/CyberJunkCity.jpg new file mode 100644 index 0000000..1f9a9ca Binary files /dev/null and b/apps/img/static/Base/CyberJunkCity.jpg differ diff --git a/apps/img/static/Base/DriveIn.jpg b/apps/img/static/Base/DriveIn.jpg new file mode 100644 index 0000000..17fe5d8 Binary files /dev/null and b/apps/img/static/Base/DriveIn.jpg differ diff --git a/apps/img/static/Base/Homestead.jpg b/apps/img/static/Base/Homestead.jpg new file mode 100644 index 0000000..723b3b5 Binary files /dev/null and b/apps/img/static/Base/Homestead.jpg differ diff --git a/apps/img/static/Base/PerformanceHall.jpg b/apps/img/static/Base/PerformanceHall.jpg new file mode 100644 index 0000000..eb08464 Binary files /dev/null and b/apps/img/static/Base/PerformanceHall.jpg differ diff --git a/apps/img/static/Base/Quarry.jpg b/apps/img/static/Base/Quarry.jpg new file mode 100644 index 0000000..73c717c Binary files /dev/null and b/apps/img/static/Base/Quarry.jpg differ diff --git a/apps/img/static/Base/Spillway.jpg b/apps/img/static/Base/Spillway.jpg new file mode 100644 index 0000000..13e993d Binary files /dev/null and b/apps/img/static/Base/Spillway.jpg differ diff --git a/apps/img/static/Bowling.jpg b/apps/img/static/Bowling.jpg new file mode 100644 index 0000000..4f237b0 Binary files /dev/null and b/apps/img/static/Bowling.jpg differ diff --git a/apps/img/static/Crescendo.jpg b/apps/img/static/Crescendo.jpg new file mode 100644 index 0000000..61e9d9e Binary files /dev/null and b/apps/img/static/Crescendo.jpg differ diff --git a/apps/img/static/CrimsonCauldron.jpg b/apps/img/static/CrimsonCauldron.jpg new file mode 100644 index 0000000..1c264fb Binary files /dev/null and b/apps/img/static/CrimsonCauldron.jpg differ diff --git a/apps/img/static/DefaultPFP.png b/apps/img/static/DefaultPFP.png new file mode 100644 index 0000000..a0c960e Binary files /dev/null and b/apps/img/static/DefaultPFP.png differ diff --git a/apps/img/static/DiscGolfLake.jpg b/apps/img/static/DiscGolfLake.jpg new file mode 100644 index 0000000..b1470ac Binary files /dev/null and b/apps/img/static/DiscGolfLake.jpg differ diff --git a/apps/img/static/DiscGolfPropulsion.jpg b/apps/img/static/DiscGolfPropulsion.jpg new file mode 100644 index 0000000..0ae5974 Binary files /dev/null and b/apps/img/static/DiscGolfPropulsion.jpg differ diff --git a/apps/img/static/Dodgeball.jpg b/apps/img/static/Dodgeball.jpg new file mode 100644 index 0000000..20ba88d Binary files /dev/null and b/apps/img/static/Dodgeball.jpg differ diff --git a/apps/img/static/DormRoom.jpg b/apps/img/static/DormRoom.jpg new file mode 100644 index 0000000..163c7f0 Binary files /dev/null and b/apps/img/static/DormRoom.jpg differ diff --git a/apps/img/static/GoldenTrophy.jpg b/apps/img/static/GoldenTrophy.jpg new file mode 100644 index 0000000..ad8259e Binary files /dev/null and b/apps/img/static/GoldenTrophy.jpg differ diff --git a/apps/img/static/IsleOfLostSkulls.jpg b/apps/img/static/IsleOfLostSkulls.jpg new file mode 100644 index 0000000..2590a5e Binary files /dev/null and b/apps/img/static/IsleOfLostSkulls.jpg differ diff --git a/apps/img/static/LaserTag.jpg b/apps/img/static/LaserTag.jpg new file mode 100644 index 0000000..ed66cd2 Binary files /dev/null and b/apps/img/static/LaserTag.jpg differ diff --git a/apps/img/static/Legacy3DCharades.jpg b/apps/img/static/Legacy3DCharades.jpg new file mode 100644 index 0000000..0efa47d Binary files /dev/null and b/apps/img/static/Legacy3DCharades.jpg differ diff --git a/apps/img/static/Lounge.jpg b/apps/img/static/Lounge.jpg new file mode 100644 index 0000000..48017d6 Binary files /dev/null and b/apps/img/static/Lounge.jpg differ diff --git a/apps/img/static/MakerRoom.jpg b/apps/img/static/MakerRoom.jpg new file mode 100644 index 0000000..74b3c64 Binary files /dev/null and b/apps/img/static/MakerRoom.jpg differ diff --git a/apps/img/static/Orientation.jpg b/apps/img/static/Orientation.jpg new file mode 100644 index 0000000..701de67 Binary files /dev/null and b/apps/img/static/Orientation.jpg differ diff --git a/apps/img/static/Paddleball.jpg b/apps/img/static/Paddleball.jpg new file mode 100644 index 0000000..68aca48 Binary files /dev/null and b/apps/img/static/Paddleball.jpg differ diff --git a/apps/img/static/Paintball.jpg b/apps/img/static/Paintball.jpg new file mode 100644 index 0000000..38f491d Binary files /dev/null and b/apps/img/static/Paintball.jpg differ diff --git a/apps/img/static/Park.jpg b/apps/img/static/Park.jpg new file mode 100644 index 0000000..96b58ad Binary files /dev/null and b/apps/img/static/Park.jpg differ diff --git a/apps/img/static/PerfomanceHall.jpg b/apps/img/static/PerfomanceHall.jpg new file mode 100644 index 0000000..2fc02ed Binary files /dev/null and b/apps/img/static/PerfomanceHall.jpg differ diff --git a/apps/img/static/RecCenter.jpg b/apps/img/static/RecCenter.jpg new file mode 100644 index 0000000..b20927a Binary files /dev/null and b/apps/img/static/RecCenter.jpg differ diff --git a/apps/img/static/RecRoyale.jpg b/apps/img/static/RecRoyale.jpg new file mode 100644 index 0000000..d58c18a Binary files /dev/null and b/apps/img/static/RecRoyale.jpg differ diff --git a/apps/img/static/Soccer.jpg b/apps/img/static/Soccer.jpg new file mode 100644 index 0000000..0a50778 Binary files /dev/null and b/apps/img/static/Soccer.jpg differ diff --git a/apps/img/static/StuntRunner.jpg b/apps/img/static/StuntRunner.jpg new file mode 100644 index 0000000..1639dfd Binary files /dev/null and b/apps/img/static/StuntRunner.jpg differ diff --git a/apps/img/static/TheInkSpace.jpg b/apps/img/static/TheInkSpace.jpg new file mode 100644 index 0000000..dc7ce87 Binary files /dev/null and b/apps/img/static/TheInkSpace.jpg differ diff --git a/apps/img/static/TheRiseofJumbotron.jpg b/apps/img/static/TheRiseofJumbotron.jpg new file mode 100644 index 0000000..26a410e Binary files /dev/null and b/apps/img/static/TheRiseofJumbotron.jpg differ diff --git a/apps/img/wrangler.jsonc b/apps/img/wrangler.jsonc index 498221a..b48922f 100644 --- a/apps/img/wrangler.jsonc +++ b/apps/img/wrangler.jsonc @@ -4,6 +4,15 @@ "main": "src/img.app.ts", "compatibility_date": "2025-09-20", "compatibility_flags": ["nodejs_compat"], + // Static fallback assets (e.g. DefaultProfileImage.jpg served when a key is + // missing from R2). `run_worker_first` keeps the Worker in control of routing + // so image requests still hit R2/signing; assets are only fetched explicitly + // via the ASSETS binding. + "assets": { + "directory": "./static", + "binding": "ASSETS", + "run_worker_first": true + }, // Images are stored as objects in an R2 bucket and streamed back by key. "r2_buckets": [ {