Immich : lectures bornées, erreurs de corps en 503, lien retiré avec le portrait

- Vignette lue en flux : au-delà de 5 Mo (ou sans taille annoncée), la lecture
  s'arrête sans charger le reste.
- Vignette servie seulement si les octets sont une image, avec
  X-Content-Type-Options: nosniff (plus de repli sur image/jpeg).
- Liste des personnes : délai global de 30 s sur toutes les pages.
- Corps JSON illisible ou flux interrompu : 503 immich_unreachable, au lieu
  d'une erreur serveur.
- Retirer le portrait à la main (DELETE avatar) ou supprimer une personne
  efface aussi son identifiant Immich, sinon l'association reste invisible.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
IliesAllaliandClaude Opus 5.5 committed 2026-10-16 18:12:21 +02:00
1 parent c8df8df937
commit f37fc329ba
5 files changed
+138 -12

No files matched your search

+1 -1
View File
@@ -32,7 +32,7 @@ export async function softDeletePersonCascade(
await db.person.updateMany({
where: { id: personId, treeId, deletedAt: null },
data: { deletedAt: now, updatedBy: actorUserId, avatarPath: null, avatarMimeType: null },
data: { deletedAt: now, updatedBy: actorUserId, avatarPath: null, avatarMimeType: null, immichPersonId: null },
})
const unions = await db.union.findMany({
+47 -4
View File
@@ -39,6 +39,8 @@ export class ImmichNotFoundError extends Error {
const IMMICH_PEOPLE_PAGE_SIZE = 1000
const IMMICH_TIMEOUT_MS = 10_000
// Délai global de la liste complète : chaque page a son propre délai, la liste entière aussi.
const IMMICH_LIST_DEADLINE_MS = 30_000
// Identifiants de personnes Immich : UUID v4 (spécification OpenAPI).
const IMMICH_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
@@ -69,6 +71,16 @@ export class ImmichClient {
// Le chemin de l'instance (ex. /immich) reste devant les chemins de l'API : on concatène,
// on ne résout pas une URL relative (qui effacerait ce chemin).
// Corps JSON lu hors de toute gestion d'erreur : un corps illisible est une instance
// défaillante (503), pas une erreur serveur.
private async readJson(response: Response): Promise<unknown> {
try {
return await response.json()
} catch {
throw new ImmichUnavailableError('Réponse Immich illisible')
}
}
private buildUrl(path: string): string {
return `${this.apiUrl}${path}`
}
@@ -119,10 +131,14 @@ export class ImmichClient {
const people: ImmichPersonSummary[] = []
let page = 1
const deadline = Date.now() + IMMICH_LIST_DEADLINE_MS
// Garde-fou : une instance qui répondrait toujours hasNextPage ne doit pas nous faire boucler.
for (let guard = 0; guard < 1000; guard += 1, page += 1) {
if (Date.now() > deadline) {
throw new ImmichUnavailableError('Liste des personnes Immich trop longue')
}
const response = await this.fetch(`/api/people?page=${page}&size=${IMMICH_PEOPLE_PAGE_SIZE}`)
const payload = (await response.json()) as ImmichPeoplePage
const payload = (await this.readJson(response)) as ImmichPeoplePage
if (!Array.isArray(payload.people)) {
throw new ImmichUnavailableError('Réponse Immich inattendue sur /api/people')
@@ -148,7 +164,7 @@ export class ImmichClient {
// se vérifie donc ici.
async getPerson(personId: string): Promise<ImmichPersonSummary> {
const response = await this.fetch(`/api/people/${encodeURIComponent(personId)}`)
const person = (await response.json()) as { id?: unknown; name?: unknown; thumbnailPath?: unknown }
const person = (await this.readJson(response)) as { id?: unknown; name?: unknown; thumbnailPath?: unknown }
if (typeof person?.id !== 'string' || typeof person?.name !== 'string') {
throw new ImmichUnavailableError('Réponse Immich inattendue sur /api/people/{id}')
}
@@ -160,9 +176,36 @@ export class ImmichClient {
}
// Vignette d'une personne, en octets bruts (JPEG côté Immich). Absente -> ImmichNotFoundError.
async getPersonThumbnail(personId: string): Promise<Buffer> {
// Lue en flux : au-delà de maxBytes, la lecture s'arrête et renvoie null sans charger le reste.
async getPersonThumbnail(personId: string, maxBytes: number): Promise<Buffer | null> {
const response = await this.fetch(`/api/people/${encodeURIComponent(personId)}/thumbnail`)
return Buffer.from(await response.arrayBuffer())
const declared = Number(response.headers.get('content-length'))
if (Number.isFinite(declared) && declared > maxBytes) {
await response.body?.cancel().catch(() => {})
return null
}
if (!response.body) {
return Buffer.alloc(0)
}
const reader = response.body.getReader()
const chunks: Buffer[] = []
let total = 0
try {
for (;;) {
const { done, value } = await reader.read()
if (done) break
total += value.byteLength
if (total > maxBytes) {
await reader.cancel().catch(() => {})
return null
}
chunks.push(Buffer.from(value))
}
} catch {
throw new ImmichUnavailableError('Lecture de la vignette Immich interrompue')
}
return Buffer.concat(chunks, total)
}
}
+9 -7
View File
@@ -303,21 +303,23 @@ export const immichRoutes: FastifyPluginAsync = async (app) => {
return reply.code(503).send({ error: 'immich_not_configured' })
}
let thumbnail: Buffer
let thumbnail: Buffer | null
try {
thumbnail = await client.getPersonThumbnail(params.data.immichPersonId)
thumbnail = await client.getPersonThumbnail(params.data.immichPersonId, MAX_THUMBNAIL_BYTES)
} catch (error) {
return replyImmichError(request, reply, error)
}
if (!thumbnail.length || thumbnail.length > MAX_THUMBNAIL_BYTES) {
request.log.warn({ size: thumbnail.length }, 'Vignette Immich vide ou trop grande')
// 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/')) {
request.log.warn('Vignette Immich vide, trop grande ou qui n’est pas une image')
return reply.code(404).send({ error: 'thumbnail_not_found' })
}
// Le type se lit dans les octets, pas dans ce qu'Immich déclare : la vignette finit dans une balise <img>.
const mimeType = sniffMimeType(thumbnail, 'photo') ?? 'image/jpeg'
reply.header('Content-Type', mimeType)
reply.header('X-Content-Type-Options', 'nosniff')
reply.header('Cache-Control', 'private, max-age=3600')
return reply.send(thumbnail)
})
@@ -368,7 +370,7 @@ export const immichRoutes: FastifyPluginAsync = async (app) => {
let thumbnail: Buffer | null = null
if (immichPerson.hasThumbnail) {
try {
thumbnail = await client.getPersonThumbnail(immichPersonId)
thumbnail = await client.getPersonThumbnail(immichPersonId, MAX_THUMBNAIL_BYTES)
} catch (error) {
if (!(error instanceof ImmichNotFoundError)) {
return replyImmichError(request, reply, error)
+2
View File
@@ -609,6 +609,8 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => {
data: {
avatarPath: null,
avatarMimeType: null,
// Retirer le portrait retire aussi le lien Immich : l'association ne survit pas au portrait.
immichPersonId: null,
updatedBy: request.actor.userId,
},
})
+79
View File
@@ -918,3 +918,82 @@ describe('Immich integration: adresse de l’instance', () => {
expect(normalizeImmichApiUrl('ftp://immich.test')).toBeNull()
})
})
describe('Immich integration: durcissement des vignettes et des réponses', () => {
it('serves only images, with nosniff, and refuses bytes that are not an image', async () => {
const app = createApp()
const token = await createUserToken(app)
fetchMock.mockResolvedValueOnce(new Response(new Uint8Array(jpegBytes), { status: 200 }))
const image = await app.inject({
method: 'GET',
url: `/trees/tree-1/immich/thumbnail/${immichPersonId}`,
headers: { authorization: `Bearer ${token}` },
})
expect(image.statusCode).toBe(200)
expect(image.headers['content-type']).toBe('image/jpeg')
expect(image.headers['x-content-type-options']).toBe('nosniff')
// Un corps HTML déclaré image n'est jamais servi comme tel.
fetchMock.mockResolvedValueOnce(new Response('<script>alert(1)</script>', { status: 200, headers: { 'content-type': 'image/png' } }))
const notImage = await app.inject({
method: 'GET',
url: `/trees/tree-1/immich/thumbnail/${immichPersonId}`,
headers: { authorization: `Bearer ${token}` },
})
expect(notImage.statusCode).toBe(404)
expect(notImage.json()).toEqual({ error: 'thumbnail_not_found' })
await app.close()
})
it('stops reading a thumbnail above 5 MB without loading the rest of it', async () => {
const app = createApp()
const token = await createUserToken(app)
// Un flux sans taille annoncée, plus grand que la limite : la lecture doit s'arrêter en route.
const big = new Uint8Array(5 * 1024 * 1024 + 1024)
big.set(jpegBytes.subarray(0, Math.min(jpegBytes.length, big.length)))
fetchMock.mockResolvedValueOnce(new Response(big, { status: 200 }))
const response = await app.inject({
method: 'GET',
url: `/trees/tree-1/immich/thumbnail/${immichPersonId}`,
headers: { authorization: `Bearer ${token}` },
})
expect(response.statusCode).toBe(404)
expect(response.json()).toEqual({ error: 'thumbnail_not_found' })
await app.close()
})
it('answers 503 when an Immich response body cannot be read as JSON', async () => {
const app = createApp()
const token = await createUserToken(app)
fetchMock.mockResolvedValue(new Response('<html>passerelle en panne</html>', { status: 200 }))
const response = await app.inject({
method: 'GET',
url: '/trees/tree-1/immich/people',
headers: { authorization: `Bearer ${token}` },
})
expect(response.statusCode).toBe(503)
expect(response.json()).toEqual({ error: 'immich_unreachable' })
await app.close()
})
it('clears the Immich link when the portrait is deleted by hand', async () => {
const app = createApp()
const token = await createUserToken(app)
prismaMock.person.findFirst.mockResolvedValue({ id: 'person-1', avatarPath: 'tree-1/person-1/avatar-a.jpg', immichPersonId })
const response = await app.inject({
method: 'DELETE',
url: '/trees/tree-1/persons/person-1/avatar',
headers: { authorization: `Bearer ${token}` },
})
expect(response.statusCode).toBe(200)
expect(prismaMock.person.updateMany.mock.calls[0][0].data).toMatchObject({ avatarPath: null, immichPersonId: null })
await app.close()
})
})