mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-09 07:01:27 -07:00
resize/cache images
This commit is contained in:
+101
-17
@@ -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 })
|
||||
|
||||
Reference in New Issue
Block a user