mirror of
https://github.com/IliesAllali/parenthese.git
synced 2026-11-11 16:12:14 +00:00
- Colonne persons.immichPersonId (schéma + migration 20261012_001) et exposition dans le graphe pour l'afficher côté fiche. - Client Immich (src/lib/immich.ts) sur le contrat OpenAPI v3.1.0 : ping, /api/people paginé, vignette par personne ; erreurs distinctes entre injoignable, clé refusée et introuvable, identifiants validés comme UUID v4. - Routes /trees/:id/immich/* (status, people, thumbnail, assign, unassign), réservées aux administrateurs de l'arbre ; l'adresse et la clé d'Immich ne quittent jamais le backend. - La vignette passe par le pipeline des portraits (type lu dans les octets, métadonnées retirées, quota de l'arbre) : le fichier est écrit avant la transaction (personne + journal immich_assign) et l'ancien portrait est supprimé après ; un échec du nettoyage est journalisé sans faire échouer la requête. Le désassignement retire le portrait seulement s'il vient d'Immich (préfixe immich-) et préserve un portrait envoyé à la main. - Côté front : bouton « Depuis Immich » sur la fiche en édition, panneau de sélection avec aperçu et retrait de l'association, traductions anglaises, messages d'erreur distincts. - Tests : 16 tests backend (succès, 400/401/403/404/413/502/503, quota, échec de transaction, préservation du portrait manuel, serveur désactivé) et le mapping immichPersonId côté front. Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
209 lines
6.5 KiB
TypeScript
209 lines
6.5 KiB
TypeScript
import Fastify, { type FastifyError } from 'fastify'
|
|
import cors from '@fastify/cors'
|
|
import jwt from '@fastify/jwt'
|
|
import rateLimit from '@fastify/rate-limit'
|
|
import { Prisma } from '@prisma/client'
|
|
|
|
import { env } from './config/env.js'
|
|
import { prisma } from './lib/prisma.js'
|
|
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'
|
|
import { gedcomRoutes } from './routes/gedcom.js'
|
|
import { treeRoutes } from './routes/trees.js'
|
|
import { visitRoutes } from './routes/visits.js'
|
|
import type { AnyJwtPayload } from './types/auth.js'
|
|
|
|
// Les adresses des médias portent un jeton (<img src> n'envoie pas d'en-tête) : jamais dans les journaux
|
|
export function redactUrl(url: string | undefined): string | undefined {
|
|
return url?.replace(/([?&]token=)[^&#]*/g, '$1[masqué]')
|
|
}
|
|
|
|
export function createApp() {
|
|
const app = Fastify({
|
|
logger: env.NODE_ENV === 'test'
|
|
? false
|
|
: {
|
|
serializers: {
|
|
req(request) {
|
|
return {
|
|
method: request.method,
|
|
url: redactUrl(request.url),
|
|
host: request.host,
|
|
remoteAddress: request.ip,
|
|
}
|
|
},
|
|
},
|
|
},
|
|
// 1 Mo par défaut : le JSON est lu avant toute vérification d'identité. Les envois de fichiers, l'import
|
|
// GEDCOM, les contributions et les annotations groupées montent à UPLOAD_BODY_LIMIT sur leur route.
|
|
bodyLimit: 1024 * 1024,
|
|
// Accept both `/path` et `/path/` to avoid 301 → 400 issues via nginx
|
|
routerOptions: { ignoreTrailingSlash: true },
|
|
// Seuls les proxys connus (nginx) sont crus : l'adresse du client est la dernière qu'ils ont ajoutée à
|
|
// X-Forwarded-For. Faire confiance à tout l'en-tête laissait le client choisir son adresse et contourner
|
|
// les limites d'essais (voir TRUST_PROXY dans config/env.ts).
|
|
trustProxy: env.TRUST_PROXY,
|
|
})
|
|
|
|
app.decorate('prisma', prisma)
|
|
app.decorateRequest('actor', null)
|
|
|
|
app.register(cors, {
|
|
origin: env.CORS_ORIGIN.length === 1 ? env.CORS_ORIGIN[0] : env.CORS_ORIGIN,
|
|
// @fastify/cors 11 n'annonce plus par défaut que GET, HEAD et POST : l'API sert aussi PUT, PATCH, DELETE
|
|
methods: 'GET,HEAD,PUT,PATCH,POST,DELETE',
|
|
})
|
|
|
|
app.register(rateLimit, {
|
|
global: false,
|
|
})
|
|
|
|
app.register(jwt, {
|
|
secret: env.JWT_SECRET,
|
|
})
|
|
|
|
app.addHook('preHandler', async (request) => {
|
|
request.actor = null
|
|
|
|
const authHeader = request.headers.authorization
|
|
if (!authHeader?.startsWith('Bearer ')) {
|
|
return
|
|
}
|
|
|
|
try {
|
|
const payload = await request.jwtVerify<AnyJwtPayload>()
|
|
|
|
if (payload.kind === 'user') {
|
|
if (isUserTokenRevoked(payload.jti)) {
|
|
return
|
|
}
|
|
|
|
// Sessions émises avant SESSIONS_NOT_BEFORE refusées (voir config/env.ts)
|
|
if (typeof payload.iat !== 'number' || payload.iat * 1000 < env.SESSIONS_NOT_BEFORE.getTime()) {
|
|
return
|
|
}
|
|
|
|
// Un compte supprimé invalide aussi les sessions ouvertes sur d'autres appareils
|
|
const accountCount = await app.prisma.user.count({ where: { id: payload.userId } })
|
|
if (accountCount === 0) {
|
|
return
|
|
}
|
|
|
|
request.actor = {
|
|
kind: 'user',
|
|
userId: payload.userId,
|
|
email: payload.email,
|
|
}
|
|
}
|
|
|
|
if (payload.kind === 'tree_access') {
|
|
const accessState = await app.prisma.treeAccessPasswords.findUnique({
|
|
where: {
|
|
treeId: payload.treeId,
|
|
},
|
|
select: {
|
|
updatedAt: true,
|
|
tree: {
|
|
select: {
|
|
deletedAt: true,
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
if (!accessState || accessState.tree.deletedAt) {
|
|
return
|
|
}
|
|
|
|
if (accessState.updatedAt.getTime() !== payload.accessVersion) {
|
|
return
|
|
}
|
|
|
|
request.actor = {
|
|
kind: 'tree_access',
|
|
treeId: payload.treeId,
|
|
role: payload.role,
|
|
accessVersion: payload.accessVersion,
|
|
}
|
|
}
|
|
} catch {
|
|
request.actor = null
|
|
}
|
|
})
|
|
|
|
app.get('/health', async () => ({ ok: true }))
|
|
|
|
app.register(authRoutes, { prefix: '/auth' })
|
|
app.register(publicRoutes)
|
|
app.register(treeRoutes)
|
|
app.register(mediaRoutes)
|
|
app.register(graphRoutes)
|
|
app.register(contributionRoutes)
|
|
app.register(annotationRoutes)
|
|
app.register(gedcomRoutes)
|
|
app.register(visitRoutes)
|
|
app.register(immichRoutes)
|
|
|
|
app.setErrorHandler((error: FastifyError | Error, request, reply) => {
|
|
if (error instanceof Prisma.PrismaClientInitializationError) {
|
|
return reply.code(503).send({
|
|
error: 'database_unavailable',
|
|
message: 'Database server unreachable. Start PostgreSQL and retry.',
|
|
})
|
|
}
|
|
|
|
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
|
const prismaError = error as Prisma.PrismaClientKnownRequestError
|
|
if (prismaError.code === 'P2002') {
|
|
return reply.code(409).send({
|
|
error: 'unique_constraint_violation',
|
|
message: 'A record with this value already exists.',
|
|
})
|
|
}
|
|
|
|
if (prismaError.code === 'P2025') {
|
|
return reply.code(404).send({
|
|
error: 'record_not_found',
|
|
message: 'The requested record does not exist.',
|
|
})
|
|
}
|
|
|
|
request.log.error({ err: error, prismaCode: prismaError.code }, 'Prisma known request error')
|
|
return reply.code(500).send({
|
|
error: 'database_error',
|
|
message: 'A database error occurred.',
|
|
})
|
|
}
|
|
|
|
const statusCode = 'statusCode' in error ? (error as FastifyError).statusCode : undefined
|
|
const errorCode = 'code' in error ? (error as FastifyError).code : undefined
|
|
|
|
if (statusCode && statusCode < 500) {
|
|
return reply.code(statusCode).send({
|
|
error: errorCode ?? 'request_error',
|
|
message: error.message,
|
|
})
|
|
}
|
|
|
|
request.log.error({ err: error }, 'Unhandled error')
|
|
return reply.code(statusCode ?? 500).send({
|
|
error: 'internal_error',
|
|
message: env.NODE_ENV === 'production'
|
|
? 'An unexpected error occurred.'
|
|
: error.message,
|
|
})
|
|
})
|
|
|
|
app.addHook('onClose', async () => {
|
|
await app.prisma.$disconnect()
|
|
})
|
|
|
|
return app
|
|
}
|