fix(mana-media): HEIC uploads from Chrome — sniff + transcode at the edge

iPhone HEIC photos uploaded through Chrome on macOS landed as
`mimeType: application/octet-stream` because Chrome doesn't recognise
the HEIC MIME and `file.type` was empty. The transform endpoint then
refused with `Transform only supported for images` (HTTP 400) and
the wardrobe Try-On flow surfaced this as `mana-media transform
failed for <id>: HTTP 400`. Even fixing the MIME wouldn't have been
enough — sharp's prebuilt binary ships the heif container format
without a HEVC decoder plugin (libde265 is omitted for patent
reasons), so the actual decode would still throw.

Three-part fix at the upload edge:

1. New `services/sniff.ts` — magic-byte sniffer for image MIMEs.
   Reads the first ~16 bytes and recognises JPEG, PNG, GIF, WebP,
   BMP, TIFF, HEIC, HEIF, AVIF. Returns `null` for everything else
   so the caller can fall back to whatever the browser claimed.

2. Upload route — sniffs every upload before passing the buffer to
   `uploadService.upload`. Trusts magic bytes over `file.type` so
   Chrome's empty-type HEIC still lands with `image/heic`. Removes
   the entire class of `application/octet-stream` rows for files
   that are obviously images.

3. HEIC/HEIF transcoded to JPEG at upload via the new
   `heic-convert` dependency (pure-JS WASM, no system libs needed).
   The original buffer is replaced with the JPEG bytes, the MIME
   becomes `image/jpeg`, and the filename's `.HEIC` extension is
   rewritten to `.jpg`. Downstream code (process pipeline, transform
   endpoint, sharp) then deals exclusively with formats sharp can
   actually decode. Failure path returns HTTP 500 with a clear
   `HEIC conversion failed` error so the client knows it wasn't a
   generic crash.

Bonus, transform endpoint hardening: `mimeType.startsWith('image/')`
gate now also accepts a row whose stored MIME is wrong (legacy
`application/octet-stream` from before this fix) when the actual
bytes sniff as an image. Lets old broken rows still serve where
the format itself is decodable; the upload-side fix prevents new
ones from existing.

Sharp 0.33 on this machine reports `heif: 1.18.2` for the container
but rejects the actual HEVC compressed bitstream — confirmed by the
exact error string `No decoding plugin installed for this
compression format (11.6003)`. Going through `heic-convert` first
sidesteps that entirely.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-25 13:46:13 +02:00
parent d880e89204
commit dff02d24a9
5 changed files with 190 additions and 13 deletions

46
pnpm-lock.yaml generated
View file

