mirror of
https://github.com/IliesAllali/parenthese.git
synced 2026-11-06 23:32:08 +00:00
* Prisma 7 : client généré dans src/generated, pilote pg, prisma.config.ts - générateur prisma-client (ESM, extension .js), compilé dans dist : plus de node_modules/.prisma à copier dans les images - @prisma/adapter-pg, délai de connexion de 5 s comme le moteur de Prisma 6 - prisma.config.ts : adresse de la base et .env pour le CLI, sans dépendre de prisma/config à l'exécution (CLI à part dans les images) - CI : migrate diff --from-config-datasource --to-schema - deploy.sh : arrêt avant installation si Node est trop vieux pour Prisma 7 - remplace les PR Dependabot #135 et #137 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Prisma 7 : import du client généré dans token-revocation (#163) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
657 lines
27 KiB
TypeScript
657 lines
27 KiB
TypeScript
import { Prisma } from '../generated/prisma/client.js'
|
||
import type { FastifyInstance, 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 {
|
||
getImmichInstanceByApiUrl,
|
||
getImmichInstances,
|
||
getTreeImmichClient,
|
||
isImmichPersonId,
|
||
normalizeImmichApiUrl,
|
||
ImmichAuthError,
|
||
ImmichNotFoundError,
|
||
ImmichUnavailableError,
|
||
type ImmichPersonSummary,
|
||
} from '../lib/immich.js'
|
||
import { decryptImmichApiKey, encryptImmichApiKey } from '../lib/immich-secret.js'
|
||
import { IMAGE_MIME_TYPES, InvalidMediaError, isImmichAvatarPath, removeStoredFile, sniffMimeType } from '../lib/media-files.js'
|
||
import { exceedsStorageQuota, getStorageRoot, prepareUploadedFile } from './media.js'
|
||
|
||
// Reconnaissance des visages : associer une personne de l'arbre à une personne Immich, dont la
|
||
// vignette devient son portrait. Chaque arbre porte sa propre connexion Immich : une adresse
|
||
// choisie parmi celles autorisées par l'opérateur du serveur (IMMICH_ALLOWED_API_URLS), et une
|
||
// clé chiffrée en base. Toutes les routes sont rattachées à un arbre, réservées à ses
|
||
// administrateurs, et ne parlent jamais à l'instance d'un autre arbre.
|
||
|
||
const treeParamSchema = z.object({ id: z.string().min(1) })
|
||
const treePersonParamsSchema = z.object({ id: z.string().min(1), personId: z.string().min(1) })
|
||
|
||
const assignSchema = z.object({
|
||
personId: z.string().min(1),
|
||
immichPersonId: z.string().refine(isImmichPersonId, 'invalid_immich_person_id'),
|
||
// Remplacer un portrait déjà en place demande un accord explicite, quelle que soit son
|
||
// origine (posé à la main ou venu d'une association Immich précédente).
|
||
replaceAvatar: z.boolean().optional(),
|
||
})
|
||
|
||
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'),
|
||
})
|
||
|
||
// Connexion Immich d'un arbre : instance choisie parmi celles autorisées par l'opérateur du
|
||
// serveur (IMMICH_ALLOWED_API_URLS), désignée par son identifiant opaque. L'adresse ne circule
|
||
// jamais vers le front. La clé se joint à la mise à jour ou, si le champ reste vide, celle en
|
||
// place est conservée — jamais relue par le front.
|
||
const configPayloadSchema = z.object({
|
||
instanceId: z.string().regex(/^[0-9a-f]{16}$/, 'invalid_instance_id'),
|
||
apiKey: z.string().min(1).max(512).optional(),
|
||
})
|
||
|
||
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' })
|
||
}
|
||
|
||
// Métadonnées sûres d'une connexion Immich d'arbre : jamais la clé, jamais son chiffré, jamais
|
||
// l'adresse (l'instance se nomme par son libellé). Une adresse sortie de la liste de l'opérateur
|
||
// vaut « non configuré » : la connexion est inerte. Une clé qui ne se déchiffre plus (JWT_SECRET
|
||
// changé) vaut aussi « non configuré », avec keyNeedsReentry : l'administrateur doit la ressaisir.
|
||
function immichConfigSummary(config: { apiUrl: string; apiKeyEnc: string; updatedAt: Date } | null) {
|
||
const instance = config ? getImmichInstanceByApiUrl(config.apiUrl) : undefined
|
||
if (!config || !instance) {
|
||
return { configured: false, keyNeedsReentry: false, instanceId: null, label: null, updatedAt: null }
|
||
}
|
||
const keyReadable = decryptImmichApiKey(config.apiKeyEnc) !== null
|
||
return {
|
||
configured: keyReadable,
|
||
keyNeedsReentry: !keyReadable,
|
||
instanceId: instance.id,
|
||
label: instance.label,
|
||
updatedAt: config.updatedAt,
|
||
}
|
||
}
|
||
|
||
// Portraits venus d'Immich : même nommage que les envois manuels (« avatar-<uuid> »), 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}`)
|
||
}
|
||
|
||
|
||
async function membershipRole(app: FastifyInstance, userId: string, treeId: string) {
|
||
const membership = await app.prisma.treeMembership.findUnique({
|
||
where: { treeId_userId: { treeId, userId } },
|
||
select: { role: true, tree: { select: { deletedAt: true } } },
|
||
})
|
||
return membership && !membership.tree.deletedAt ? membership.role : null
|
||
}
|
||
|
||
// Immich passe par la clé du propriétaire, qui ouvre toute sa photothèque, pas seulement les photos de l'arbre :
|
||
// réservé au propriétaire, jamais aux co-gestionnaires (issue #145)
|
||
async function requireImmichOwner(app: FastifyInstance, userId: string, treeId: string): Promise<boolean> {
|
||
return (await membershipRole(app, userId, treeId)) === 'owner'
|
||
}
|
||
|
||
export const immichRoutes: FastifyPluginAsync = async (app) => {
|
||
// Lecture réservée aux administrateurs : la clé donne accès à la photothèque de l'arbre.
|
||
// Les fichiers sélectionnés passent ensuite par l'envoi de souvenirs habituel (quota,
|
||
// validation et rattachement au moment), sans exposer d'URL ni de clé Immich au navigateur.
|
||
app.get('/trees/:id/immich/assets', async (request, reply) => {
|
||
if (request.actor?.kind !== 'user') return reply.code(401).send({ error: 'authentication_required' })
|
||
const params = treeParamSchema.safeParse(request.params)
|
||
const query = z.object({
|
||
page: z.coerce.number().int().min(1).max(100000).default(1),
|
||
type: z.enum(['IMAGE', 'VIDEO']).default('IMAGE'),
|
||
q: z.string().trim().max(500).optional(),
|
||
personId: z.string().refine(isImmichPersonId).optional(),
|
||
}).safeParse(request.query)
|
||
if (!params.success || !query.success) return reply.code(400).send({ error: 'invalid_params' })
|
||
if (!(await requireImmichOwner(app, request.actor.userId, params.data.id))) return reply.code(403).send({ error: 'forbidden' })
|
||
const client = await getTreeImmichClient(app.prisma, params.data.id)
|
||
if (!client) return reply.code(503).send({ error: 'immich_not_configured' })
|
||
try {
|
||
reply.header('Cache-Control', 'no-store')
|
||
return reply.send(await client.listAssets(query.data.page, query.data.type, { query: query.data.q, personId: query.data.personId }))
|
||
} catch (error) { return replyImmichError(request, reply, error) }
|
||
})
|
||
|
||
app.get('/trees/:id/immich/assets/:assetId/:variant', async (request, reply) => {
|
||
if (request.actor?.kind !== 'user') return reply.code(401).send({ error: 'authentication_required' })
|
||
const params = z.object({ id: z.string().min(1), assetId: z.string().refine(isImmichPersonId), variant: z.enum(['thumbnail', 'preview', 'original']) }).safeParse(request.params)
|
||
if (!params.success) return reply.code(400).send({ error: 'invalid_params' })
|
||
if (!(await requireImmichOwner(app, request.actor.userId, params.data.id))) return reply.code(403).send({ error: 'forbidden' })
|
||
const client = await getTreeImmichClient(app.prisma, params.data.id)
|
||
if (!client) return reply.code(503).send({ error: 'immich_not_configured' })
|
||
try {
|
||
const thumbnail = params.data.variant !== 'original'
|
||
const bytes = await client.getAssetBytes(params.data.assetId, thumbnail, (thumbnail ? 5 : 20) * 1024 * 1024, params.data.variant === 'preview')
|
||
if (!bytes) return reply.code(413).send({ error: 'immich_asset_too_large' })
|
||
const mime = sniffMimeType(bytes, 'video')
|
||
if (!mime || !(mime.startsWith('image/') || (!thumbnail && mime.startsWith('video/')))) return reply.code(400).send({ error: 'invalid_immich_media' })
|
||
if (mime.startsWith('image/') && bytes.length > MAX_THUMBNAIL_BYTES) return reply.code(413).send({ error: 'immich_asset_too_large' })
|
||
return reply.header('Content-Type', mime).header('X-Content-Type-Options', 'nosniff').header('Cache-Control', 'no-store').send(bytes)
|
||
} catch (error) {
|
||
if (error instanceof ImmichNotFoundError) return reply.code(404).send({ error: 'immich_asset_not_found' })
|
||
return replyImmichError(request, reply, error)
|
||
}
|
||
})
|
||
|
||
// ── Connexion Immich de l'arbre : lecture, écriture, suppression. Réservées aux
|
||
// administrateurs de l'arbre ; la clé d'API ne quitte jamais le serveur.
|
||
|
||
app.get('/trees/:id/immich/config', 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' })
|
||
}
|
||
|
||
const role = await membershipRole(app, request.actor.userId, params.data.id)
|
||
if (role !== 'owner' && role !== 'admin') {
|
||
return reply.code(403).send({ error: 'forbidden' })
|
||
}
|
||
// Co-gestionnaire : la clé Immich est celle du propriétaire et ouvre toute sa photothèque. Il ne voit ni ne
|
||
// règle rien ; pour l'interface, Immich n'est pas configuré
|
||
if (role !== 'owner') {
|
||
return reply.send({ config: null, instances: [], ownerOnly: true })
|
||
}
|
||
|
||
const config = await app.prisma.treeImmichConfig.findUnique({
|
||
where: { treeId: params.data.id },
|
||
select: { apiUrl: true, apiKeyEnc: true, updatedAt: true },
|
||
})
|
||
|
||
return reply.send({
|
||
config: immichConfigSummary(config),
|
||
// Les administrateurs choisissent parmi ces instances : le front en fait un menu, pas un champ libre.
|
||
instances: getImmichInstances().map(({ id, label }) => ({ id, label })),
|
||
})
|
||
})
|
||
|
||
app.put('/trees/:id/immich/config', 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 = configPayloadSchema.safeParse(request.body)
|
||
if (!params.success) {
|
||
return reply.code(400).send({ error: 'invalid_params' })
|
||
}
|
||
if (!payload.success) {
|
||
return reply.code(400).send({ error: 'invalid_payload', details: payload.error.flatten() })
|
||
}
|
||
|
||
if (!(await requireImmichOwner(app, request.actor.userId, params.data.id))) {
|
||
return reply.code(403).send({ error: 'forbidden' })
|
||
}
|
||
|
||
// L'instance doit faire partie de la liste de l'opérateur : le serveur ne devient jamais
|
||
// un proxy vers un service interne choisi librement (SSRF). L'adresse vient de la liste,
|
||
// jamais de la requête.
|
||
const instance = getImmichInstances().find((candidate) => candidate.id === payload.data.instanceId)
|
||
if (!instance) {
|
||
return reply.code(400).send({ error: 'instance_not_allowed' })
|
||
}
|
||
|
||
const treeId = params.data.id
|
||
const existing = await app.prisma.treeImmichConfig.findUnique({
|
||
where: { treeId },
|
||
select: { apiUrl: true, apiKeyEnc: true },
|
||
})
|
||
|
||
// Sans nouvelle clé, la clé en place est conservée — mais il en faut une pour une
|
||
// première configuration, et quand celle en place ne se déchiffre plus : on ne devine
|
||
// jamais d'identifiants ailleurs.
|
||
if (!payload.data.apiKey && (!existing || !decryptImmichApiKey(existing.apiKeyEnc))) {
|
||
return reply.code(400).send({ error: 'api_key_required' })
|
||
}
|
||
|
||
// Changer d'instance n'emmène jamais la clé de l'ancienne : elle ne vaut rien pour la
|
||
// nouvelle, et on ne l'envoie pas à un serveur qui n'est pas le sien.
|
||
if (existing && !payload.data.apiKey
|
||
&& normalizeImmichApiUrl(existing.apiUrl) !== instance.apiUrl) {
|
||
return reply.code(400).send({ error: 'api_key_required' })
|
||
}
|
||
|
||
const apiKeyEnc = payload.data.apiKey
|
||
? encryptImmichApiKey(payload.data.apiKey)
|
||
: existing!.apiKeyEnc
|
||
|
||
const config = await app.prisma.treeImmichConfig.upsert({
|
||
where: { treeId },
|
||
create: { treeId, apiUrl: instance.apiUrl, apiKeyEnc },
|
||
update: { apiUrl: instance.apiUrl, apiKeyEnc },
|
||
select: { apiUrl: true, apiKeyEnc: true, updatedAt: true },
|
||
})
|
||
|
||
// Journal d'audit : jamais la clé, seulement le fait qu'elle a changé.
|
||
await app.prisma.auditLog.create({
|
||
data: {
|
||
treeId,
|
||
actorType: 'user',
|
||
actorId: request.actor.userId,
|
||
action: 'immich_config_updated',
|
||
entityType: 'tree_immich_config',
|
||
entityId: treeId,
|
||
payloadJson: { instanceId: instance.id, apiKeyChanged: Boolean(payload.data.apiKey) },
|
||
},
|
||
})
|
||
|
||
return reply.send({ config: immichConfigSummary(config) })
|
||
})
|
||
|
||
app.delete('/trees/:id/immich/config', 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 requireImmichOwner(app, request.actor.userId, params.data.id))) {
|
||
return reply.code(403).send({ error: 'forbidden' })
|
||
}
|
||
|
||
const treeId = params.data.id
|
||
const removed = await app.prisma.treeImmichConfig.deleteMany({ where: { treeId } })
|
||
|
||
if (removed.count > 0) {
|
||
await app.prisma.auditLog.create({
|
||
data: {
|
||
treeId,
|
||
actorType: 'user',
|
||
actorId: request.actor.userId,
|
||
action: 'immich_config_removed',
|
||
entityType: 'tree_immich_config',
|
||
entityId: treeId,
|
||
payloadJson: {},
|
||
},
|
||
})
|
||
}
|
||
|
||
return reply.send({ config: { configured: false, keyNeedsReentry: false, instanceId: null, label: null, updatedAt: null } })
|
||
})
|
||
|
||
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 requireImmichOwner(app, request.actor.userId, params.data.id))) {
|
||
return reply.code(403).send({ error: 'forbidden' })
|
||
}
|
||
|
||
const client = await getTreeImmichClient(app.prisma, params.data.id)
|
||
if (!client) {
|
||
return reply.send({ available: false, reason: 'not_configured' })
|
||
}
|
||
|
||
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 requireImmichOwner(app, request.actor.userId, params.data.id))) {
|
||
return reply.code(403).send({ error: 'forbidden' })
|
||
}
|
||
|
||
const client = await getTreeImmichClient(app.prisma, params.data.id)
|
||
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 requireImmichOwner(app, request.actor.userId, params.data.id))) {
|
||
return reply.code(403).send({ error: 'forbidden' })
|
||
}
|
||
|
||
const client = await getTreeImmichClient(app.prisma, params.data.id)
|
||
if (!client) {
|
||
return reply.code(503).send({ error: 'immich_not_configured' })
|
||
}
|
||
|
||
let thumbnail: Buffer | null
|
||
try {
|
||
thumbnail = await client.getPersonThumbnail(params.data.immichPersonId, MAX_THUMBNAIL_BYTES)
|
||
} catch (error) {
|
||
return replyImmichError(request, reply, error)
|
||
}
|
||
|
||
// 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 || !(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' })
|
||
}
|
||
|
||
reply.header('Content-Type', mimeType)
|
||
reply.header('X-Content-Type-Options', 'nosniff')
|
||
reply.header('Cache-Control', 'private, max-age=3600')
|
||
return reply.send(thumbnail)
|
||
})
|
||
|
||
// Personne Immich déjà associée à une fiche, pour la marquer dans le sélecteur. Réservé aux
|
||
// administrateurs : l'identifiant n'entre jamais dans le graphe public.
|
||
app.get('/trees/:id/immich/links/:personId', async (request, reply) => {
|
||
if (!request.actor || request.actor.kind !== 'user') {
|
||
return reply.code(401).send({ error: 'authentication_required' })
|
||
}
|
||
|
||
const params = treePersonParamsSchema.safeParse(request.params)
|
||
if (!params.success) {
|
||
return reply.code(400).send({ error: 'invalid_params' })
|
||
}
|
||
|
||
if (!(await requireImmichOwner(app, request.actor.userId, params.data.id))) {
|
||
return reply.code(403).send({ error: 'forbidden' })
|
||
}
|
||
|
||
const person = await app.prisma.person.findFirst({
|
||
where: { id: params.data.personId, treeId: params.data.id, deletedAt: null },
|
||
select: { immichPersonId: true },
|
||
})
|
||
if (!person) {
|
||
return reply.code(404).send({ error: 'person_not_found' })
|
||
}
|
||
|
||
return reply.send({ immichPersonId: person.immichPersonId })
|
||
})
|
||
|
||
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 requireImmichOwner(app, request.actor.userId, params.data.id))) {
|
||
return reply.code(403).send({ error: 'forbidden' })
|
||
}
|
||
|
||
const client = await getTreeImmichClient(app.prisma, params.data.id)
|
||
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' })
|
||
}
|
||
|
||
// La personne Immich doit exister : le 404 de la vignette ne le prouve pas (personne
|
||
// absente ou simple vignette manquante), on la vérifie donc explicitement. Un refus
|
||
// d'authentification ou une erreur serveur ne sont jamais pris pour une absence.
|
||
let immichPerson: ImmichPersonSummary
|
||
try {
|
||
immichPerson = await client.getPerson(immichPersonId)
|
||
} catch (error) {
|
||
return replyImmichError(request, reply, error)
|
||
}
|
||
|
||
// Vignette de la personne Immich : absente -> on associe sans toucher au portrait existant.
|
||
// Demandée seulement si Immich en annonce une : sans vignette, l'appel est inutile.
|
||
let thumbnail: Buffer | null = null
|
||
if (immichPerson.hasThumbnail) {
|
||
try {
|
||
thumbnail = await client.getPersonThumbnail(immichPersonId, MAX_THUMBNAIL_BYTES)
|
||
} 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 })
|
||
}
|
||
|
||
// Un portrait déjà en place ne se remplace jamais en silence : sans accord explicite,
|
||
// l'association s'arrête avant d'écrire quoi que ce soit. Le front transforme ce refus
|
||
// en demande de confirmation puis rejoue la requête avec replaceAvatar.
|
||
if (person.avatarPath && !payload.data.replaceAvatar) {
|
||
return reply.code(409).send({ error: 'avatar_exists' })
|
||
}
|
||
|
||
// 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<ReturnType<typeof prepareUploadedFile>>
|
||
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,
|
||
// Nouveau portrait, nouveau cadrage à faire
|
||
avatarCrop: Prisma.DbNull,
|
||
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 requireImmichOwner(app, request.actor.userId, params.data.id))) {
|
||
return reply.code(403).send({ error: 'forbidden' })
|
||
}
|
||
|
||
// Pas de garde « non configuré » ici : le retrait d'association ne parle jamais à Immich,
|
||
// et c'est le seul moyen de nettoyer des associations passées après une déconnexion.
|
||
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 graphe ne dit plus qui est associé : le retrait se propose toujours côté front. Sans
|
||
// association en place, il ne fait rien — pas d'écriture, pas d'entrée d'audit.
|
||
if (!person.immichPersonId) {
|
||
return reply.send({ ok: true, avatarRemoved: false })
|
||
}
|
||
|
||
// 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 = isImmichAvatarPath(person.avatarPath)
|
||
|
||
const updateData: {
|
||
immichPersonId: null
|
||
updatedBy: string
|
||
avatarPath?: null
|
||
avatarMimeType?: null
|
||
avatarCrop?: typeof Prisma.DbNull
|
||
} = { immichPersonId: null, updatedBy: request.actor.userId }
|
||
if (avatarFromImmich) {
|
||
updateData.avatarPath = null
|
||
updateData.avatarMimeType = null
|
||
updateData.avatarCrop = Prisma.DbNull
|
||
}
|
||
|
||
// 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 })
|
||
})
|
||
}
|