diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9937ec7c6..9571a7020 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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): diff --git a/services/mana-media/apps/api/package.json b/services/mana-media/apps/api/package.json index 6acb7fb3c..9625daf81 100644 --- a/services/mana-media/apps/api/package.json +++ b/services/mana-media/apps/api/package.json @@ -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", diff --git a/services/mana-media/apps/api/src/routes/delivery.ts b/services/mana-media/apps/api/src/routes/delivery.ts index 5b76201dc..2d40bbb5d 100644 --- a/services/mana-media/apps/api/src/routes/delivery.ts +++ b/services/mana-media/apps/api/src/routes/delivery.ts @@ -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, { diff --git a/services/mana-media/apps/api/src/routes/upload.ts b/services/mana-media/apps/api/src/routes/upload.ts index f046a538b..fc039a8ff 100644 --- a/services/mana-media/apps/api/src/routes/upload.ts +++ b/services/mana-media/apps/api/src/routes/upload.ts @@ -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); }); diff --git a/services/mana-media/apps/api/src/services/sniff.ts b/services/mana-media/apps/api/src/services/sniff.ts new file mode 100644 index 000000000..692960d84 --- /dev/null +++ b/services/mana-media/apps/api/src/services/sniff.ts @@ -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; +}