@ -2940,6 +2940,9 @@ importers:
exifr:
specifier: ^7.1.3
version: 7.1.3
heic-convert:
specifier: ^2.1.0
version: 2.1.0
hono:
specifier: ^4.7.0
version: 4.12.12
@ -2962,6 +2965,9 @@ importers:
'@mana/shared-drizzle-config':
specifier: workspace:*
version: link:../../../../packages/shared-drizzle-config
'@types/heic-convert':
specifier: ^2.1.0
version: 2.1.0
'@types/mime-types':
specifier: ^2.1.4
version: 2.1.4
@ -8328,6 +8334,9 @@ packages:
'@types/hast@3.0.4':
resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==}
'@types/heic-convert@2.1.0':
resolution: {integrity: sha512-Cf5Sdc2Gm2pfZ0uN1zjj35wcf3mF1lJCMIzws5OdJynrdMJRTIRUGa5LegbVg0hatzOPkH2uAf2JRjPYgl9apg==}
'@types/hoist-non-react-statics@3.3.7':
resolution: {integrity: sha512-PQTyIulDkIDro8P+IHbKCsw7U2xxBYflVzW/FgWdCAePD9xGSidgA76/GeJ6lBKoblyhf9pBY763gbrN+1dI8g==}
peerDependencies:
@ -11942,6 +11951,14 @@ packages:
hastscript@9.0.1:
resolution: {integrity: sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==}
heic-convert@2.1.0:
resolution: {integrity: sha512-1qDuRvEHifTVAj3pFIgkqGgJIr0M3X7cxEPjEp0oG4mo8GFjq99DpCo8Eg3kg17Cy0MTjxpFdoBHOatj7ZVKtg==}
engines: {node: '>=12.0.0'}
heic-decode@2.1.0:
resolution: {integrity: sha512-0fB3O3WMk38+PScbHLVp66jcNhsZ/ErtQ6u2lMYu/YxXgbBtl+oKOhGQHa4RpvE68k8IzbWkABzHnyAIjR758A==}
engines: {node: '>=8.0.0'}
hermes-compiler@0.14.1:
resolution: {integrity: sha512-+RPPQlayoZ9n6/KXKt5SFILWXCGJ/LV5d24L5smXrvTDrPS4L6dSctPczXauuvzFP3QEJbD1YO7Z3Ra4a+4IhA==}
@ -12606,6 +12623,9 @@ packages:
resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==}
engines: {node: '>=10'}
jpeg-js@0.4.4:
resolution: {integrity: sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==}
js-binary-schema-parser@2.0.3:
resolution: {integrity: sha512-xezGJmOb4lk/M1ZZLTR/jaBHQ4gG/lqQnJqdIv4721DMggsa1bDVlHXNeHYogaIEHD9vCRv0fcL4hMA+Coarkg==}
@ -12784,6 +12804,10 @@ packages:
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
engines: {node: '>= 0.8.0'}
libheif-js@1.19.8:
resolution: {integrity: sha512-vQJWusIxO7wavpON1dusciL8Go9jsIQ+EUrckauFYAiSTjcmLAsuJh3SszLpvkwPci3JcL41ek2n+LUZGFpPIQ==}
engines: {node: '>=8.0.0'}
libphonenumber-js@1.12.41:
resolution: {integrity: sha512-lsmMmGXBxXIK/VMLEj0kL6MtUs1kBGj1nTCzi6zgQoG1DEwqwt2DQyHxcLykceIxAnfE3hya7NuIh6PpC6S3fA==}
@ -14128,6 +14152,10 @@ packages:
resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==}
engines: {node: '>=10.13.0'}
pngjs@6.0.0:
resolution: {integrity: sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg==}
engines: {node: '>=12.13.0'}
possible-typed-array-names@1.1.0:
resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
engines: {node: '>= 0.4'}
@ -23759,6 +23787,8 @@ snapshots:
dependencies:
'@types/unist': 3.0.3
'@types/heic-convert@2.1.0': {}
'@types/hoist-non-react-statics@3.3.7(@types/react@19.2.14)':
dependencies:
'@types/react': 19.2.14
@ -28497,6 +28527,16 @@ snapshots:
property-information: 7.1.0
space-separated-tokens: 2.0.2
heic-convert@2.1.0:
dependencies:
heic-decode: 2.1.0
jpeg-js: 0.4.4
pngjs: 6.0.0
heic-decode@2.1.0:
dependencies:
libheif-js: 1.19.8
hermes-compiler@0.14.1: {}
hermes-estree@0.29.1: {}
@ -29401,6 +29441,8 @@ snapshots:
joycon@3.1.1: {}
jpeg-js@0.4.4: {}
js-binary-schema-parser@2.0.3: {}
js-tokens@10.0.0: {}
@ -29604,6 +29646,8 @@ snapshots:
prelude-ls: 1.2.1
type-check: 0.4.0
libheif-js@1.19.8: {}
libphonenumber-js@1.12.41: {}
lighthouse-logger@1.4.2:
@ -31445,6 +31489,8 @@ snapshots:
pngjs@5.0.0: {}
pngjs@6.0.0: {}
possible-typed-array-names@1.1.0: {}
postcss-import@15.1.0(postcss@8.5.8):

View file

