fix(1688): accept opaque CDN image responses
This commit is contained in:
parent
ed15789a81
commit
b63b2fe974
@ -93,4 +93,60 @@ describe('product archive source image service', () => {
|
||||
|
||||
await expect(service.readByBarcode('BARCODE-1')).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('accepts an opaque binary response whose bytes sniff as an image', async () => {
|
||||
// JPEG magic bytes: FF D8 FF ...
|
||||
const jpegBytes = [0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00, 0x01]
|
||||
const service = new ProductArchiveSourceImageService(
|
||||
repository('https://lf3-cm.example.com/obj/ecom-shop-material/jpeg_m_x_sx_1_www800-800'),
|
||||
jest.fn().mockResolvedValue(
|
||||
response({ contentType: 'application/octet-stream', bytes: jpegBytes }),
|
||||
) as unknown as typeof fetch,
|
||||
jest.fn().mockResolvedValue(['93.184.216.34']),
|
||||
)
|
||||
|
||||
await expect(service.readByBarcode('BARCODE-1')).resolves.toEqual({
|
||||
bytes: expect.objectContaining({ byteLength: jpegBytes.length }),
|
||||
contentType: 'image/jpeg',
|
||||
})
|
||||
})
|
||||
|
||||
it('accepts an empty content type response whose bytes sniff as png', async () => {
|
||||
const pngBytes = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d]
|
||||
const service = new ProductArchiveSourceImageService(
|
||||
repository('https://images.example.com/product'),
|
||||
jest.fn().mockResolvedValue(response({ contentType: '', bytes: pngBytes })) as unknown as typeof fetch,
|
||||
jest.fn().mockResolvedValue(['93.184.216.34']),
|
||||
)
|
||||
|
||||
await expect(service.readByBarcode('BARCODE-1')).resolves.toEqual(
|
||||
expect.objectContaining({ contentType: 'image/png' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects an opaque binary response whose bytes are not an image', async () => {
|
||||
const service = new ProductArchiveSourceImageService(
|
||||
repository('https://lf3-cm.example.com/obj/ecom-shop-material/jpeg_m_y_sx_2_www800-800'),
|
||||
jest.fn().mockResolvedValue(
|
||||
response({ contentType: 'application/octet-stream', bytes: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13] }),
|
||||
) as unknown as typeof fetch,
|
||||
jest.fn().mockResolvedValue(['93.184.216.34']),
|
||||
)
|
||||
|
||||
await expect(service.readByBarcode('BARCODE-1')).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('never sniffs non-opaque content types back to images', async () => {
|
||||
// text/html body starting with image magic must stay rejected
|
||||
const jpegBytes = [0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00, 0x01]
|
||||
const service = new ProductArchiveSourceImageService(
|
||||
repository('https://images.example.com/page.jpg'),
|
||||
jest.fn().mockResolvedValue(
|
||||
response({ contentType: 'text/html', bytes: jpegBytes }),
|
||||
) as unknown as typeof fetch,
|
||||
jest.fn().mockResolvedValue(['93.184.216.34']),
|
||||
)
|
||||
|
||||
await expect(service.readByBarcode('BARCODE-1')).resolves.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@ -19,6 +19,35 @@ const SAFE_IMAGE_CONTENT_TYPES = new Set([
|
||||
'image/png',
|
||||
'image/webp',
|
||||
])
|
||||
// Some CDNs (e.g. Douyin ecombdstatic material objects) serve real images with
|
||||
// an opaque content type; fall back to magic-byte sniffing for these only.
|
||||
const OPAQUE_BINARY_CONTENT_TYPES = new Set([
|
||||
'application/octet-stream',
|
||||
'binary/octet-stream',
|
||||
'',
|
||||
])
|
||||
|
||||
function sniffImageContentType(bytes: ArrayBuffer): string | null {
|
||||
const view = new Uint8Array(bytes)
|
||||
if (view.length < 12) return null
|
||||
if (view[0] === 0xff && view[1] === 0xd8 && view[2] === 0xff) return 'image/jpeg'
|
||||
if (
|
||||
view[0] === 0x89 && view[1] === 0x50 && view[2] === 0x4e && view[3] === 0x47 &&
|
||||
view[4] === 0x0d && view[5] === 0x0a && view[6] === 0x1a && view[7] === 0x0a
|
||||
) return 'image/png'
|
||||
if (view[0] === 0x47 && view[1] === 0x49 && view[2] === 0x46 && view[3] === 0x38) return 'image/gif'
|
||||
if (
|
||||
view[0] === 0x52 && view[1] === 0x49 && view[2] === 0x46 && view[3] === 0x46 &&
|
||||
view[8] === 0x57 && view[9] === 0x45 && view[10] === 0x42 && view[11] === 0x50
|
||||
) return 'image/webp'
|
||||
if (view[0] === 0x42 && view[1] === 0x4d) return 'image/bmp'
|
||||
const brand = String.fromCharCode(view[8], view[9], view[10], view[11])
|
||||
if (
|
||||
view[4] === 0x66 && view[5] === 0x74 && view[6] === 0x79 && view[7] === 0x70 &&
|
||||
(brand === 'avif' || brand === 'avis')
|
||||
) return 'image/avif'
|
||||
return null
|
||||
}
|
||||
|
||||
const defaultFetch: FetchLike = (input, init) => globalThis.fetch(input, init)
|
||||
const defaultResolveAddresses: ResolveAddresses = async (hostname) => {
|
||||
@ -104,17 +133,22 @@ export class ProductArchiveSourceImageService {
|
||||
}
|
||||
if (!response.ok) return null
|
||||
|
||||
const contentType = (response.headers.get('content-type') ?? '')
|
||||
.split(';')[0]
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
if (!SAFE_IMAGE_CONTENT_TYPES.has(contentType)) return null
|
||||
|
||||
const declaredLength = Number(response.headers.get('content-length') || '0')
|
||||
if (Number.isFinite(declaredLength) && declaredLength > MAX_IMAGE_BYTES) return null
|
||||
|
||||
const bytes = await response.arrayBuffer()
|
||||
if (bytes.byteLength === 0 || bytes.byteLength > MAX_IMAGE_BYTES) return null
|
||||
|
||||
const headerContentType = (response.headers.get('content-type') ?? '')
|
||||
.split(';')[0]
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
const contentType = SAFE_IMAGE_CONTENT_TYPES.has(headerContentType)
|
||||
? headerContentType
|
||||
: OPAQUE_BINARY_CONTENT_TYPES.has(headerContentType)
|
||||
? sniffImageContentType(bytes)
|
||||
: null
|
||||
if (!contentType) return null
|
||||
return { bytes, contentType }
|
||||
}
|
||||
return null
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user