mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 22:51:30 -07:00
add playersettings, img, more match endpoints
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
import type { HonoApp } from '@repo/hono-helpers'
|
||||
import type { SharedHonoEnv, SharedHonoVariables } from '@repo/hono-helpers/src/types'
|
||||
|
||||
export type Env = SharedHonoEnv & {
|
||||
/** R2 bucket holding the served image objects, keyed by filename. */
|
||||
IMAGES: R2Bucket
|
||||
/**
|
||||
* RSA-2048 private key (PKCS8 DER, base64) used to sign image responses
|
||||
* requested with `?sig=p1`. Optional — when absent, responses are unsigned.
|
||||
*/
|
||||
IMG_SIGNING_KEY?: string
|
||||
}
|
||||
|
||||
/** Variables can be extended */
|
||||
export type Variables = SharedHonoVariables
|
||||
|
||||
export interface App extends HonoApp {
|
||||
Bindings: Env
|
||||
Variables: Variables
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { Hono } from 'hono'
|
||||
import { useWorkersLogger } from 'workers-tagged-logger'
|
||||
|
||||
import { withNotFound, withOnError } from '@repo/hono-helpers'
|
||||
|
||||
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'
|
||||
|
||||
// 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
|
||||
|
||||
function getSigningKey(env: Env): Promise<CryptoKey | null> {
|
||||
if (signingKey === undefined) {
|
||||
signingKey = (async () => {
|
||||
if (!env.IMG_SIGNING_KEY) return null
|
||||
const der = Uint8Array.from(atob(env.IMG_SIGNING_KEY), (ch) => ch.charCodeAt(0))
|
||||
return crypto.subtle.importKey(
|
||||
'pkcs8',
|
||||
der,
|
||||
{ name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-1' },
|
||||
false,
|
||||
['sign']
|
||||
)
|
||||
})()
|
||||
}
|
||||
return signingKey
|
||||
}
|
||||
|
||||
/** RSA-SHA1 sign the bytes, base64-encoded — matches the C# `Signatures.Sign`. */
|
||||
async function signImage(env: Env, bytes: ArrayBuffer): Promise<string | null> {
|
||||
const key = await getSigningKey(env)
|
||||
if (!key) return null
|
||||
const sig = await crypto.subtle.sign('RSASSA-PKCS1-v1_5', key, bytes)
|
||||
let binary = ''
|
||||
for (const byte of new Uint8Array(sig)) binary += String.fromCharCode(byte)
|
||||
return btoa(binary)
|
||||
}
|
||||
|
||||
const app = new Hono<App>()
|
||||
.use(
|
||||
'*',
|
||||
// middleware
|
||||
(c, next) =>
|
||||
useWorkersLogger(c.env.NAME, {
|
||||
environment: c.env.ENVIRONMENT,
|
||||
release: c.env.SENTRY_RELEASE,
|
||||
})(c, next)
|
||||
)
|
||||
|
||||
.onError(withOnError())
|
||||
.notFound(withNotFound())
|
||||
|
||||
.get('/', (c) => c.json({ service: 'img', status: 'ok' }))
|
||||
|
||||
// Stream an image straight from the R2 bucket by key, e.g.
|
||||
// `GET /DefaultProfileImage.jpg`. The key may contain slashes for nested
|
||||
// objects. Supports conditional requests via If-None-Match.
|
||||
//
|
||||
// When the client appends `?sig=p1`, the response body is RSA-SHA1 signed and
|
||||
// the signature returned in a `Content-Signature` header (mirrors the C#
|
||||
// ImageController). Signing requires the full body, so the object is buffered.
|
||||
.get('/:key{.+}', async (c) => {
|
||||
const key = c.req.param('key')
|
||||
if (key.includes('..')) return c.body(null, 400)
|
||||
|
||||
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()
|
||||
|
||||
const headers = new Headers()
|
||||
object.writeHttpMetadata(headers)
|
||||
headers.set('etag', object.httpEtag)
|
||||
headers.set('cache-control', 'public, max-age=3600')
|
||||
|
||||
// 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') {
|
||||
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 new Response(object.body, { headers })
|
||||
})
|
||||
|
||||
export default app
|
||||
@@ -0,0 +1,83 @@
|
||||
import { env, SELF } from 'cloudflare:test'
|
||||
import { beforeAll, describe, expect, it } from 'vitest'
|
||||
|
||||
import '../../img.app'
|
||||
|
||||
import type { Env } from '../../context'
|
||||
|
||||
declare module 'cloudflare:test' {
|
||||
interface ProvidedEnv extends Env {}
|
||||
}
|
||||
|
||||
const ORIGIN = 'https://img.rec.djdevin.net'
|
||||
|
||||
// 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])
|
||||
|
||||
// Public half (SPKI DER, base64) of the dev IMG_SIGNING_KEY in wrangler.jsonc —
|
||||
// used to verify the Content-Signature header.
|
||||
const PUBLIC_SPKI_B64 =
|
||||
'MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA1EIcBzPCvOFRy3WYuG8ICaRyr/OpotABJBpiMq2zcZHsSPXQw7NC+N082JDqYLy627oB9qJ+wC3idtbzFTANLkIYIEWMWJC9hjWl56vBVXOIroji2+lOpR4hV9JRdgmJfBYXmJPtHRP4GAl8np9xcnZpbMJdauR+HIJiQT3QHc2RomLXWCUfOb564cW8Ks7CLlmXPWf4M77DufHhY+788uWq6bI0+QSJ1qrUi3gaou0HPj7YPTl7pUTwX4VOmHKN5Nw+/jB9f2JNpRKp9niylCVUgdHnmHz5iqMW86HRf7EJcalSyYn7cC6b1ng9GPYryybipZ7QuTgl52qu2GQDaQIDAQAB'
|
||||
|
||||
beforeAll(async () => {
|
||||
await env.IMAGES.put('DefaultProfileImage.jpg', IMAGE_BYTES, {
|
||||
httpMetadata: { contentType: 'image/jpeg' },
|
||||
})
|
||||
})
|
||||
|
||||
describe('img endpoints', () => {
|
||||
it('GET / reports service status', async () => {
|
||||
const res = await SELF.fetch(`${ORIGIN}/`)
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({ service: 'img', status: 'ok' })
|
||||
})
|
||||
|
||||
it('streams an image stored in R2 with its content type', async () => {
|
||||
const res = await SELF.fetch(`${ORIGIN}/DefaultProfileImage.jpg`)
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.headers.get('content-type')).toBe('image/jpeg')
|
||||
expect(new Uint8Array(await res.arrayBuffer())).toEqual(IMAGE_BYTES)
|
||||
})
|
||||
|
||||
it('returns 304 when If-None-Match matches the etag', async () => {
|
||||
const first = await SELF.fetch(`${ORIGIN}/DefaultProfileImage.jpg`)
|
||||
const etag = first.headers.get('etag')
|
||||
expect(etag).toBeTruthy()
|
||||
const res = await SELF.fetch(`${ORIGIN}/DefaultProfileImage.jpg`, {
|
||||
headers: { 'If-None-Match': etag! },
|
||||
})
|
||||
expect(res.status).toBe(304)
|
||||
})
|
||||
|
||||
it('404 for a missing image', async () => {
|
||||
const res = await SELF.fetch(`${ORIGIN}/missing.png`)
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
|
||||
it('signs the response with ?sig=p1 and the signature verifies', async () => {
|
||||
const res = await SELF.fetch(`${ORIGIN}/DefaultProfileImage.jpg?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('does not sign without ?sig=p1', async () => {
|
||||
const res = await SELF.fetch(`${ORIGIN}/DefaultProfileImage.jpg`)
|
||||
expect(res.headers.get('content-signature')).toBeNull()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user