fix(médias) : HEIC et AVIF reconnus dans les octets, plus typés vidéo/mp4

Une photo HEIC (marques heic, heix, mif1, msf1, heim, heis) était typée video/mp4. Elle est maintenant image/heic,
et AVIF image/avif. Elles restent refusées à l'envoi (invalid_media_mime_type), comme avant : sharp ne décode
que l'AVIF en entrée HEIF, pas le HEVC des HEIC. La marque hevc n'est une photo que si l'envoi se déclare photo,
pour qu'une vidéo HEVC en MP4 reste acceptée. Les vignettes Immich ne sont servies que pour les types image
autorisés.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
IliesAllaliandClaude Haiku 5.5 committed 2026-10-21 15:51:48 +02:00
1 parent 25b33ca056
commit 0fb4aeec0d
3 files changed
+48 -2

No files matched your search

+7
View File
@@ -59,6 +59,9 @@ const EXTENSION_BY_MIME: Record<string, string> = {
'application/gpx+xml': '.gpx',
}
// Marques ISO BMFF des images HEIF/HEIC (ISO/IEC 23008-12)
const HEIC_BRANDS = new Set(['heic', 'heix', 'mif1', 'msf1', 'heim', 'heis'])
function startsWith(buf: Buffer, bytes: number[], offset = 0): boolean {
if (buf.length < offset + bytes.length) return false
return bytes.every((byte, index) => buf[offset + index] === byte)
@@ -89,6 +92,10 @@ export function sniffMimeType(buf: Buffer, declaredType: BinaryMediaType, fileNa
if (ascii(buf, 4, 8) === 'ftyp') {
const brand = ascii(buf, 8, 12)
if (brand === 'qt ') return 'video/quicktime'
// HEIF (photos iPhone et Android) : pas une vidéo, et refusée à l'envoi faute de conversion (sharp ne décode
// pas le HEVC). « hevc » peut aussi désigner une vidéo HEVC en MP4 : photo seulement si l'envoi se déclare photo.
if (HEIC_BRANDS.has(brand) || (brand === 'hevc' && declaredType === 'photo')) return 'image/heic'
if (brand === 'avif' || brand === 'avis') return 'image/avif'
if (declaredType === 'audio' || brand === 'M4A ' || brand === 'M4B ') return 'audio/mp4'
return 'video/mp4'
}
+2 -2
View File
@@ -17,7 +17,7 @@ import {
type ImmichPersonSummary,
} from '../lib/immich.js'
import { decryptImmichApiKey, encryptImmichApiKey } from '../lib/immich-secret.js'
import { InvalidMediaError, isImmichAvatarPath, removeStoredFile, sniffMimeType } from '../lib/media-files.js'
import { IMAGE_MIME_TYPES, InvalidMediaError, isImmichAvatarPath, removeStoredFile, sniffMimeType } from '../lib/media-files.js'
import { exceedsStorageQuota, getStorageRoot, prepareUploadedFile, requireAdminMembership } from './media.js'
// Reconnaissance des visages : associer une personne de l'arbre à une personne Immich, dont la
@@ -315,7 +315,7 @@ export const immichRoutes: FastifyPluginAsync = async (app) => {
// Le type se lit dans les octets, pas dans ce qu'Immich déclare : la vignette finit dans une
// balise <img>. Vide, trop grande ou pas une image : rien à montrer.
const mimeType = thumbnail?.length ? sniffMimeType(thumbnail, 'photo') : null
if (!thumbnail || !mimeType?.startsWith('image/')) {
if (!thumbnail || !mimeType || !(IMAGE_MIME_TYPES as readonly string[]).includes(mimeType)) {
request.log.warn('Vignette Immich vide, trop grande ou qui n’est pas une image')
return reply.code(404).send({ error: 'thumbnail_not_found' })
}
+39
View File
@@ -49,6 +49,45 @@ describe('fichiers envoyés : type lu dans les octets', () => {
})
})
// Boîte ftyp minimale : taille, « ftyp », marque principale
function ftypBox(brand: string): Buffer {
const box = Buffer.alloc(24)
box.writeUInt32BE(24, 0)
box.write('ftyp', 4, 'latin1')
box.write(brand, 8, 'latin1')
return box
}
describe('HEIC et AVIF : reconnus, jamais pris pour une vidéo', () => {
it.each(['heic', 'heix', 'mif1', 'msf1', 'heim', 'heis'])('marque %s : image/heic, refusée à l’envoi', (brand) => {
expect(sniffMimeType(ftypBox(brand), 'photo', 'IMG_0001.HEIC')).toBe('image/heic')
expect(accepted(ftypBox(brand), 'photo', 'IMG_0001.HEIC')).toBeNull()
expect(accepted(ftypBox(brand), 'video', 'IMG_0001.MOV')).toBeNull()
})
it('marque avif : image/avif, refusée à l’envoi (pas de conversion)', () => {
expect(sniffMimeType(ftypBox('avif'), 'photo')).toBe('image/avif')
expect(accepted(ftypBox('avif'), 'photo')).toBeNull()
})
it('marque hevc : image/heic quand l’envoi se déclare photo, vidéo MP4 sinon (une vidéo HEVC reste acceptée)', () => {
expect(sniffMimeType(ftypBox('hevc'), 'photo')).toBe('image/heic')
expect(accepted(ftypBox('hevc'), 'video')).toBe('video/mp4')
})
it('les vidéos MP4 et QuickTime ne changent pas', () => {
expect(accepted(ftypBox('isom'), 'video')).toBe('video/mp4')
expect(accepted(ftypBox('mp42'), 'video')).toBe('video/mp4')
expect(accepted(ftypBox('qt '), 'video')).toBe('video/quicktime')
expect(accepted(ftypBox('M4A '), 'audio')).toBe('audio/mp4')
})
it('une image HEIC n’entre dans aucun type de média autorisé', () => {
expect(isMimeAllowedForType('photo', 'image/heic')).toBe(false)
expect(isMimeAllowedForType('photo', 'image/avif')).toBe(false)
})
})
describe('photos : métadonnées retirées, rotation appliquée', () => {
it('retire le GPS et tourne les pixels selon l orientation du téléphone', async () => {
const withGps = await sharp({ create: { width: 64, height: 48, channels: 3, background: { r: 200, g: 120, b: 60 } } })