@ -15,6 +15,7 @@
"bullmq": "^5.34.0",
"drizzle-orm": "^0.38.3",
"exifr": "^7.1.3",
"heic-convert": "^2.1.0",
"hono": "^4.7.0",
"mime-types": "^2.1.35",
"minio": "^8.0.0",
@ -24,6 +25,7 @@
},
"devDependencies": {
"@mana/shared-drizzle-config": "workspace:*",
"@types/heic-convert": "^2.1.0",
"@types/mime-types": "^2.1.4",
"@types/node": "^22.0.0",
"drizzle-kit": "^0.30.1",

View file

@ -3,6 +3,7 @@ import { stream } from 'hono/streaming';
import type { UploadService } from '../services/upload';
import type { ProcessService } from '../services/process';
import type { StorageService } from '../services/storage';
import { sniffImageMimeType } from '../services/sniff';
type Variant = 'thumb' | 'medium' | 'large';
@ -46,11 +47,19 @@ export function deliveryRoutes(
const record = await uploadService.get(c.req.param('id'));
if (!record) return c.json({ error: 'Media not found' }, 404);
if (!record.mimeType.startsWith('image/')) {
const originalBuffer = await storage.download(record.keys.original);
// Trust the stored mime first; fall back to magic-byte sniffing
// for legacy rows uploaded before the upload sniffer landed
// (HEIC from Chrome, etc.) where the row says
// `application/octet-stream` but the bytes are actually an image.
// Refuse only when neither header nor bytes look like an image.
const looksLikeImage =
record.mimeType.startsWith('image/') || sniffImageMimeType(originalBuffer) !== null;
if (!looksLikeImage) {
return c.json({ error: 'Transform only supported for images' }, 400);
}
const originalBuffer = await storage.download(record.keys.original);
const format = (c.req.query('format') as 'webp' | 'jpeg' | 'png' | 'avif') || 'webp';
const transformedBuffer = await processService.transformImage(originalBuffer, {

View file

@ -1,5 +1,6 @@
import { Hono } from 'hono';
import type { UploadService, MediaRecord } from '../services/upload';
import { sniffImageMimeType } from '../services/sniff';
function toResponse(record: MediaRecord) {
const baseUrl = process.env.PUBLIC_URL || 'http://localhost:3015/api/v1';
@ -38,18 +39,59 @@ export function uploadRoutes(uploadService: UploadService) {
return c.json({ error: 'File too large (max 100MB)' }, 400);
}
const buffer = Buffer.from(await file.arrayBuffer());
const record = await uploadService.upload(
buffer,
file.name,
file.type || 'application/octet-stream',
file.size,
{
app: body['app'] as string | undefined,
userId: body['userId'] as string | undefined,
skipProcessing: body['skipProcessing'] === 'true',
let buffer = Buffer.from(await file.arrayBuffer());
// Magic-byte sniff first; trust the bytes over the browser's
// `file.type`. Chrome on macOS doesn't recognise HEIC and sends
// an empty type, which would otherwise land as
// `application/octet-stream` and break every downstream
// `mimeType.startsWith('image/')` check (transform endpoint,
// process pipeline, etc). A successful sniff returns an
// authoritative image MIME; anything we don't recognise falls
// back to whatever the browser claimed.
const sniffed = sniffImageMimeType(buffer);
let mimeType = sniffed ?? file.type ?? 'application/octet-stream';
let storedName = file.name;
let storedSize = file.size;
// HEIC/HEIF transcode. The sharp version we ship has the heif
// container format but no HEVC decoder plugin (libde265 is not
// bundled in sharp's prebuilt binaries due to patent licensing),
// so iPhone HEIC uploads would fail every downstream sharp
// transform with `No decoding plugin installed for this
// compression format`. Convert to JPEG once at upload time via
// `heic-convert` (pure-JS WASM, no system deps); the server then
// stores standard JPEG and every later step is mime-agnostic.
if (mimeType === 'image/heic' || mimeType === 'image/heif') {
try {
const heicConvert = (await import('heic-convert')).default;
const jpegArrayBuffer = await heicConvert({
// `Buffer` extends `Uint8Array` and is what heic-convert
// actually accepts at runtime. `@types/heic-convert`
// over-tightens the param to `ArrayBufferLike` (which
// in TS ≥ 5.7 includes the `grow` property only on
// `SharedArrayBuffer`), so a normal Buffer doesn't
// match the declared type. Cast through `unknown` to
// avoid lying about a wider intersection.
buffer: buffer as unknown as ArrayBufferLike,
format: 'JPEG',
quality: 0.9,
});
buffer = Buffer.from(jpegArrayBuffer);
mimeType = 'image/jpeg';
storedName = file.name.replace(/\.(heic|heif)$/i, '.jpg');
storedSize = buffer.length;
} catch (err) {
console.error('[upload] HEIC convert failed', err);
return c.json({ error: 'HEIC conversion failed', detail: (err as Error).message }, 500);
}
);
}
const record = await uploadService.upload(buffer, storedName, mimeType, storedSize, {
app: body['app'] as string | undefined,
userId: body['userId'] as string | undefined,
skipProcessing: body['skipProcessing'] === 'true',
});
return c.json(toResponse(record), 201);
});

View file

@ -0,0 +1,78 @@
/**
* Magic-byte sniffer for image MIME types.
*
* Why this exists:
* Browsers don't all recognise the same set of image formats. Chrome
* on macOS, for example, hands a HEIC file to the upload endpoint
* with `file.type === ''`, which the server then stores as
* `application/octet-stream`. The transform endpoint subsequently
* refuses to touch the row because `mimeType.startsWith('image/')`
* is false even though the bytes on disk are a perfectly valid
* image. Sniffing the buffer's magic bytes at upload time fixes
* this at the source.
*
* The sniffer reads only the first ~16 bytes cheap, synchronous,
* runs once per upload. Only image formats are detected; any other
* file type returns null so the caller can fall back to whatever the
* browser reported.
*/
const ASCII = (s: string): number[] => Array.from(s, (c) => c.charCodeAt(0));
function bytesEqual(buf: Buffer, offset: number, expected: number[]): boolean {
if (offset + expected.length > buf.length) return false;
for (let i = 0; i < expected.length; i++) {
if (buf[offset + i] !== expected[i]) return false;
}
return true;
}
// HEIF/HEIC family — major brand at offset 8, after the 4-byte size +
// `ftyp` marker at offset 4. List from ISO/IEC 23008-12 + 14496-12.
// AVIF shares the same container with a different brand.
const HEIC_BRANDS = ['heic', 'heix', 'heim', 'heis', 'hevc', 'hevx', 'mif1', 'msf1'];
const AVIF_BRANDS = ['avif', 'avis'];
/**
* Inspect the first bytes of `buf` and return a canonical image MIME
* type if recognized, or null when nothing matches. Trustworthy
* substitute for `file.type` when the browser left it empty or
* defaulted it to `application/octet-stream`.
*/
export function sniffImageMimeType(buf: Buffer): string | null {
// JPEG — FF D8 FF
if (bytesEqual(buf, 0, [0xff, 0xd8, 0xff])) return 'image/jpeg';
// PNG — 89 50 4E 47 0D 0A 1A 0A
if (bytesEqual(buf, 0, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) {
return 'image/png';
}
// GIF87a / GIF89a — 47 49 46 38 ...
if (bytesEqual(buf, 0, ASCII('GIF8'))) return 'image/gif';
// WebP — RIFF....WEBP at offset 8
if (bytesEqual(buf, 0, ASCII('RIFF')) && bytesEqual(buf, 8, ASCII('WEBP'))) {
return 'image/webp';
}
// BMP — 42 4D
if (bytesEqual(buf, 0, [0x42, 0x4d])) return 'image/bmp';
// TIFF — 49 49 2A 00 (LE) or 4D 4D 00 2A (BE)
if (
bytesEqual(buf, 0, [0x49, 0x49, 0x2a, 0x00]) ||
bytesEqual(buf, 0, [0x4d, 0x4d, 0x00, 0x2a])
) {
return 'image/tiff';
}
// HEIC / HEIF / AVIF — `ftyp` at offset 4, brand at offset 8.
if (bytesEqual(buf, 4, ASCII('ftyp'))) {
const brand = buf.slice(8, 12).toString('ascii');
if (HEIC_BRANDS.includes(brand)) return 'image/heic';
if (AVIF_BRANDS.includes(brand)) return 'image/avif';
}
return null;
}