From e81d4cc02cb1b97e37ac7d52f4abedc218c43db6 Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Mon, 6 Jul 2026 23:23:27 -0400 Subject: [PATCH] resize/cache images --- apps/img/package.json | 1 + apps/img/src/img.app.ts | 118 ++++++++++++++++++---- apps/img/src/test/integration/api.test.ts | 75 ++++++++++++++ apps/img/wrangler.jsonc | 3 + pnpm-lock.yaml | 15 +++ 5 files changed, 195 insertions(+), 17 deletions(-) diff --git a/apps/img/package.json b/apps/img/package.json index 30010db..2164c36 100644 --- a/apps/img/package.json +++ b/apps/img/package.json @@ -16,6 +16,7 @@ "test": "run-vitest" }, "dependencies": { + "@cf-wasm/photon": "^0.3.6", "@repo/hono-helpers": "workspace:*", "hono": "4.12.27", "workers-tagged-logger": "1.0.1" diff --git a/apps/img/src/img.app.ts b/apps/img/src/img.app.ts index 678449c..0527d66 100644 --- a/apps/img/src/img.app.ts +++ b/apps/img/src/img.app.ts @@ -1,3 +1,4 @@ +import { PhotonImage, resize, SamplingFilter } from '@cf-wasm/photon' import { Hono } from 'hono' import { useWorkersLogger } from 'workers-tagged-logger' @@ -11,6 +12,61 @@ 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' +/** Upper bound on a requested output dimension; guards against abuse. */ +const MAX_DIMENSION = 4096 + +/** JPEG quality used when re-encoding a resized image. */ +const RESIZE_JPEG_QUALITY = 90 + +/** A requested resize, from `?width=`/`?height=`. At least one is set. */ +interface Transform { + width?: number + height?: number +} + +/** Parse a positive-integer dimension query param, or `undefined` if invalid/absent. */ +function parseDimension(value: string | undefined): number | undefined { + if (value === undefined) return undefined + const n = Number(value) + if (!Number.isInteger(n) || n <= 0 || n > MAX_DIMENSION) return undefined + return n +} + +/** Build a `Transform` from the request query, or `null` when none is requested. */ +function parseTransform(width: string | undefined, height: string | undefined): Transform | null { + const w = parseDimension(width) + const h = parseDimension(height) + if (w === undefined && h === undefined) return null + return { width: w, height: h } +} + +/** + * Decode `input`, resize (preserving aspect ratio when only one dimension is + * given), and re-encode as JPEG. Runs the Photon WASM codec in-isolate — there + * is no caching yet, so every request pays the full decode/resize/encode cost. + */ +function resizeImage(input: Uint8Array, transform: Transform): Uint8Array { + const img = PhotonImage.new_from_byteslice(input) + try { + const srcW = img.get_width() + const srcH = img.get_height() + let { width, height } = transform + if (width !== undefined && height === undefined) { + height = Math.max(1, Math.round((srcH / srcW) * width)) + } else if (height !== undefined && width === undefined) { + width = Math.max(1, Math.round((srcW / srcH) * height)) + } + const resized = resize(img, width!, height!, SamplingFilter.Lanczos3) + try { + return resized.get_bytes_jpeg(RESIZE_JPEG_QUALITY) + } finally { + resized.free() + } + } finally { + img.free() + } +} + // 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 @@ -33,7 +89,7 @@ function getSigningKey(env: Env): Promise { } /** RSA-SHA1 sign the bytes, base64-encoded. */ -async function signImage(env: Env, bytes: ArrayBuffer): Promise { +async function signImage(env: Env, bytes: BufferSource): Promise { const key = await getSigningKey(env) if (!key) return null const sig = await crypto.subtle.sign('RSASSA-PKCS1-v1_5', key, bytes) @@ -42,13 +98,45 @@ async function signImage(env: Env, bytes: ArrayBuffer): Promise { return btoa(binary) } +/** + * Given the full image bytes and prepared response `headers`, optionally resize + * (Photon) and/or RSA-SHA1 sign (`?sig=p1`) before returning the `Response`. + * Both operations need the whole body, so callers buffer before calling this. + */ +async function finalizeImage( + env: Env, + bytes: ArrayBuffer, + headers: Headers, + transform: Transform | null, + wantsSignature: boolean +): Promise { + let body: BufferSource = bytes + if (transform) { + body = resizeImage(new Uint8Array(bytes), transform) + // Output is always JPEG, and the source etag no longer describes the body. + headers.set('content-type', 'image/jpeg') + headers.delete('etag') + } + + if (wantsSignature) { + const signature = await signImage(env, body) + if (signature) { + headers.set('content-signature', `key-id=${SIGNATURE_KEY_ID}; data=${signature}`) + } + } + + return new Response(body, { headers }) +} + /** * Serve a static asset `Response` with our standard cache headers, honouring - * `?sig=p1` by RSA-SHA1 signing the (buffered) body into `Content-Signature`. + * `?width`/`?height` (resize) and `?sig=p1` (signing). Either requires the full + * body, so the asset is buffered; otherwise it is streamed through untouched. */ async function serveStaticAsset( env: Env, asset: Response, + transform: Transform | null, wantsSignature: boolean ): Promise { const headers = new Headers() @@ -56,13 +144,9 @@ async function serveStaticAsset( if (contentType) headers.set('content-type', contentType) headers.set('cache-control', 'public, max-age=3600') - if (wantsSignature) { + if (transform || 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 finalizeImage(env, bytes, headers, transform, wantsSignature) } return new Response(asset.body, { headers }) @@ -96,16 +180,20 @@ const app = new Hono() if (key.includes('..')) return c.body(null, 400) const wantsSignature = c.req.query('sig') === 'p1' + const transform = parseTransform(c.req.query('width'), c.req.query('height')) // 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) + return serveStaticAsset(c.env, staticAsset, transform, wantsSignature) } - const ifNoneMatch = c.req.header('if-none-match')?.replace(/"/g, '') + // Conditional requests only make sense for the untransformed object: a + // 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, ifNoneMatch ? { onlyIf: { etagDoesNotMatch: ifNoneMatch } } : undefined @@ -115,7 +203,7 @@ const app = new Hono() // 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) + return serveStaticAsset(c.env, asset, transform, wantsSignature) } const headers = new Headers() @@ -126,13 +214,9 @@ 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 (wantsSignature) { + if (transform || wantsSignature) { const bytes = await object.arrayBuffer() - const signature = await signImage(c.env, bytes) - if (signature) { - headers.set('content-signature', `key-id=${SIGNATURE_KEY_ID}; data=${signature}`) - } - return new Response(bytes, { headers }) + return finalizeImage(c.env, bytes, headers, transform, wantsSignature) } return new Response(object.body, { headers }) diff --git a/apps/img/src/test/integration/api.test.ts b/apps/img/src/test/integration/api.test.ts index 90efcd6..514b427 100644 --- a/apps/img/src/test/integration/api.test.ts +++ b/apps/img/src/test/integration/api.test.ts @@ -11,6 +11,34 @@ declare module 'cloudflare:test' { const ORIGIN = 'https://example.com' +/** Read the pixel dimensions out of a JPEG's SOF marker, independent of Photon. */ +function jpegSize(bytes: Uint8Array): { width: number; height: number } { + let i = 2 // skip SOI (FFD8) + while (i + 1 < bytes.length) { + if (bytes[i] !== 0xff) { + i++ + continue + } + const marker = bytes[i + 1] + // Standalone markers carry no length payload. + if (marker === 0xd8 || marker === 0xd9 || (marker >= 0xd0 && marker <= 0xd7)) { + i += 2 + continue + } + const len = (bytes[i + 2] << 8) | bytes[i + 3] + const isSOF = + marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc + if (isSOF) { + return { + height: (bytes[i + 5] << 8) | bytes[i + 6], + width: (bytes[i + 7] << 8) | bytes[i + 8], + } + } + i += 2 + len + } + throw new Error('no SOF marker found') +} + // A tiny valid JPEG magic-number blob — enough to assert round-tripping. const IMAGE_BYTES = new Uint8Array([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46]) @@ -137,4 +165,51 @@ describe('img endpoints', () => { const res = await SELF.fetch(`${ORIGIN}/${R2_KEY}`) expect(res.headers.get('content-signature')).toBeNull() }) + + it('resizes a static asset to ?width, preserving aspect ratio', async () => { + const full = new Uint8Array(await (await SELF.fetch(`${ORIGIN}/RecCenter.jpg`)).arrayBuffer()) + const original = jpegSize(full) + + const res = await SELF.fetch(`${ORIGIN}/RecCenter.jpg?width=512`) + expect(res.status).toBe(200) + expect(res.headers.get('content-type')).toBe('image/jpeg') + // Resized responses carry no etag (the source etag no longer describes them). + expect(res.headers.get('etag')).toBeNull() + + const body = new Uint8Array(await res.arrayBuffer()) + const resized = jpegSize(body) + expect(resized.width).toBe(512) + // Height scales with the source aspect ratio (allow 1px rounding). + expect(resized.height).toBeCloseTo(Math.round((original.height / original.width) * 512), -0.5) + }) + + it('ignores an invalid ?width and serves the original', async () => { + const full = new Uint8Array(await (await SELF.fetch(`${ORIGIN}/RecCenter.jpg`)).arrayBuffer()) + const res = await SELF.fetch(`${ORIGIN}/RecCenter.jpg?width=0`) + expect(res.status).toBe(200) + expect(new Uint8Array(await res.arrayBuffer())).toEqual(full) + }) + + it('signs the resized body with ?width and ?sig=p1', async () => { + const res = await SELF.fetch(`${ORIGIN}/RecCenter.jpg?width=512&sig=p1`) + expect(res.status).toBe(200) + expect(jpegSize(new Uint8Array(await res.clone().arrayBuffer())).width).toBe(512) + + const header = res.headers.get('content-signature') + expect(header).toMatch(/^key-id=KEY:RSA:p1\.rec\.net; data=/) + + const signature = Uint8Array.from(atob(header!.split('data=')[1]), (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'] + ) + // The signature must verify over the RESIZED bytes the client receives. + const ok = await crypto.subtle.verify('RSASSA-PKCS1-v1_5', publicKey, signature, body) + expect(ok).toBe(true) + }) }) diff --git a/apps/img/wrangler.jsonc b/apps/img/wrangler.jsonc index b48922f..b566c29 100644 --- a/apps/img/wrangler.jsonc +++ b/apps/img/wrangler.jsonc @@ -8,6 +8,9 @@ // 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. + "cache": { + "enabled": true, + }, "assets": { "directory": "./static", "binding": "ASSETS", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3eaedf8..041222b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -301,6 +301,9 @@ importers: apps/img: dependencies: + '@cf-wasm/photon': + specifier: ^0.3.6 + version: 0.3.6 '@repo/hono-helpers': specifier: workspace:* version: link:../../packages/hono-helpers @@ -766,6 +769,12 @@ packages: resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} engines: {node: '>=6.9.0'} + '@cf-wasm/internals@0.1.2': + resolution: {integrity: sha512-9d/I3JFv1IpQFYOrIw5RQShQPyuZRw9DyeBylF39Uj/MH7my8+EzKDPpUCHiqZ5O7tZS/A6zwP08egpeI5NBhA==} + + '@cf-wasm/photon@0.3.6': + resolution: {integrity: sha512-LfLfJ10+Z+DrohjQTSBkgmqxp6d4gvlGuwFkb5c4RHJGAMyVJxrAQWGrLunWE6TvZssIkT5fhKAjQZYhFSYzqg==} + '@cloudflare/kv-asset-handler@0.5.0': resolution: {integrity: sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==} engines: {node: '>=22.0.0'} @@ -2664,6 +2673,12 @@ snapshots: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 + '@cf-wasm/internals@0.1.2': {} + + '@cf-wasm/photon@0.3.6': + dependencies: + '@cf-wasm/internals': 0.1.2 + '@cloudflare/kv-asset-handler@0.5.0': {} '@cloudflare/unenv-preset@2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260625.1)':