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>
229 lines
7.7 KiB
TypeScript
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
|
|
}
|