Files
parenthese/backend/src/app.ts
T
IliesAllaliandClaude Opus 5.5 cc01059c47 Prisma 7 : pilote pg, client généré, prisma.config.ts (#164)
* 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>
2026-10-26 13:58:42 +01:00

229 lines
7.7 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 { 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 { 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)
})
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 }))
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
}