mirror of
https://github.com/IliesAllali/parenthese.git
synced 2026-11-10 07:52:13 +00:00
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:
1 parent
25b33ca056
commit
0fb4aeec0d
3 files changed
+48
-2
No files matched your search
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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' })
|
||||
}
|
||||
|
||||
@@ -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 } } })
|
||||
|
||||
Reference in new issue
Block a user