mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 18:41:08 +02:00
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:
parent
d880e89204
commit
dff02d24a9
5 changed files with 190 additions and 13 deletions
46
pnpm-lock.yaml
generated
46
pnpm-lock.yaml
generated
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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, {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
78
services/mana-media/apps/api/src/services/sniff.ts
Normal file
78
services/mana-media/apps/api/src/services/sniff.ts
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue