diff --git a/backend/.env.example b/backend/.env.example index b6d997d..9f87832 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -6,3 +6,8 @@ JWT_EXPIRES_IN="7d" JWT_TREE_ACCESS_EXPIRES_IN="24h" CORS_ORIGIN="http://localhost:5173,http://localhost:5174,http://localhost:5175,http://localhost:5176" MEDIA_STORAGE_PATH="./storage/media" +# Reconnaissance des visages via une instance Immich (https://immich.app). Désactivé par défaut. +# IMMICH_API_URL : adresse de l'API, sans slash final. IMMICH_API_KEY : clé créée dans Immich. +IMMICH_ENABLED=false +IMMICH_API_URL="http://immich.example.org:2283" +IMMICH_API_KEY="" diff --git a/backend/.env.production.example b/backend/.env.production.example index 75296d2..3c49081 100644 --- a/backend/.env.production.example +++ b/backend/.env.production.example @@ -23,3 +23,9 @@ CORS_ORIGIN="https://arbre.example.org" # Dossier des médias envoyés. Un chemin relatif part du dossier backend/. MEDIA_STORAGE_PATH="/var/lib/parenthese/media" + +# Reconnaissance des visages via une instance Immich (https://immich.app). Désactivé par défaut. +# IMMICH_API_URL : adresse de l'API, sans slash final. IMMICH_API_KEY : clé créée dans Immich. +IMMICH_ENABLED=false +IMMICH_API_URL="http://immich.example.org:2283" +IMMICH_API_KEY="" diff --git a/backend/prisma/migrations/20261012_001_add_immich_person_id/migration.sql b/backend/prisma/migrations/20261012_001_add_immich_person_id/migration.sql new file mode 100644 index 0000000..8eab9cb --- /dev/null +++ b/backend/prisma/migrations/20261012_001_add_immich_person_id/migration.sql @@ -0,0 +1,2 @@ +-- Association d'une personne à une personne Immich (reconnaissance des visages). +ALTER TABLE "persons" ADD COLUMN "immichPersonId" TEXT; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index a80a8d5..cd8290a 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -208,6 +208,8 @@ model Person { notes String? createdBy String? updatedBy String? + // Personne Immich associée (reconnaissance des visages) : identifiant côté Immich, ou null. + immichPersonId String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt deletedAt DateTime? diff --git a/backend/src/app.ts b/backend/src/app.ts index db855e0..61272f1 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -10,6 +10,7 @@ import { isUserTokenRevoked } from './lib/token-revocation.js' import { authRoutes } from './routes/auth.js' import { contributionRoutes } from './routes/contributions.js' import { graphRoutes } from './routes/graph.js' +import { immichRoutes } from './routes/immich.js' import { mediaRoutes } from './routes/media.js' import { publicRoutes } from './routes/public.js' import { annotationRoutes } from './routes/annotations.js' @@ -147,6 +148,7 @@ export function createApp() { app.register(annotationRoutes) app.register(gedcomRoutes) app.register(visitRoutes) + app.register(immichRoutes) app.setErrorHandler((error: FastifyError | Error, request, reply) => { if (error instanceof Prisma.PrismaClientInitializationError) { diff --git a/backend/src/config/env.ts b/backend/src/config/env.ts index c14eab7..5aa7530 100644 --- a/backend/src/config/env.ts +++ b/backend/src/config/env.ts @@ -50,6 +50,10 @@ const envSchema = z.object({ // Place disque par arbre, et part réservée aux envois de la famille en attente de relecture MEDIA_QUOTA_PER_TREE_MB: z.coerce.number().int().positive().default(5120), MEDIA_PENDING_QUOTA_PER_TREE_MB: z.coerce.number().int().positive().default(500), + // Reconnaissance des visages via Immich : désactivé par défaut, adresse et clé de l'instance. + IMMICH_ENABLED: z.preprocess((val) => String(val).toLowerCase() === 'true', z.boolean()).default(false), + IMMICH_API_URL: z.string().url().optional(), + IMMICH_API_KEY: z.string().min(1).optional(), }) export const env = envSchema.parse(process.env) diff --git a/backend/src/lib/immich.ts b/backend/src/lib/immich.ts new file mode 100644 index 0000000..0edf9bb --- /dev/null +++ b/backend/src/lib/immich.ts @@ -0,0 +1,153 @@ +import { env } from '../config/env.js' + +// Client Immich : la reconnaissance des visages lit les personnes et leurs vignettes. +// Contrat vérifié sur la spécification OpenAPI officielle d'Immich v3.1.0 : +// GET /api/server/ping -> 200 {"res":"pong"} +// GET /api/people?page=N&size=M -> 200 { people: PersonResponseDto[], total, hasNextPage, hidden } (size <= 1000) +// GET /api/people/{id} -> 200 PersonResponseDto | 404 (id : UUID v4) +// GET /api/people/{id}/thumbnail -> 200 image binaire | 404 +// Authentification : en-tête x-api-key ; clé refusée -> 401. + +// Instance injoignable : réseau coupé, délai dépassé, réponse illisible. +export class ImmichUnavailableError extends Error { + constructor(message = 'Immich est injoignable') { + super(message) + this.name = 'ImmichUnavailableError' + } +} + +// Clé d'API refusée par Immich (401/403). +export class ImmichAuthError extends Error { + constructor(message = 'Clé d’API Immich refusée') { + super(message) + this.name = 'ImmichAuthError' + } +} + +// Personne ou vignette inexistante côté Immich (404). +export class ImmichNotFoundError extends Error { + constructor(message = 'Personne Immich introuvable') { + super(message) + this.name = 'ImmichNotFoundError' + } +} + +const IMMICH_PEOPLE_PAGE_SIZE = 1000 +const IMMICH_TIMEOUT_MS = 10_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 + +export function isImmichPersonId(value: string): boolean { + return IMMICH_ID_RE.test(value) +} + +export type ImmichPersonSummary = { + id: string + name: string + hasThumbnail: boolean +} + +type ImmichPeoplePage = { + people?: unknown + hasNextPage?: unknown +} + +export class ImmichClient { + private readonly apiUrl: string + private readonly apiKey: string + + constructor(apiUrl: string, apiKey: string) { + this.apiUrl = apiUrl.replace(/\/$/, '') + this.apiKey = apiKey + } + + private buildUrl(path: string): string { + return new URL(path, `${this.apiUrl}/`).toString() + } + + private async fetch(path: string): Promise { + const url = this.buildUrl(path) + let response: Response + try { + response = await fetch(url, { + headers: { 'x-api-key': this.apiKey, Accept: 'application/json' }, + signal: AbortSignal.timeout(IMMICH_TIMEOUT_MS), + }) + } catch (error) { + // L'URL ne contient aucun secret ; la clé voyage dans l'en-tête, jamais dans les journaux. + console.warn(`Immich injoignable : ${url} (${(error as Error).message})`) + throw new ImmichUnavailableError() + } + + if (response.ok) { + return response + } + + // Journal avec le statut et l'URL (sans secret), puis remontée d'une erreur distincte par cause. + console.warn(`Immich a répondu ${response.status} sur ${url}`) + if (response.status === 401 || response.status === 403) { + throw new ImmichAuthError() + } + if (response.status === 404) { + throw new ImmichNotFoundError() + } + throw new ImmichUnavailableError(`Immich a répondu ${response.status}`) + } + + async ping(): Promise { + try { + const response = await this.fetch('/api/server/ping') + const payload = (await response.json()) as { res?: unknown } + return payload?.res === 'pong' + } catch { + return false + } + } + + // Toutes les personnes (sans les masquées), en suivant la pagination d'Immich. + async listPeople(): Promise { + const people: ImmichPersonSummary[] = [] + let page = 1 + + // 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) { + const response = await this.fetch(`/api/people?page=${page}&size=${IMMICH_PEOPLE_PAGE_SIZE}`) + const payload = (await response.json()) as ImmichPeoplePage + + if (!Array.isArray(payload.people)) { + throw new ImmichUnavailableError('Réponse Immich inattendue sur /api/people') + } + + for (const person of payload.people as Array<{ 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') + } + people.push({ id: person.id, name: person.name, hasThumbnail: typeof person.thumbnailPath === 'string' && person.thumbnailPath.length > 0 }) + } + + if (payload.hasNextPage !== true || (payload.people as unknown[]).length === 0) { + break + } + } + + return people + } + + // Vignette d'une personne, en octets bruts (JPEG côté Immich). Absente -> ImmichNotFoundError. + async getPersonThumbnail(personId: string): Promise { + const response = await this.fetch(`/api/people/${encodeURIComponent(personId)}/thumbnail`) + return Buffer.from(await response.arrayBuffer()) + } +} + +export function isImmichConfigured(): boolean { + return env.IMMICH_ENABLED && Boolean(env.IMMICH_API_URL && env.IMMICH_API_KEY) +} + +export function getImmichClient(): ImmichClient | null { + if (!isImmichConfigured()) { + return null + } + return new ImmichClient(env.IMMICH_API_URL!, env.IMMICH_API_KEY!) +} diff --git a/backend/src/routes/immich.ts b/backend/src/routes/immich.ts new file mode 100644 index 0000000..b321b5a --- /dev/null +++ b/backend/src/routes/immich.ts @@ -0,0 +1,360 @@ +import type { FastifyPluginAsync, FastifyReply, FastifyRequest } from 'fastify' +import path from 'node:path' +import { randomUUID } from 'node:crypto' +import { mkdir, writeFile } from 'node:fs/promises' +import { z } from 'zod' + +import { + getImmichClient, + isImmichConfigured, + isImmichPersonId, + ImmichAuthError, + ImmichNotFoundError, + ImmichUnavailableError, +} from '../lib/immich.js' +import { InvalidMediaError, 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 +// vignette devient son portrait. Toutes les routes sont rattachées à un arbre et réservées à +// ses administrateurs : la liste des visages d'Immich ne se montre pas au premier compte venu. + +const treeParamSchema = z.object({ id: z.string().min(1) }) + +const assignSchema = z.object({ + personId: z.string().min(1), + immichPersonId: z.string().refine(isImmichPersonId, 'invalid_immich_person_id'), +}) + +const unassignSchema = z.object({ + personId: z.string().min(1), +}) + +const thumbnailParamSchema = z.object({ + id: z.string().min(1), + immichPersonId: z.string().refine(isImmichPersonId, 'invalid_immich_person_id'), +}) + +const MAX_THUMBNAIL_BYTES = 5 * 1024 * 1024 + +function replyImmichError(request: FastifyRequest, reply: FastifyReply, error: unknown): FastifyReply { + if (error instanceof ImmichNotFoundError) { + return reply.code(404).send({ error: 'immich_person_not_found' }) + } + if (error instanceof ImmichAuthError) { + return reply.code(502).send({ error: 'immich_auth_failed' }) + } + if (error instanceof ImmichUnavailableError) { + return reply.code(503).send({ error: 'immich_unreachable' }) + } + request.log.error({ err: error }, 'Erreur inattendue pendant un appel à Immich') + return reply.code(500).send({ error: 'immich_api_error' }) +} + +function requireConfiguredClient() { + return getImmichClient() +} + +// Portraits venus d'Immich : même nommage que les envois manuels (« avatar- »), avec le +// préfixe « immich- » qui dit au désassignement que le fichier nous appartient. Un portrait +// envoyé à la main n'est jamais touché par le désassignement. +function buildAvatarRelativePath(treeId: string, personId: string, extension: string): string { + return path.posix.join(treeId, personId, `immich-${randomUUID()}${extension}`) +} + +export const immichRoutes: FastifyPluginAsync = async (app) => { + app.get('/trees/:id/immich/status', async (request, reply) => { + if (!request.actor || request.actor.kind !== 'user') { + return reply.code(401).send({ error: 'authentication_required' }) + } + + const params = treeParamSchema.safeParse(request.params) + if (!params.success) { + return reply.code(400).send({ error: 'invalid_params' }) + } + + if (!(await requireAdminMembership(app, request.actor.userId, params.data.id))) { + return reply.code(403).send({ error: 'forbidden' }) + } + + if (!isImmichConfigured()) { + return reply.send({ available: false, reason: 'not_configured' }) + } + + const client = requireConfiguredClient()! + const available = await client.ping() + return reply.send({ available, reason: available ? null : 'unreachable' }) + }) + + app.get('/trees/:id/immich/people', async (request, reply) => { + if (!request.actor || request.actor.kind !== 'user') { + return reply.code(401).send({ error: 'authentication_required' }) + } + + const params = treeParamSchema.safeParse(request.params) + if (!params.success) { + return reply.code(400).send({ error: 'invalid_params' }) + } + + if (!(await requireAdminMembership(app, request.actor.userId, params.data.id))) { + return reply.code(403).send({ error: 'forbidden' }) + } + + const client = requireConfiguredClient() + if (!client) { + return reply.code(503).send({ error: 'immich_not_configured' }) + } + + try { + const people = await client.listPeople() + return reply.send({ people }) + } catch (error) { + return replyImmichError(request, reply, error) + } + }) + + // Vignette d'une personne Immich, montrée dans le sélecteur. Passée par l'API : l'adresse + // d'Immich et sa clé ne quittent jamais le serveur. + app.get('/trees/:id/immich/thumbnail/:immichPersonId', async (request, reply) => { + if (!request.actor || request.actor.kind !== 'user') { + return reply.code(401).send({ error: 'authentication_required' }) + } + + const params = thumbnailParamSchema.safeParse(request.params) + if (!params.success) { + return reply.code(400).send({ error: 'invalid_immich_person_id' }) + } + + if (!(await requireAdminMembership(app, request.actor.userId, params.data.id))) { + return reply.code(403).send({ error: 'forbidden' }) + } + + const client = requireConfiguredClient() + if (!client) { + return reply.code(503).send({ error: 'immich_not_configured' }) + } + + let thumbnail: Buffer + try { + thumbnail = await client.getPersonThumbnail(params.data.immichPersonId) + } 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') + 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 . + const mimeType = sniffMimeType(thumbnail, 'photo') ?? 'image/jpeg' + reply.header('Content-Type', mimeType) + reply.header('Cache-Control', 'private, max-age=3600') + return reply.send(thumbnail) + }) + + app.post('/trees/:id/immich/assign', async (request, reply) => { + if (!request.actor || request.actor.kind !== 'user') { + return reply.code(401).send({ error: 'authentication_required' }) + } + + const params = treeParamSchema.safeParse(request.params) + const payload = assignSchema.safeParse(request.body) + if (!params.success || !payload.success) { + return reply.code(400).send({ error: payload.success ? 'invalid_params' : 'invalid_immich_person_id' }) + } + + if (!(await requireAdminMembership(app, request.actor.userId, params.data.id))) { + return reply.code(403).send({ error: 'forbidden' }) + } + + const client = requireConfiguredClient() + if (!client) { + return reply.code(503).send({ error: 'immich_not_configured' }) + } + + const { personId, immichPersonId } = payload.data + const treeId = params.data.id + + const person = await app.prisma.person.findFirst({ + where: { id: personId, treeId, deletedAt: null }, + select: { id: true, avatarPath: true }, + }) + if (!person) { + return reply.code(404).send({ error: 'person_not_found' }) + } + + // Vignette de la personne Immich : absente -> on associe sans toucher au portrait existant. + let thumbnail: Buffer | null = null + try { + thumbnail = await client.getPersonThumbnail(immichPersonId) + } catch (error) { + if (!(error instanceof ImmichNotFoundError)) { + return replyImmichError(request, reply, error) + } + // Vignette absente : l'association se fait sans portrait. + } + + if (!thumbnail) { + await app.prisma.$transaction([ + app.prisma.person.update({ + where: { id: personId }, + data: { immichPersonId, updatedBy: request.actor.userId }, + }), + app.prisma.auditLog.create({ + data: { + treeId, + actorType: 'user', + actorId: request.actor.userId, + action: 'immich_assign', + entityType: 'person', + entityId: personId, + payloadJson: { immichPersonId, avatarUpdated: false }, + }, + }), + ]) + + return reply.send({ ok: true, avatarUpdated: false, immichPersonId }) + } + + // La vignette passe par le même traitement qu'un portrait envoyé : type lu dans les octets, + // métadonnées retirées, quota de l'arbre respecté. + let prepared: Awaited> + try { + prepared = await prepareUploadedFile(thumbnail, 'photo', `immich-${immichPersonId}.jpg`) + } catch (error) { + if (error instanceof InvalidMediaError) { + return reply.code(400).send({ error: 'invalid_avatar_image' }) + } + throw error + } + + if (await exceedsStorageQuota(app, treeId, prepared.buffer.length, false)) { + return reply.code(413).send({ error: 'storage_quota_exceeded' }) + } + + const relativePath = buildAvatarRelativePath(treeId, personId, prepared.extension) + const storageRoot = getStorageRoot() + const absolutePath = path.resolve(storageRoot, relativePath) + + // Chemin hors du stockage : refusé, jamais écrit. + if (!absolutePath.startsWith(storageRoot + path.sep)) { + return reply.code(400).send({ error: 'invalid_media_path' }) + } + + // Fichier écrit avant la base : si la transaction échoue, on l'efface et rien n'a changé. + await mkdir(path.dirname(absolutePath), { recursive: true }) + await writeFile(absolutePath, prepared.buffer) + + try { + await app.prisma.$transaction([ + app.prisma.person.update({ + where: { id: personId }, + data: { + avatarPath: relativePath, + avatarMimeType: prepared.mimeType, + immichPersonId, + updatedBy: request.actor.userId, + }, + }), + app.prisma.auditLog.create({ + data: { + treeId, + actorType: 'user', + actorId: request.actor.userId, + action: 'immich_assign', + entityType: 'person', + entityId: personId, + payloadJson: { immichPersonId, avatarUpdated: true, avatarPath: relativePath }, + }, + }), + ]) + } catch (error) { + await removeStoredFile(storageRoot, relativePath).catch(() => {}) + request.log.error({ err: error }, 'Échec de l’association Immich : fichier de portrait retiré, base inchangée') + throw error + } + + // L'ancien portrait ne reste pas sur le disque. Un échec du nettoyage n'invalide pas + // l'association, qui a déjà réussi : il est journalisé, rien de plus. + if (person.avatarPath && person.avatarPath !== relativePath) { + await removeStoredFile(storageRoot, person.avatarPath).catch((cleanupError) => { + request.log.error( + { err: cleanupError, oldAvatar: person.avatarPath }, + 'Ancien portrait non supprimé après une association Immich', + ) + }) + } + + return reply.send({ ok: true, avatarUpdated: true, immichPersonId }) + }) + + app.post('/trees/:id/immich/unassign', async (request, reply) => { + if (!request.actor || request.actor.kind !== 'user') { + return reply.code(401).send({ error: 'authentication_required' }) + } + + const params = treeParamSchema.safeParse(request.params) + const payload = unassignSchema.safeParse(request.body) + if (!params.success || !payload.success) { + return reply.code(400).send({ error: 'invalid_params' }) + } + + if (!(await requireAdminMembership(app, request.actor.userId, params.data.id))) { + return reply.code(403).send({ error: 'forbidden' }) + } + + const { personId } = payload.data + const treeId = params.data.id + + const person = await app.prisma.person.findFirst({ + where: { id: personId, treeId, deletedAt: null }, + select: { id: true, avatarPath: true, immichPersonId: true }, + }) + if (!person) { + return reply.code(404).send({ error: 'person_not_found' }) + } + + // Le portrait vient d'Immich uniquement si son fichier porte notre préfixe. Un portrait + // envoyé à la main reste en place : seule l'association est retirée. + const avatarFromImmich = person.avatarPath !== null && path.basename(person.avatarPath).startsWith('immich-') + + const updateData: { + immichPersonId: null + updatedBy: string + avatarPath?: null + avatarMimeType?: null + } = { immichPersonId: null, updatedBy: request.actor.userId } + if (avatarFromImmich) { + updateData.avatarPath = null + updateData.avatarMimeType = null + } + + // Base d'abord, fichier ensuite : le portrait disparaît seulement une fois la base à jour. + await app.prisma.$transaction([ + app.prisma.person.update({ where: { id: personId }, data: updateData }), + app.prisma.auditLog.create({ + data: { + treeId, + actorType: 'user', + actorId: request.actor.userId, + action: 'immich_unassign', + entityType: 'person', + entityId: personId, + payloadJson: { previousImmichPersonId: person.immichPersonId, avatarRemoved: avatarFromImmich }, + }, + }), + ]) + + if (avatarFromImmich) { + await removeStoredFile(getStorageRoot(), person.avatarPath).catch((cleanupError) => { + request.log.error( + { err: cleanupError, avatarPath: person.avatarPath }, + 'Portrait Immich non supprimé après un désassignement (base à jour)', + ) + }) + } + + return reply.send({ ok: true, avatarRemoved: avatarFromImmich }) + }) +} diff --git a/backend/src/routes/media.ts b/backend/src/routes/media.ts index 0488fef..798041a 100644 --- a/backend/src/routes/media.ts +++ b/backend/src/routes/media.ts @@ -92,7 +92,8 @@ function hasAdminRight(role: MembershipRole): boolean { return role === 'owner' || role === 'admin' } -function getStorageRoot(): string { +// Racine du stockage des médias : partagée avec les portraits venus d'Immich (routes/immich.ts). +export function getStorageRoot(): string { return path.resolve(env.MEDIA_STORAGE_PATH) } @@ -116,7 +117,7 @@ function normalizeOptionalText(value: string | null | undefined): string | null // Fichier décodé, reconnu à ses octets et nettoyé (photos sans métadonnées, vidéos sans position). // Le type déclaré par le navigateur n'est plus qu'un indice pour les conteneurs ambigus. -async function prepareUploadedFile( +export async function prepareUploadedFile( buffer: Buffer, type: BinaryMediaType, fileName: string, @@ -167,7 +168,7 @@ async function treeDiskUsage(treeId: string): Promise { } // Place disque d'un arbre : total, et part des envois en attente de relecture (limite l'envoi en boucle) -async function exceedsStorageQuota(app: FastifyRequest['server'], treeId: string, incomingBytes: number, isPending: boolean): Promise { +export async function exceedsStorageQuota(app: FastifyRequest['server'], treeId: string, incomingBytes: number, isPending: boolean): Promise { const MB = 1024 * 1024 if ((await treeDiskUsage(treeId)) + incomingBytes > env.MEDIA_QUOTA_PER_TREE_MB * MB) { return true @@ -187,7 +188,7 @@ async function exceedsStorageQuota(app: FastifyRequest['server'], treeId: string // Seules les routes d'envoi acceptent un corps de 12 Mo (fichier en base64), le reste de l'API est à 1 Mo (app.ts) const UPLOAD_RATE_LIMIT = { bodyLimit: UPLOAD_BODY_LIMIT, config: { rateLimit: { max: 30, timeWindow: '1 minute' } } } -async function requireAdminMembership(app: FastifyRequest['server'], userId: string, treeId: string) { +export async function requireAdminMembership(app: FastifyRequest['server'], userId: string, treeId: string) { const membership = await app.prisma.treeMembership.findUnique({ where: { treeId_userId: { diff --git a/backend/src/utils/graph-payload.ts b/backend/src/utils/graph-payload.ts index ea847dc..c66d297 100644 --- a/backend/src/utils/graph-payload.ts +++ b/backend/src/utils/graph-payload.ts @@ -17,6 +17,7 @@ export type GraphPayload = { avatarUrlPath: string | null sex: string | null notes: string | null + immichPersonId: string | null }> unions: Array<{ id: string @@ -90,6 +91,7 @@ export function buildGraphPayload( avatarUrlPath: person.avatarPath ? `/trees/${treeId}/persons/${person.id}/avatar` : null, sex: person.sex, notes: person.notes, + immichPersonId: person.immichPersonId, })), unions: unions.map((union) => ({ id: union.id, diff --git a/backend/test/immich-unconfigured.test.ts b/backend/test/immich-unconfigured.test.ts new file mode 100644 index 0000000..bb84555 --- /dev/null +++ b/backend/test/immich-unconfigured.test.ts @@ -0,0 +1,99 @@ +import { randomUUID } from 'node:crypto' +import type { FastifyInstance } from 'fastify' +import { beforeAll, describe, expect, it, vi } from 'vitest' + +// Immich désactivé : aucune requête vers l'instance ne doit partir, chaque route répond « non configuré ». +// Fichier à part : la configuration est lue au démarrage de l'application, impossible à changer à chaud. + +const { prismaMock } = vi.hoisted(() => ({ + prismaMock: { + user: { + count: vi.fn().mockResolvedValue(1), + }, + person: { + findFirst: vi.fn(), + update: vi.fn(), + }, + treeMembership: { + findUnique: vi.fn(), + }, + auditLog: { + create: vi.fn(), + }, + $transaction: vi.fn(), + $disconnect: vi.fn(), + }, +})) + +vi.mock('../src/lib/prisma.js', () => ({ + prisma: prismaMock, +})) + +let createApp: () => FastifyInstance + +beforeAll(async () => { + delete process.env.IMMICH_ENABLED + delete process.env.IMMICH_API_URL + delete process.env.IMMICH_API_KEY + + process.env.NODE_ENV = 'test' + process.env.PORT = '4000' + process.env.DATABASE_URL = 'postgresql://postgres:postgres@localhost:5432/genealogy' + process.env.JWT_SECRET = 'test-secret-with-sufficient-length' + process.env.JWT_EXPIRES_IN = '7d' + process.env.JWT_TREE_ACCESS_EXPIRES_IN = '24h' + process.env.CORS_ORIGIN = 'http://localhost:5173' + + const module = await import('../src/app.js') + createApp = module.createApp +}) + +describe('Immich integration: disabled server', () => { + it('reports not configured and never calls Immich', async () => { + const app = createApp() + await app.ready() + const token = (app as any).jwt.sign( + { kind: 'user', sub: 'user-1', jti: randomUUID(), userId: 'user-1', email: 'user@example.com' }, + { expiresIn: '1h' }, + ) + + prismaMock.treeMembership.findUnique.mockResolvedValue({ + treeId: 'tree-1', + userId: 'user-1', + role: 'owner', + tree: { deletedAt: null }, + }) + + const fetchMock = vi.fn() + vi.stubGlobal('fetch', fetchMock) + + const status = await app.inject({ + method: 'GET', + url: '/trees/tree-1/immich/status', + headers: { authorization: `Bearer ${token}` }, + }) + expect(status.statusCode).toBe(200) + expect(status.json()).toEqual({ available: false, reason: 'not_configured' }) + + const people = await app.inject({ + method: 'GET', + url: '/trees/tree-1/immich/people', + headers: { authorization: `Bearer ${token}` }, + }) + expect(people.statusCode).toBe(503) + expect(people.json()).toEqual({ error: 'immich_not_configured' }) + + const assign = await app.inject({ + method: 'POST', + url: '/trees/tree-1/immich/assign', + headers: { authorization: `Bearer ${token}` }, + payload: { personId: 'person-1', immichPersonId: '123e4567-e89b-42d3-a456-426614174000' }, + }) + expect(assign.statusCode).toBe(503) + expect(assign.json()).toEqual({ error: 'immich_not_configured' }) + + expect(fetchMock).not.toHaveBeenCalled() + vi.unstubAllGlobals() + await app.close() + }) +}) diff --git a/backend/test/immich.test.ts b/backend/test/immich.test.ts new file mode 100644 index 0000000..49f28b2 --- /dev/null +++ b/backend/test/immich.test.ts @@ -0,0 +1,571 @@ +import { randomUUID } from 'node:crypto' +import { mkdtemp, mkdir, rm, writeFile, stat, readdir } from 'node:fs/promises' +import path from 'node:path' +import os from 'node:os' +import sharp from 'sharp' + +import type { FastifyInstance } from 'fastify' +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' + +const { prismaMock } = vi.hoisted(() => ({ + prismaMock: { + user: { + count: vi.fn(), + }, + person: { + findFirst: vi.fn(), + update: vi.fn(), + }, + treeMembership: { + findUnique: vi.fn(), + }, + mediaItem: { + aggregate: vi.fn(), + }, + auditLog: { + create: vi.fn(), + }, + $transaction: vi.fn(), + $disconnect: vi.fn(), + }, +})) + +vi.mock('../src/lib/prisma.js', () => ({ + prisma: prismaMock, +})) + +let createApp: () => FastifyInstance +let storageRoot: string +let jpegBytes: Buffer +const immichPersonId = '123e4567-e89b-42d3-a456-426614174000' +const otherImmichId = '123e4567-e89b-42d3-a456-426614174111' + +const fetchMock = vi.fn() + +async function jsonResponse(payload: unknown, status = 200): Promise { + return new Response(JSON.stringify(payload), { status, headers: { 'content-type': 'application/json' } }) +} + +async function createUserToken(app: FastifyInstance, userId = 'user-1'): Promise { + await app.ready() + return (app as any).jwt.sign( + { kind: 'user', sub: userId, jti: randomUUID(), userId, email: 'user@example.com' }, + { expiresIn: '1h' }, + ) +} + +async function absStoragePath(relativePath: string): Promise { + return path.resolve(storageRoot, relativePath) +} + +async function fsExists(absolutePath: string): Promise { + try { + await stat(absolutePath) + return true + } catch { + return false + } +} + +async function fsListDir(absolutePath: string): Promise { + try { + return await readdir(absolutePath) + } catch { + return [] + } +} + +beforeAll(async () => { + process.env.NODE_ENV = 'test' + process.env.PORT = '4000' + process.env.DATABASE_URL = 'postgresql://postgres:postgres@localhost:5432/genealogy' + process.env.JWT_SECRET = 'test-secret-with-sufficient-length' + process.env.JWT_EXPIRES_IN = '7d' + process.env.JWT_TREE_ACCESS_EXPIRES_IN = '24h' + process.env.CORS_ORIGIN = 'http://localhost:5173' + process.env.IMMICH_ENABLED = 'true' + process.env.IMMICH_API_URL = 'http://immich.test' + process.env.IMMICH_API_KEY = 'test-key' + + storageRoot = await mkdtemp(path.join(os.tmpdir(), 'parenthese-immich-')) + process.env.MEDIA_STORAGE_PATH = storageRoot + // Quota serré pour le tester : le disque est mesuré en vrai (taille du dossier de l'arbre). + process.env.MEDIA_QUOTA_PER_TREE_MB = '1' + + jpegBytes = await sharp({ create: { width: 8, height: 8, channels: 3, background: '#aabbcc' } }).jpeg().toBuffer() + + const module = await import('../src/app.js') + createApp = module.createApp + + vi.stubGlobal('fetch', fetchMock) +}) + +afterAll(async () => { + await rm(storageRoot, { recursive: true, force: true }) + vi.unstubAllGlobals() +}) + +beforeEach(async () => { + vi.clearAllMocks() + + prismaMock.user.count.mockResolvedValue(1) + + // La transaction de l'API prend un tableau d'opérations ; les autres tests du dépôt utilisent la forme callback. + prismaMock.$transaction.mockImplementation(async (input: unknown) => { + if (Array.isArray(input)) { + return Promise.all(input) + } + return (input as (tx: typeof prismaMock) => Promise)(prismaMock) + }) + + prismaMock.treeMembership.findUnique.mockResolvedValue({ + treeId: 'tree-1', + userId: 'user-1', + role: 'owner', + tree: { deletedAt: null }, + }) + + prismaMock.person.findFirst.mockResolvedValue(null) + prismaMock.person.update.mockResolvedValue({ id: 'person-1' }) + prismaMock.auditLog.create.mockResolvedValue({ id: 'audit-1' }) + prismaMock.mediaItem.aggregate.mockResolvedValue({ _sum: { sizeBytes: 0 } }) + + fetchMock.mockReset() + fetchMock.mockResolvedValue(await jsonResponse({ res: 'pong' })) +}) + +describe('Immich integration: status and people', () => { + it('reports Immich available when ping answers pong', async () => { + const app = createApp() + const token = await createUserToken(app) + fetchMock.mockResolvedValue(await jsonResponse({ res: 'pong' })) + + const response = await app.inject({ + method: 'GET', + url: '/trees/tree-1/immich/status', + headers: { authorization: `Bearer ${token}` }, + }) + + expect(response.statusCode).toBe(200) + expect(response.json()).toEqual({ available: true, reason: null }) + await app.close() + }) + + it('reports Immich unreachable when ping fails', async () => { + const app = createApp() + const token = await createUserToken(app) + fetchMock.mockRejectedValue(new Error('network down')) + + const response = await app.inject({ + method: 'GET', + url: '/trees/tree-1/immich/status', + headers: { authorization: `Bearer ${token}` }, + }) + + expect(response.statusCode).toBe(200) + expect(response.json()).toEqual({ available: false, reason: 'unreachable' }) + await app.close() + }) + + it('rejects unauthenticated and non-admin requests on status', async () => { + const app = createApp() + + const anonymous = await app.inject({ method: 'GET', url: '/trees/tree-1/immich/status' }) + expect(anonymous.statusCode).toBe(401) + + const token = await createUserToken(app) + prismaMock.treeMembership.findUnique.mockResolvedValue({ + treeId: 'tree-1', + userId: 'user-1', + role: 'member', + tree: { deletedAt: null }, + }) + const member = await app.inject({ + method: 'GET', + url: '/trees/tree-1/immich/status', + headers: { authorization: `Bearer ${token}` }, + }) + expect(member.statusCode).toBe(403) + await app.close() + }) + + it('lists people across Immich pages', async () => { + const app = createApp() + const token = await createUserToken(app) + fetchMock.mockImplementation(async (input: RequestInfo | URL) => { + const url = String(input) + if (url.includes('/api/people?')) { + const page = url.includes('page=2') ? 2 : 1 + if (page === 1) { + return jsonResponse({ + people: [{ id: immichPersonId, name: 'Jean Dupont', thumbnailPath: '/thumb/a.jpg', isHidden: false, birthDate: null }], + total: 2, + hasNextPage: true, + hidden: 0, + }) + } + return jsonResponse({ + people: [{ id: otherImmichId, name: 'Marie Martin', thumbnailPath: '', isHidden: false, birthDate: null }], + total: 2, + hasNextPage: false, + hidden: 0, + }) + } + return jsonResponse({ res: 'pong' }) + }) + + const response = await app.inject({ + method: 'GET', + url: '/trees/tree-1/immich/people', + headers: { authorization: `Bearer ${token}` }, + }) + + expect(response.statusCode).toBe(200) + expect(response.json().people).toEqual([ + { id: immichPersonId, name: 'Jean Dupont', hasThumbnail: true }, + { id: otherImmichId, name: 'Marie Martin', hasThumbnail: false }, + ]) + await app.close() + }) + + it('maps an Immich auth failure to 502 and a malformed response to 503', async () => { + const app = createApp() + const token = await createUserToken(app) + fetchMock.mockResolvedValue(new Response(JSON.stringify({ message: 'Invalid API key' }), { status: 401 })) + + const authFailure = await app.inject({ + method: 'GET', + url: '/trees/tree-1/immich/people', + headers: { authorization: `Bearer ${token}` }, + }) + expect(authFailure.statusCode).toBe(502) + expect(authFailure.json()).toEqual({ error: 'immich_auth_failed' }) + + fetchMock.mockResolvedValue(await jsonResponse({ total: 0, hasNextPage: false })) + const malformed = await app.inject({ + method: 'GET', + url: '/trees/tree-1/immich/people', + headers: { authorization: `Bearer ${token}` }, + }) + expect(malformed.statusCode).toBe(503) + expect(malformed.json()).toEqual({ error: 'immich_unreachable' }) + await app.close() + }) +}) + +describe('Immich integration: thumbnail proxy', () => { + it('proxies a thumbnail with its sniffed content type', async () => { + const app = createApp() + const token = await createUserToken(app) + fetchMock.mockResolvedValue(new Response(new Uint8Array(jpegBytes), { status: 200 })) + + const response = await app.inject({ + method: 'GET', + url: `/trees/tree-1/immich/thumbnail/${immichPersonId}`, + headers: { authorization: `Bearer ${token}` }, + }) + + expect(response.statusCode).toBe(200) + expect(response.headers['content-type']).toContain('image/jpeg') + expect(Buffer.from(response.rawPayload).equals(jpegBytes)).toBe(true) + await app.close() + }) + + it('returns 404 when the thumbnail is missing and 400 for a malformed identifier', async () => { + const app = createApp() + const token = await createUserToken(app) + fetchMock.mockResolvedValue(new Response('', { status: 404 })) + + const missing = await app.inject({ + method: 'GET', + url: `/trees/tree-1/immich/thumbnail/${immichPersonId}`, + headers: { authorization: `Bearer ${token}` }, + }) + expect(missing.statusCode).toBe(404) + expect(missing.json()).toEqual({ error: 'immich_person_not_found' }) + + const invalid = await app.inject({ + method: 'GET', + url: '/trees/tree-1/immich/thumbnail/not-a-uuid', + headers: { authorization: `Bearer ${token}` }, + }) + expect(invalid.statusCode).toBe(400) + await app.close() + }) +}) + +describe('Immich integration: assign', () => { + it('downloads the thumbnail, replaces the avatar and audits the change', async () => { + const app = createApp() + const token = await createUserToken(app) + + // Portrait manuel en place : il doit être supprimé une fois la base à jour. + const oldAvatarRelative = 'tree-1/person-1/avatar-old.jpg' + await mkdir(path.dirname(await absStoragePath(oldAvatarRelative)), { recursive: true }) + await writeFile(await absStoragePath(oldAvatarRelative), 'old avatar') + + prismaMock.person.findFirst.mockResolvedValue({ id: 'person-1', avatarPath: oldAvatarRelative }) + fetchMock.mockResolvedValue(new Response(new Uint8Array(jpegBytes), { status: 200 })) + + const response = await app.inject({ + method: 'POST', + url: '/trees/tree-1/immich/assign', + headers: { authorization: `Bearer ${token}` }, + payload: { personId: 'person-1', immichPersonId }, + }) + + expect(response.statusCode).toBe(200) + expect(response.json()).toEqual({ ok: true, avatarUpdated: true, immichPersonId }) + + const updateData = prismaMock.person.update.mock.calls[0][0].data + expect(updateData.immichPersonId).toBe(immichPersonId) + expect(updateData.avatarMimeType).toBe('image/jpeg') + expect(typeof updateData.avatarPath).toBe('string') + + // Le nouveau portrait existe sur le disque, l'ancien a été retiré. + const newAvatar = await absStoragePath(updateData.avatarPath) + await expect(fsExists(newAvatar)).resolves.toBe(true) + await expect(fsExists(await absStoragePath(oldAvatarRelative))).resolves.toBe(false) + + expect(prismaMock.auditLog.create).toHaveBeenCalledWith({ + data: { + treeId: 'tree-1', + actorType: 'user', + actorId: 'user-1', + action: 'immich_assign', + entityType: 'person', + entityId: 'person-1', + payloadJson: { immichPersonId, avatarUpdated: true, avatarPath: updateData.avatarPath }, + }, + }) + await app.close() + }) + + it('assigns without touching the avatar when Immich has no thumbnail', async () => { + const app = createApp() + const token = await createUserToken(app) + + prismaMock.person.findFirst.mockResolvedValue({ id: 'person-1', avatarPath: 'tree-1/person-1/avatar-keep.jpg' }) + fetchMock.mockResolvedValue(new Response('', { status: 404 })) + + const response = await app.inject({ + method: 'POST', + url: '/trees/tree-1/immich/assign', + headers: { authorization: `Bearer ${token}` }, + payload: { personId: 'person-1', immichPersonId }, + }) + + expect(response.statusCode).toBe(200) + expect(response.json()).toEqual({ ok: true, avatarUpdated: false, immichPersonId }) + + const updateData = prismaMock.person.update.mock.calls[0][0].data + expect(updateData.immichPersonId).toBe(immichPersonId) + expect(updateData).not.toHaveProperty('avatarPath') + expect(updateData).not.toHaveProperty('avatarMimeType') + await app.close() + }) + + it('rejects invalid identifiers, unauthenticated, non-admin and unknown person', async () => { + const app = createApp() + const token = await createUserToken(app) + + const invalidId = await app.inject({ + method: 'POST', + url: '/trees/tree-1/immich/assign', + headers: { authorization: `Bearer ${token}` }, + payload: { personId: 'person-1', immichPersonId: '../etc/passwd' }, + }) + expect(invalidId.statusCode).toBe(400) + expect(invalidId.json()).toEqual({ error: 'invalid_immich_person_id' }) + + const anonymous = await app.inject({ + method: 'POST', + url: '/trees/tree-1/immich/assign', + payload: { personId: 'person-1', immichPersonId }, + }) + expect(anonymous.statusCode).toBe(401) + + prismaMock.treeMembership.findUnique.mockResolvedValue({ + treeId: 'tree-1', + userId: 'user-1', + role: 'member', + tree: { deletedAt: null }, + }) + const member = await app.inject({ + method: 'POST', + url: '/trees/tree-1/immich/assign', + headers: { authorization: `Bearer ${token}` }, + payload: { personId: 'person-1', immichPersonId }, + }) + expect(member.statusCode).toBe(403) + + prismaMock.treeMembership.findUnique.mockResolvedValue({ + treeId: 'tree-1', + userId: 'user-1', + role: 'owner', + tree: { deletedAt: null }, + }) + prismaMock.person.findFirst.mockResolvedValue(null) + const unknown = await app.inject({ + method: 'POST', + url: '/trees/tree-1/immich/assign', + headers: { authorization: `Bearer ${token}` }, + payload: { personId: 'person-x', immichPersonId }, + }) + expect(unknown.statusCode).toBe(404) + expect(unknown.json()).toEqual({ error: 'person_not_found' }) + await app.close() + }) + + it('refuses the assignment when the tree quota is exceeded', async () => { + const app = createApp() + const token = await createUserToken(app) + + // L'arbre est déjà plein : 1 Mo sur le disque, un quota de 1 Mo. + await mkdir(path.join(storageRoot, 'tree-quota'), { recursive: true }) + await writeFile(path.join(storageRoot, 'tree-quota', 'big.bin'), Buffer.alloc(1024 * 1024)) + + prismaMock.person.findFirst.mockResolvedValue({ id: 'person-quota', avatarPath: null }) + fetchMock.mockResolvedValue(new Response(new Uint8Array(jpegBytes), { status: 200 })) + + const response = await app.inject({ + method: 'POST', + url: '/trees/tree-quota/immich/assign', + headers: { authorization: `Bearer ${token}` }, + payload: { personId: 'person-quota', immichPersonId }, + }) + + expect(response.statusCode).toBe(413) + expect(response.json()).toEqual({ error: 'storage_quota_exceeded' }) + await app.close() + }) + + it('removes the new file and keeps the old avatar when the database update fails', async () => { + const app = createApp() + const token = await createUserToken(app) + + const oldAvatarRelative = 'tree-1/person-tx/avatar-kept.jpg' + await mkdir(path.dirname(await absStoragePath(oldAvatarRelative)), { recursive: true }) + await writeFile(await absStoragePath(oldAvatarRelative), 'old avatar') + + prismaMock.person.findFirst.mockResolvedValue({ id: 'person-tx', avatarPath: oldAvatarRelative }) + fetchMock.mockResolvedValue(new Response(new Uint8Array(jpegBytes), { status: 200 })) + prismaMock.$transaction.mockRejectedValue(new Error('db down')) + + const response = await app.inject({ + method: 'POST', + url: '/trees/tree-1/immich/assign', + headers: { authorization: `Bearer ${token}` }, + payload: { personId: 'person-tx', immichPersonId }, + }) + + expect(response.statusCode).toBe(500) + + // La base n'a pas changé : l'ancien portrait est là, le nouveau a été nettoyé. + await expect(fsExists(await absStoragePath(oldAvatarRelative))).resolves.toBe(true) + + const writtenFiles = await fsListDir(path.join(storageRoot, 'tree-1', 'person-tx')) + expect(writtenFiles.filter((name) => name.startsWith('immich-'))).toEqual([]) + await app.close() + }) +}) + +describe('Immich integration: unassign', () => { + it('clears an Immich avatar after the database update', async () => { + const app = createApp() + const token = await createUserToken(app) + + const immichAvatarRelative = `tree-1/person-1/immich-${randomUUID()}.jpg` + await mkdir(path.dirname(await absStoragePath(immichAvatarRelative)), { recursive: true }) + await writeFile(await absStoragePath(immichAvatarRelative), 'immich avatar') + + prismaMock.person.findFirst.mockResolvedValue({ id: 'person-1', avatarPath: immichAvatarRelative, immichPersonId }) + + const response = await app.inject({ + method: 'POST', + url: '/trees/tree-1/immich/unassign', + headers: { authorization: `Bearer ${token}` }, + payload: { personId: 'person-1' }, + }) + + expect(response.statusCode).toBe(200) + expect(response.json()).toEqual({ ok: true, avatarRemoved: true }) + + const updateData = prismaMock.person.update.mock.calls[0][0].data + expect(updateData).toMatchObject({ immichPersonId: null, avatarPath: null, avatarMimeType: null }) + + expect(prismaMock.auditLog.create).toHaveBeenCalledWith({ + data: { + treeId: 'tree-1', + actorType: 'user', + actorId: 'user-1', + action: 'immich_unassign', + entityType: 'person', + entityId: 'person-1', + payloadJson: { previousImmichPersonId: immichPersonId, avatarRemoved: true }, + }, + }) + + await expect(fsExists(await absStoragePath(immichAvatarRelative))).resolves.toBe(false) + await app.close() + }) + + it('preserves a manually uploaded avatar when unassigning', async () => { + const app = createApp() + const token = await createUserToken(app) + + const manualAvatarRelative = `tree-1/person-1/avatar-${randomUUID()}.jpg` + await mkdir(path.dirname(await absStoragePath(manualAvatarRelative)), { recursive: true }) + await writeFile(await absStoragePath(manualAvatarRelative), 'manual avatar') + + prismaMock.person.findFirst.mockResolvedValue({ id: 'person-1', avatarPath: manualAvatarRelative, immichPersonId }) + + const response = await app.inject({ + method: 'POST', + url: '/trees/tree-1/immich/unassign', + headers: { authorization: `Bearer ${token}` }, + payload: { personId: 'person-1' }, + }) + + expect(response.statusCode).toBe(200) + expect(response.json()).toEqual({ ok: true, avatarRemoved: false }) + + const updateData = prismaMock.person.update.mock.calls[0][0].data + expect(updateData.immichPersonId).toBeNull() + expect(updateData).not.toHaveProperty('avatarPath') + expect(updateData).not.toHaveProperty('avatarMimeType') + + await expect(fsExists(await absStoragePath(manualAvatarRelative))).resolves.toBe(true) + await app.close() + }) + + it('rejects unknown persons and non-admin members', async () => { + const app = createApp() + const token = await createUserToken(app) + + prismaMock.person.findFirst.mockResolvedValue(null) + const unknown = await app.inject({ + method: 'POST', + url: '/trees/tree-1/immich/unassign', + headers: { authorization: `Bearer ${token}` }, + payload: { personId: 'person-x' }, + }) + expect(unknown.statusCode).toBe(404) + + prismaMock.treeMembership.findUnique.mockResolvedValue({ + treeId: 'tree-1', + userId: 'user-1', + role: 'member', + tree: { deletedAt: null }, + }) + prismaMock.person.findFirst.mockResolvedValue({ id: 'person-1', avatarPath: null, immichPersonId }) + const member = await app.inject({ + method: 'POST', + url: '/trees/tree-1/immich/unassign', + headers: { authorization: `Bearer ${token}` }, + payload: { personId: 'person-1' }, + }) + expect(member.statusCode).toBe(403) + await app.close() + }) +}) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 6d1fb8c..f9cea5d 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1503,6 +1503,11 @@ function App() { } }, [auth.userAuth.token, tree.treeContext.accessToken, tree.treeContext.treeId, refreshTreeAndKeepSelection]) + // Visage venu d'Immich : recharger l'arbre pour montrer le nouveau portrait (ou le portrait restant) + const handleImmichChange = useCallback(async (personId) => { + await refreshTreeAndKeepSelection(personId, { relayout: false, avatarCacheBust: Date.now() }) + }, [refreshTreeAndKeepSelection]) + const handlePersonAvatarDelete = useCallback(async (personId) => { const accessToken = tree.treeContext.accessToken || auth.userAuth.token if (!accessToken || !tree.treeContext.treeId) return @@ -2698,6 +2703,9 @@ function App() { onManageMedia={media.handleOpenMediaManager} onAvatarUpload={tree.canEditCurrentTree ? handlePersonAvatarUpload : undefined} onAvatarDelete={tree.canEditCurrentTree ? handlePersonAvatarDelete : undefined} + treeId={tree.treeContext.treeId || null} + immichToken={tree.treeContext.accessToken || auth.userAuth.token} + onImmichChange={tree.canEditCurrentTree ? handleImmichChange : undefined} avatarActionLoading={avatarActionState.loading} avatarActionError={avatarActionState.error} isAdmin={userRole.isAdmin} diff --git a/frontend/src/api/immichApi.js b/frontend/src/api/immichApi.js new file mode 100644 index 0000000..f045f41 --- /dev/null +++ b/frontend/src/api/immichApi.js @@ -0,0 +1,57 @@ +import { API_BASE_URL, apiRequest } from './client.js' + +// Reconnaissance des visages : l'API du backend parle à Immich et ne lui expose ni son +// adresse ni sa clé. Les erreurs distinguent non configuré / injoignable / clé refusée. + +function withAuth(options = {}, token) { + if (!token) return options + return { ...options, headers: { ...(options.headers || {}), Authorization: `Bearer ${token}` } } +} + +export function getImmichErrorMessage(error, t) { + const code = error?.payload?.error || '' + if (code === 'immich_not_configured') return t('Immich n’est pas configuré sur le serveur.') + if (code === 'immich_unreachable') return t('Immich est injoignable.') + if (code === 'immich_auth_failed') return t('La clé d’accès à Immich a été refusée.') + if (code === 'immich_person_not_found') return t('Personne introuvable dans Immich.') + if (code === 'invalid_immich_person_id') return t('Identifiant Immich invalide.') + if (code === 'invalid_avatar_image') return t('La vignette Immich n’est pas une image valide.') + if (code === 'storage_quota_exceeded') return t('Espace disque de l’arbre dépassé.') + if (code === 'forbidden') return t('Seuls les administrateurs de l’arbre peuvent associer un visage Immich.') + if (error?.status === 0 || error?.message === 'Failed to fetch') return t('Immich est injoignable.') + return error?.message || t('Erreur inattendue.') +} + +export async function checkImmichStatus(treeId, token) { + return apiRequest(`/trees/${encodeURIComponent(treeId)}/immich/status`, withAuth({}, token)) +} + +export async function listImmichPeople(treeId, token) { + const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/immich/people`, withAuth({}, token)) + return Array.isArray(data?.people) ? data.people : [] +} + +export async function fetchImmichThumbnail(treeId, immichPersonId, token) { + const path = `/trees/${encodeURIComponent(treeId)}/immich/thumbnail/${encodeURIComponent(immichPersonId)}` + const response = await fetch(`${API_BASE_URL}${path}`, { + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }) + if (!response.ok) { + throw new Error(`immich_thumbnail_failed_${response.status}`) + } + return URL.createObjectURL(await response.blob()) +} + +export async function assignImmichPerson(treeId, personId, immichPersonId, token) { + return apiRequest(`/trees/${encodeURIComponent(treeId)}/immich/assign`, withAuth({ + method: 'POST', + body: JSON.stringify({ personId, immichPersonId }), + }, token)) +} + +export async function unassignImmichPerson(treeId, personId, token) { + return apiRequest(`/trees/${encodeURIComponent(treeId)}/immich/unassign`, withAuth({ + method: 'POST', + body: JSON.stringify({ personId }), + }, token)) +} diff --git a/frontend/src/components/ImmichFacePanel.css b/frontend/src/components/ImmichFacePanel.css new file mode 100644 index 0000000..b5fa855 --- /dev/null +++ b/frontend/src/components/ImmichFacePanel.css @@ -0,0 +1,81 @@ +/* Association d'un visage Immich : sélecteur de personne avec aperçu du portrait. La fenêtre vient de pz.css */ +.immich-modal-overlay { z-index: 2200; } +.immich-modal-card { max-width: 520px; } + +.immich-body { display: grid; gap: 16px; } + +.immich-current { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; + padding: 12px 14px; + border: 1px solid var(--pz-a300); + border-radius: var(--pz-r-m); + background: var(--pz-a50); + font: 500 13px var(--pz-sans); + color: var(--pz-ink-2); +} + +.immich-search { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 12px; + border: 1px solid var(--pz-line); + border-radius: var(--pz-r-m); + background: var(--pz-surface); + color: var(--pz-faint); +} +.immich-search input { + flex: 1; + border: none; + background: transparent; + font: 500 13.5px var(--pz-sans); + color: var(--pz-ink); + outline: none; +} + +.immich-list { + display: grid; + gap: 6px; + max-height: 220px; + overflow-y: auto; + border: 1px solid var(--pz-line); + border-radius: var(--pz-r-m); + padding: 6px; +} +.immich-person { + display: flex; + align-items: center; + gap: 10px; + padding: 9px 10px; + border: 1px solid transparent; + border-radius: calc(var(--pz-r-m) - 3px); + background: var(--pz-surface); + color: var(--pz-ink-2); + font: 500 13px var(--pz-sans); + text-align: left; + cursor: pointer; + transition: background-color 140ms ease, color 140ms ease; +} +.immich-person.is-active { border-color: var(--pz-a500); background: var(--pz-a50); color: var(--pz-a700); } +@media (hover: hover) and (pointer: fine) { .immich-person:hover:not(.is-active) { background: var(--pz-a50); } } + +.immich-preview { + display: grid; + justify-items: center; + gap: 8px; + padding: 12px; + border: 1px dashed var(--pz-line); + border-radius: var(--pz-r-m); +} +.immich-preview img { width: 96px; height: 96px; object-fit: cover; border-radius: 50%; } +.immich-preview-empty { width: 96px; height: 96px; display: grid; place-items: center; border-radius: 50%; background: var(--pz-surface); color: var(--pz-faint); } +.immich-preview-name { font: 500 13px var(--pz-sans); color: var(--pz-faint); } + +.immich-empty { font: 500 13px var(--pz-sans); color: var(--pz-faint); text-align: center; padding: 12px 0; } +.immich-error { margin: 0; } +.immich-spin { animation: immich-rotate 900ms linear infinite; } +@keyframes immich-rotate { to { transform: rotate(360deg); } } diff --git a/frontend/src/components/ImmichFacePanel.jsx b/frontend/src/components/ImmichFacePanel.jsx new file mode 100644 index 0000000..1c57acf --- /dev/null +++ b/frontend/src/components/ImmichFacePanel.jsx @@ -0,0 +1,220 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { ImagePlus, Loader2, Search, Unlink, UserRound, X } from 'lucide-react' + +import { + assignImmichPerson, + checkImmichStatus, + fetchImmichThumbnail, + getImmichErrorMessage, + listImmichPeople, + unassignImmichPerson, +} from '../api/immichApi.js' +import { t } from '../i18n/index.js' +import PzBusy from './PzBusy.jsx' +import './ImmichFacePanel.css' + +// Associer une personne de l'arbre à une personne Immich : sa vignette devient le portrait. +// Réservé aux administrateurs de l'arbre (l'API vérifie le rôle côté serveur). +const ImmichFacePanel = ({ person, treeId, token, onClose, onImmichChange }) => { + const [available, setAvailable] = useState(null) // null : vérification en cours + const [unavailableReason, setUnavailableReason] = useState('') + const [people, setPeople] = useState([]) + const [loadingPeople, setLoadingPeople] = useState(false) + const [search, setSearch] = useState('') + const [selected, setSelected] = useState(null) + const [thumbnailUrl, setThumbnailUrl] = useState(null) + const [actionLoading, setActionLoading] = useState(false) + const [error, setError] = useState('') + const thumbnailRef = useRef(null) + + const releaseThumbnail = useCallback(() => { + if (thumbnailRef.current) { + URL.revokeObjectURL(thumbnailRef.current) + thumbnailRef.current = null + } + setThumbnailUrl(null) + }, []) + + useEffect(() => { + let cancelled = false + releaseThumbnail() + + const load = async () => { + try { + const status = await checkImmichStatus(treeId, token) + if (cancelled) return + setAvailable(Boolean(status?.available)) + setUnavailableReason(status?.available ? '' : (status?.reason || 'unreachable')) + if (!status?.available) return + + setLoadingPeople(true) + const immichPeople = await listImmichPeople(treeId, token) + if (cancelled) return + setPeople(immichPeople) + } catch (err) { + if (cancelled) return + setAvailable(false) + setUnavailableReason('') + setError(getImmichErrorMessage(err, t)) + } finally { + if (!cancelled) setLoadingPeople(false) + } + } + + load() + return () => { cancelled = true } + }, [treeId, token, releaseThumbnail]) + + useEffect(() => releaseThumbnail, [releaseThumbnail]) + + const loadThumbnail = useCallback(async (immichPersonId) => { + releaseThumbnail() + try { + const url = await fetchImmichThumbnail(treeId, immichPersonId, token) + thumbnailRef.current = url + setThumbnailUrl(url) + } catch { + setThumbnailUrl(null) + } + }, [treeId, token, releaseThumbnail]) + + const handleSelect = useCallback((immichPerson) => { + setSelected(immichPerson) + setError('') + if (immichPerson?.hasThumbnail && immichPerson?.id) { + loadThumbnail(immichPerson.id) + } else { + releaseThumbnail() + } + }, [loadThumbnail, releaseThumbnail]) + + const handleAssign = useCallback(async () => { + if (!selected || actionLoading) return + setActionLoading(true) + setError('') + try { + await assignImmichPerson(treeId, person.id, selected.id, token) + onImmichChange?.(person.id) + onClose?.() + } catch (err) { + setError(getImmichErrorMessage(err, t)) + } finally { + setActionLoading(false) + } + }, [selected, actionLoading, treeId, person, token, onImmichChange, onClose]) + + const handleUnassign = useCallback(async () => { + if (!person.immichPersonId || actionLoading) return + setActionLoading(true) + setError('') + try { + await unassignImmichPerson(treeId, person.id, token) + onImmichChange?.(person.id) + onClose?.() + } catch (err) { + setError(getImmichErrorMessage(err, t)) + } finally { + setActionLoading(false) + } + }, [person, actionLoading, treeId, token, onImmichChange, onClose]) + + const filteredPeople = search.trim() + ? people.filter((immichPerson) => immichPerson.name?.toLowerCase().includes(search.trim().toLowerCase())) + : people + + const handleOverlayClick = (event) => { + if (event.target === event.currentTarget) { + onClose() + } + } + + return ( +
+
+
+

{t('Reconnaissance des visages')}

+

{person.firstName} {person.lastName}

+
+ + + {available === null || loadingPeople ? ( +
+ ) : available === false ? ( +
+

+ {unavailableReason === 'not_configured' + ? t('Immich n’est pas configuré sur le serveur.') + : t('Immich est injoignable.')} +

+
+ ) : ( +
+ {person.immichPersonId && ( +
+

{t('{name} est déjà associée à une personne Immich.', { name: person.firstName || person.lastName })}

+ +
+ )} + +
+ {t('Personne à associer dans Immich')} +
+
+
+ + {filteredPeople.length === 0 ? ( +

{people.length === 0 ? t('Aucune personne dans Immich.') : t('Aucune personne trouvée.')}

+ ) : ( +
+ {filteredPeople.map((immichPerson) => ( + + ))} +
+ )} + +
+ {thumbnailUrl + ? {selected?.name + :
} +
{selected?.name || t('Aucune sélection')}
+
+ + {error &&
{error}
} + +
+ + +
+
+ )} +
+
+ ) +} + +export default ImmichFacePanel diff --git a/frontend/src/components/PersonCard.jsx b/frontend/src/components/PersonCard.jsx index bf35fdb..5c25c37 100644 --- a/frontend/src/components/PersonCard.jsx +++ b/frontend/src/components/PersonCard.jsx @@ -1,7 +1,8 @@ import { useState, useEffect, useRef, forwardRef } from 'react' -import { Save, Trash2, Plus, X, Search, Check, Play } from 'lucide-react' +import { Save, Trash2, Plus, X, Search, Check, Play, Sparkles } from 'lucide-react' import { getParents, getChildren, getPersonUnions, getPersonMedias, filiations, persons, unions } from '../data/mockData' import GeoMediaThumbnail from './GeoMediaThumbnail' +import ImmichFacePanel from './ImmichFacePanel.jsx' import './PersonCard.css' import PzBusy from './PzBusy.jsx' import { t, dateLocale } from '../i18n/index.js' @@ -35,6 +36,9 @@ const PersonCard = ({ onManageMedia, onAvatarUpload, onAvatarDelete, + treeId = null, + immichToken = null, + onImmichChange, avatarActionLoading = false, avatarActionError = '', isAdmin = false, @@ -55,6 +59,7 @@ const PersonCard = ({ const [saving, setSaving] = useState(false) const [saveSuccess, setSaveSuccess] = useState(false) const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) + const [showImmichPanel, setShowImmichPanel] = useState(false) // Inline relation search state const [activeRelationSearch, setActiveRelationSearch] = useState(null) // 'parent' | 'spouse' | 'child' | null @@ -356,6 +361,11 @@ const PersonCard = ({ {t('Retirer')} )} + {treeId && onImmichChange && ( + + )} )} @@ -675,6 +685,17 @@ const PersonCard = ({ )} )} + + {/* ── Association d'un visage Immich (modale) ── */} + {showImmichPanel && treeId && ( + setShowImmichPanel(false)} + onImmichChange={onImmichChange} + /> + )} ) } diff --git a/frontend/src/data/mockData.js b/frontend/src/data/mockData.js index b57a489..79b7c71 100644 --- a/frontend/src/data/mockData.js +++ b/frontend/src/data/mockData.js @@ -115,6 +115,7 @@ function mapRemotePersonToLocal(person, current = null) { profession: person?.profession || null, nationality: person?.nationality || null, photo: current?.photo || null, + immichPersonId: person?.immichPersonId ?? null, } } diff --git a/frontend/src/data/mockData.test.js b/frontend/src/data/mockData.test.js index 5caa7fe..ca7de5a 100644 --- a/frontend/src/data/mockData.test.js +++ b/frontend/src/data/mockData.test.js @@ -15,6 +15,7 @@ describe('loadGraphData avatar mapping', () => { deathDate: null, notes: null, avatarUrlPath: '/trees/t-1/persons/p-1/avatar', + immichPersonId: '123e4567-e89b-42d3-a456-426614174000', }, { id: 'p-2', @@ -24,6 +25,7 @@ describe('loadGraphData avatar mapping', () => { deathDate: null, notes: null, avatarUrlPath: null, + immichPersonId: null, }, ], unions: [], @@ -48,5 +50,8 @@ describe('loadGraphData avatar mapping', () => { expect(getPersonById('p-1')?.photo).toBe('/api/trees/t-1/persons/p-1/avatar?token=abc') expect(getPersonById('p-2')?.photo).toBeNull() expect(getPersonMedias('p-2')[0]?.source).toBe('Album familial') + // Association Immich portée par la personne (null quand il n'y en a pas) + expect(getPersonById('p-1')?.immichPersonId).toBe('123e4567-e89b-42d3-a456-426614174000') + expect(getPersonById('p-2')?.immichPersonId).toBeNull() }) }) diff --git a/frontend/src/i18n/en/fiche.js b/frontend/src/i18n/en/fiche.js index 2c1fcbe..1e04029 100644 --- a/frontend/src/i18n/en/fiche.js +++ b/frontend/src/i18n/en/fiche.js @@ -259,4 +259,27 @@ export default { 'En rédaction': 'In the newsroom', 'Interview archive radio': 'Archived radio interview', 'Inconnu': 'Unknown', + // Visages venus d'Immich (PersonCard, ImmichFacePanel, immichApi) + 'Depuis Immich': 'From Immich', + 'Visage Immich': 'Immich face', + 'Reconnaissance des visages': 'Face recognition', + 'Immich n’est pas configuré sur le serveur.': 'Immich is not configured on the server.', + 'Immich est injoignable.': 'Immich cannot be reached.', + 'La clé d’accès à Immich a été refusée.': 'The Immich access key was rejected.', + 'Personne introuvable dans Immich.': 'Person not found in Immich.', + 'Identifiant Immich invalide.': 'Invalid Immich identifier.', + 'La vignette Immich n’est pas une image valide.': 'The Immich thumbnail is not a valid image.', + 'Espace disque de l’arbre dépassé.': 'Tree storage quota exceeded.', + 'Seuls les administrateurs de l’arbre peuvent associer un visage Immich.': 'Only tree administrators can link an Immich face.', + 'Erreur inattendue.': 'Unexpected error.', + '{name} est déjà associée à une personne Immich.': '{name} is already linked to an Immich person.', + 'Retirer l’association': 'Remove the link', + 'Personne à associer dans Immich': 'Immich person to link', + 'Rechercher par nom': 'Search by name', + 'Aucune personne dans Immich.': 'No people in Immich.', + 'Aucune personne trouvée.': 'No people found.', + 'Personnes Immich': 'Immich people', + 'Sans nom': 'Unnamed', + 'Aucune sélection': 'No selection', + 'Associer et prendre le portrait': 'Link and use as portrait', } diff --git a/frontend/src/utils/graphAdapter.js b/frontend/src/utils/graphAdapter.js index 7fb09ca..f0f716f 100644 --- a/frontend/src/utils/graphAdapter.js +++ b/frontend/src/utils/graphAdapter.js @@ -57,6 +57,7 @@ export function adaptGraphForGalaxy(apiGraph, _treeId) { birthDate: p.birthDate, deathDate: p.deathDate, photo: p.avatarUrlPath ? `/api${p.avatarUrlPath}` : null, + immichPersonId: p.immichPersonId || null, frameType: 'round', // Default }))