load up images
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
|
After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 186 KiB |
|
After Width: | Height: | Size: 353 KiB |
|
After Width: | Height: | Size: 318 KiB |
|
After Width: | Height: | Size: 370 KiB |
|
After Width: | Height: | Size: 373 KiB |
|
After Width: | Height: | Size: 221 KiB |
|
After Width: | Height: | Size: 273 KiB |
|
After Width: | Height: | Size: 334 KiB |
|
After Width: | Height: | Size: 114 KiB |
|
After Width: | Height: | Size: 520 KiB |
|
After Width: | Height: | Size: 114 KiB |
|
After Width: | Height: | Size: 142 KiB |
|
After Width: | Height: | Size: 134 KiB |
|
After Width: | Height: | Size: 121 KiB |
|
After Width: | Height: | Size: 140 KiB |
|
After Width: | Height: | Size: 129 KiB |
|
After Width: | Height: | Size: 149 KiB |
|
After Width: | Height: | Size: 144 KiB |
|
After Width: | Height: | Size: 156 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 115 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 176 KiB |
|
After Width: | Height: | Size: 133 KiB |
|
After Width: | Height: | Size: 153 KiB |
|
After Width: | Height: | Size: 77 KiB |
|
After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 134 KiB |
|
After Width: | Height: | Size: 136 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 115 KiB |
|
After Width: | Height: | Size: 122 KiB |
|
After Width: | Height: | Size: 123 KiB |
@@ -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": [
|
||||
{
|
||||
|
||||