load up images

This commit is contained in:
Devin Zuczek
2026-07-05 16:29:41 -04:00
parent f48d78bd5f
commit 0e8bf114e0
39 changed files with 127 additions and 11 deletions
+4 -1
View File
@@ -9,7 +9,10 @@ key:
- `GET /<key>` — 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 /<key>?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=<base64>`
header. The client uses this to verify image integrity. Signing buffers the
+2
View File
@@ -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.
+47 -2
View File
@@ -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<CryptoKey | null> | undefined
@@ -39,6 +42,32 @@ async function signImage(env: Env, bytes: ArrayBuffer): Promise<string | null> {
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<Response> {
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<App>()
.use(
'*',
@@ -66,12 +95,28 @@ const app = new Hono<App>()
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<App>()
// 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) {
+65 -8
View File
@@ -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()
})
})
Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 353 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 318 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 370 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 373 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 273 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 334 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 520 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

+9
View File
@@ -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": [
{