mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
[cdn] fix some range issues where delivered images might be corrupt
This commit is contained in:
+39
-5
@@ -3,7 +3,7 @@ import { Hono } from 'hono'
|
||||
import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
|
||||
import { useWorkersLogger } from 'workers-tagged-logger'
|
||||
|
||||
import { withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
|
||||
import { withCleanSpec, withNotFound, withOnError, writeContentRange } from '@repo/hono-helpers'
|
||||
|
||||
import { imageBytes, json, ServiceStatus } from './openapi'
|
||||
|
||||
@@ -373,6 +373,12 @@ app.get(
|
||||
'return JPEG with no `ETag` (the source etag no longer describes the body), and the',
|
||||
'`If-None-Match` precondition is skipped. An out-of-range or non-integer dimension is',
|
||||
'ignored and the original is served — never an error.',
|
||||
'',
|
||||
'A `Range` is honoured (206) only on the untouched stream, which is the only response',
|
||||
'that advertises `Accept-Ranges`. A transform decodes the whole image and a real',
|
||||
'signature covers the whole body, so those serve the entire result and ignore the',
|
||||
'header. Where a range does apply, a `bytes=` request is never answered with a bare',
|
||||
'200: the `Content-Range` always states which bytes the body holds.',
|
||||
].join('\n'),
|
||||
parameters: [
|
||||
{
|
||||
@@ -433,9 +439,22 @@ app.get(
|
||||
'Conditional request against the R2 object etag. Ignored when a transform is requested.',
|
||||
schema: { type: 'string' },
|
||||
},
|
||||
{
|
||||
name: 'Range',
|
||||
in: 'header',
|
||||
required: false,
|
||||
description: [
|
||||
'A single byte range, parsed by R2 itself. Honoured with a 206 on the untouched',
|
||||
'stream only — ignored when a transform or a real signature applies, since both',
|
||||
'need the whole image. A `bytes=` value never yields a bare 200: the',
|
||||
'`Content-Range` names the bytes enclosed even where that is all of them.',
|
||||
].join(' '),
|
||||
schema: { type: 'string', example: 'bytes=0-1023' },
|
||||
},
|
||||
],
|
||||
responses: {
|
||||
200: imageBytes('The image bytes (or the DefaultProfileImage.jpg fallback)'),
|
||||
206: imageBytes('A byte range of the stored image, when the request carried a `Range`'),
|
||||
304: { description: 'If-None-Match matched the stored object etag; no body' },
|
||||
400: { description: 'The key contained `..`; no body' },
|
||||
},
|
||||
@@ -468,10 +487,16 @@ app.get(
|
||||
// one. Skip the precondition when a transform is requested.
|
||||
const ifNoneMatch = transform ? undefined : c.req.header('if-none-match')?.replace(/"/g, '')
|
||||
const { bucket, objectKey } = resolveObject(c.env, key)
|
||||
const object = await bucket.get(
|
||||
objectKey,
|
||||
ifNoneMatch ? { onlyIf: { etagDoesNotMatch: ifNoneMatch } } : undefined
|
||||
)
|
||||
// A `Range` applies only to the untouched stream. Resizing decodes the whole image
|
||||
// and an RSA signature covers the whole body, so a ranged read there would produce
|
||||
// bytes that are not the range asked for — ask R2 for the range only when we are
|
||||
// going to hand its bytes straight back. R2 parses the header itself; see
|
||||
// writeContentRange() below for why it is never answered with a bare 200.
|
||||
const range = needsBody(transform, signing) ? undefined : c.req.raw.headers
|
||||
const object = await bucket.get(objectKey, {
|
||||
...(ifNoneMatch ? { onlyIf: { etagDoesNotMatch: ifNoneMatch } } : {}),
|
||||
...(range ? { range } : {}),
|
||||
})
|
||||
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
|
||||
@@ -497,6 +522,15 @@ app.get(
|
||||
return finalizeImage(c.env, bytes, headers, transform, signing)
|
||||
}
|
||||
|
||||
// Only the untouched stream can honour a range, so only it advertises the fact.
|
||||
// The transformed and static-asset paths above serve the whole thing regardless,
|
||||
// which is the legal answer to a range you cannot honour — but claiming
|
||||
// `accept-ranges` there would invite a client to expect otherwise.
|
||||
headers.set('accept-ranges', 'bytes')
|
||||
if (writeContentRange(headers, c.req.raw.headers, object)) {
|
||||
return new Response(object.body, { status: 206, headers })
|
||||
}
|
||||
|
||||
return new Response(object.body, { headers })
|
||||
}
|
||||
)
|
||||
|
||||
@@ -140,6 +140,62 @@ describe('img endpoints', () => {
|
||||
expect(res.status).toBe(304)
|
||||
})
|
||||
|
||||
it('honors a Range request on the stored image with a 206', async () => {
|
||||
const res = await SELF.fetch(`${ORIGIN}/${R2_KEY}`, { headers: { Range: 'bytes=2-4' } })
|
||||
expect(res.status).toBe(206)
|
||||
expect(res.headers.get('content-range')).toBe('bytes 2-4/8')
|
||||
expect(res.headers.get('accept-ranges')).toBe('bytes')
|
||||
expect(new Uint8Array(await res.arrayBuffer())).toEqual(IMAGE_BYTES.slice(2, 5))
|
||||
})
|
||||
|
||||
// R2 resolves a range it cannot parse or satisfy to the WHOLE object rather than
|
||||
// failing. Handing that back as a bare 200 is the shape that corrupts a chunked
|
||||
// download — the client wrote a whole file where it expected a slice — so every one
|
||||
// of these still states what the body holds.
|
||||
it('never answers a bytes range with a whole-object 200', async () => {
|
||||
for (const range of ['bytes=100-200', 'bytes=abc', 'bytes=0-1,3-4', 'bytes=0-7']) {
|
||||
const res = await SELF.fetch(`${ORIGIN}/${R2_KEY}`, { headers: { Range: range } })
|
||||
expect(res.status, range).toBe(206)
|
||||
expect(res.headers.get('content-range'), range).toBe('bytes 0-7/8')
|
||||
}
|
||||
|
||||
// A unit other than bytes must be ignored outright (RFC 9110), not answered with
|
||||
// a byte-denominated Content-Range.
|
||||
const other = await SELF.fetch(`${ORIGIN}/${R2_KEY}`, { headers: { Range: 'items=0-1' } })
|
||||
expect(other.status).toBe(200)
|
||||
expect(other.headers.get('content-range')).toBeNull()
|
||||
})
|
||||
|
||||
// A resize decodes the whole image, so there is no meaningful slice of the source to
|
||||
// read — the range is ignored and the whole transformed result served, which is the
|
||||
// legal answer. What it must NOT do is claim a 206 over bytes it rebuilt. Runs against
|
||||
// the R2 path (a decodable JPEG borrowed from `static/`), since that is the one that
|
||||
// has a range to suppress; the static-asset path is never handed one at all.
|
||||
it('ignores a Range when a transform rebuilds the body', async () => {
|
||||
const real = await (await SELF.fetch(`${ORIGIN}/3DCharades.jpg`)).arrayBuffer()
|
||||
await env.IMAGES.put('ranged-transform.jpg', real, {
|
||||
httpMetadata: { contentType: 'image/jpeg' },
|
||||
})
|
||||
|
||||
const res = await SELF.fetch(`${ORIGIN}/ranged-transform.jpg?width=128`, {
|
||||
headers: { Range: 'bytes=0-9' },
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.headers.get('content-range')).toBeNull()
|
||||
expect(res.headers.get('accept-ranges')).toBeNull()
|
||||
expect(jpegSize(new Uint8Array(await res.arrayBuffer())).width).toBe(128)
|
||||
|
||||
// Same for a real RSA signature, which covers the whole body (the test env binds
|
||||
// IMG_SIGNING_ENABLED on, so `?sig=p1` takes the signing path rather than the stub).
|
||||
const signed = await SELF.fetch(`${ORIGIN}/ranged-transform.jpg?sig=p1`, {
|
||||
headers: { Range: 'bytes=0-9' },
|
||||
})
|
||||
expect(signed.status).toBe(200)
|
||||
expect(signed.headers.get('content-range')).toBeNull()
|
||||
expect(signed.headers.get('content-signature')).toContain('key-id=KEY:RSA:p1.rec.net')
|
||||
expect(new Uint8Array(await signed.arrayBuffer()).byteLength).toBe(real.byteLength)
|
||||
})
|
||||
|
||||
it('serves the DefaultProfileImage.jpg fallback for a missing image', async () => {
|
||||
const res = await SELF.fetch(`${ORIGIN}/missing.png`)
|
||||
expect(res.status).toBe(200)
|
||||
|
||||
Reference in New Issue
Block a user