resize/cache images

This commit is contained in:
Devin Zuczek
2026-07-06 23:23:27 -04:00
parent 7017cab732
commit e81d4cc02c
5 changed files with 195 additions and 17 deletions
+1
View File
@@ -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"
+101 -17
View File
@@ -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<CryptoKey | null> | undefined
@@ -33,7 +89,7 @@ function getSigningKey(env: Env): Promise<CryptoKey | null> {
}
/** RSA-SHA1 sign the bytes, base64-encoded. */
async function signImage(env: Env, bytes: ArrayBuffer): Promise<string | null> {
async function signImage(env: Env, bytes: BufferSource): Promise<string | null> {
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<string | null> {
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<Response> {
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<Response> {
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<App>()
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<App>()
// 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<App>()
// 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 })
+75
View File
@@ -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)
})
})
+3
View File
@@ -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",