Files
parenthese/backend/src/app.ts
T
IliesAllaliandClaude Opus 5.5 291dde8073 Aperçus flous (BlurHash) pendant le chargement des photos et des portraits
Les photos partent en taille réelle, sans miniature : sur un réseau lent, une vignette restait un carré
vide et un portrait montrait ses initiales. Chaque photo et chaque portrait a maintenant un aperçu flou
aux bonnes couleurs (une chaîne de 28 caractères), affiché jusqu'à l'arrivée de l'image, sans fondu.

- Base : media_items.blurhash et persons.avatarBlurhash (migration additive 20261027_001).
- API : calculé à l'envoi d'une photo et d'un portrait (sharp, 32 px), publié dans le graphe ; les autres
  chemins (Immich, import GEDCOM, propositions acceptées) et les photos d'avant sont rattrapés en tâche de
  fond (lots de 25, puis une passe par minute). Un fichier illisible reçoit un aperçu vide.
- Navigateur : vignettes de la fiche, de la frise et du gestionnaire de souvenirs, portrait de la fiche,
  portraits et souvenirs dessinés sur l'arbre.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 16:17:58 +01:00

236 lines
8.1 KiB
TypeScript

import { createHmac } from 'node:crypto'
import Fastify, { type FastifyError } from 'fastify'
import cookie from '@fastify/cookie'
import cors from '@fastify/cors'
import jwt from '@fastify/jwt'
import rateLimit from '@fastify/rate-limit'
import { Prisma } from './generated/prisma/client.js'
import { env } from './config/env.js'
import { prisma } from './lib/prisma.js'
import { isUserTokenRevoked, loadRevokedTokens } from './lib/token-revocation.js'
import { registerIdempotency } from './lib/idempotency.js'
import { startBlurhashFiller } from './lib/blurhash.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 { managerRoutes } from './routes/managers.js'
import { searchSourceRoutes } from './routes/search-sources.js'
import { getStorageRoot, mediaRoutes } from './routes/media.js'
import { oidcRoutes } from './routes/oidc.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), le retour OIDC un code
// d'autorisation : jamais dans les journaux
export function redactUrl(url: string | undefined): string | undefined {
return url?.replace(/([?&](?:token|code)=)[^&#]*/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)
// Les déconnexions d'avant le redémarrage reviennent en mémoire avant la première requête (onReady passe avant
// listen). Sans elles, l'API ne démarre pas : chaque session fermée redeviendrait valable sans que personne le
// voie, et pm2 comme Docker relancent l'API.
app.addHook('onReady', async () => {
await loadRevokedTokens(app.prisma)
})
// Aperçus flous des photos et portraits qui n'en ont pas encore (lib/blurhash.ts) ; pas pendant les tests
if (env.NODE_ENV !== 'test') app.addHook('onReady', async () => startBlurhashFiller(app, getStorageRoot()))
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,
})
// Cookie signé du parcours OIDC seulement (state, nonce, PKCE entre /auth/oidc/start et le retour) : les sessions
// restent des jetons Bearer
// Clé propre au cookie, dérivée de JWT_SECRET : une signature de cookie ne vaut jamais signature de jeton
app.register(cookie, { secret: createHmac('sha256', env.JWT_SECRET).update('parenthese-oidc-cookie').digest('hex') })
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 }))
// Après l'identification : une clé d'idempotence ne vaut que pour la personne qui l'a envoyée
registerIdempotency(app)
app.register(authRoutes, { prefix: '/auth' })
app.register(oidcRoutes, { prefix: '/auth/oidc' })
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.register(managerRoutes)
app.register(searchSourceRoutes)
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
}