GEDCOM et contributions : plus de blocage de l'API, une seule validation

Un nom GEDCOM piégé bloquait l'API plusieurs secondes (expression régulière quadratique) : lu par indexOf, lignes et fiches plafonnées. L'export n'injecte plus de lignes. Deux relectures simultanées appliquaient deux fois les ajouts : la session est verrouillée dans la transaction. Au plus 200 changements par envoi, photos refusées effacées du disque.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
IliesAllaliandClaude Opus 5.5 committed 2026-09-30 17:57:27 +02:00
1 parent ced84333db
commit 5b06d020bd
2 files changed
+70 -18

No files matched your search

+29 -2
View File
@@ -1,6 +1,10 @@
import path from 'node:path'
import type { FastifyInstance, FastifyPluginAsync } from 'fastify'
import { Prisma } from '@prisma/client'
import { z } from 'zod'
import { env } from '../config/env.js'
import { removeStoredFile } from '../lib/media-files.js'
import { syncPersonMediaOrder } from '../lib/media-order.js'
import type { Actor, MembershipRole } from '../types/auth.js'
@@ -30,7 +34,8 @@ const createSessionSchema = z.object({
title: z.string().min(2).max(120),
submittedByLabel: z.string().trim().min(1).max(120).optional(),
comment: z.string().trim().max(500).optional().nullable(),
changes: z.array(contributionChangeSchema).min(1),
// Une famille propose quelques personnes à la fois ; au-delà, c'est un envoi en boucle
changes: z.array(contributionChangeSchema).min(1).max(200),
})
const listSessionsQuerySchema = z.object({
@@ -988,7 +993,7 @@ export async function resolveSubmissionContext(app: FastifyInstance, actor: Acto
}
export const contributionRoutes: FastifyPluginAsync = async (app) => {
app.post('/trees/:id/contributions/sessions', async (request, reply) => {
app.post('/trees/:id/contributions/sessions', { config: { rateLimit: { max: 30, timeWindow: '1 minute' } } }, async (request, reply) => {
const params = treeIdParamSchema.safeParse(request.params)
if (!params.success) {
return reply.code(400).send({ error: 'invalid_tree_id' })
@@ -1167,7 +1172,18 @@ export const contributionRoutes: FastifyPluginAsync = async (app) => {
explicitDecisions.set(changeDecision.changeId, changeDecision.decision)
}
const rejectedMediaIds: string[] = []
const { updatedSession, approvedCount, rejectedCount, conflictCount, status } = await app.prisma.$transaction(async (tx) => {
// Deux relectures simultanées : la seconde attend le verrou de la ligne, puis ne trouve plus
// la session en attente. Sans ce verrou, les deux appliquaient les mêmes ajouts.
const claimed = await tx.contributionSession.updateMany({
where: { id: session.id, status: 'pending' },
data: { reviewedBy: membership.userId },
})
if (claimed.count === 0) {
throw new ContributionRouteError(409, 'contribution_session_already_reviewed')
}
let approvedCount = 0
let rejectedCount = 0
let conflictCount = 0
@@ -1223,6 +1239,7 @@ export const contributionRoutes: FastifyPluginAsync = async (app) => {
where: { id: change.entityId, treeId: params.data.id },
data: { status: 'rejected', deletedAt: new Date() },
})
rejectedMediaIds.push(change.entityId)
}
rejectedCount += 1
}
@@ -1270,6 +1287,16 @@ export const contributionRoutes: FastifyPluginAsync = async (app) => {
return { updatedSession, approvedCount, rejectedCount, conflictCount, status }
}, { timeout: 30_000 })
// Photo refusée = effacée du disque, une fois la relecture enregistrée
if (rejectedMediaIds.length > 0) {
const rejected = await app.prisma.mediaItem.findMany({
where: { id: { in: rejectedMediaIds }, treeId: params.data.id },
select: { filePath: true },
})
const storageRoot = path.resolve(env.MEDIA_STORAGE_PATH)
await Promise.all(rejected.map((media) => removeStoredFile(storageRoot, media.filePath)))
}
return reply.send({
session: updatedSession,
approvedCount,
+41 -16
View File
@@ -11,6 +11,23 @@ const importBodySchema = z.object({
content: z.string().min(1).max(10 * 1024 * 1024),
})
// Un fichier GEDCOM vient de l'extérieur : longueur des lignes, nombre de fiches et taille des champs
// plafonnés comme partout ailleurs (sinon un seul fichier bloque l'API ou remplit la base)
const MAX_LINE_LENGTH = 4096
const MAX_IMPORTED_PERSONS = 20000
const MAX_IMPORTED_FAMILIES = 20000
function clip(value: string | null, max: number): string | null {
if (value === null) return null
const trimmed = value.trim()
return trimmed ? trimmed.slice(0, max) : null
}
// Export : un champ sur une ligne ne doit jamais en créer une autre (enregistrement injecté)
function oneLine(value: string | null | undefined): string {
return String(value ?? '').replace(/[\r\n]+/g, ' ')
}
function hasAdminRight(role: MembershipRole): boolean {
return role === 'owner' || role === 'admin'
}
@@ -26,13 +43,13 @@ type GedcomNode = {
}
function parseGedcom(text: string): GedcomNode[] {
const lines = text.split(/\r?\n/)
const lines = text.split(/\r\n|\r|\n/)
const roots: GedcomNode[] = []
const stack: GedcomNode[] = []
for (const raw of lines) {
const line = raw.trim()
if (!line) continue
if (!line || line.length > MAX_LINE_LENGTH) continue
const match = line.match(/^(\d+)\s+(?:(@[^@]+@)\s+)?(\w+)(?:\s+(.*))?$/)
if (!match) continue
@@ -131,12 +148,16 @@ function isoToGedcomDate(value: string | Date | null): string | null {
// ─── Name Parsing ────────────────────────────────────────────────────────────
// « Jean /Dupont/ » : lu par indexOf, l'ancienne expression régulière était quadratique sur les espaces
function parseGedcomName(value: string): { firstName: string; lastName: string } {
const slashMatch = value.match(/^(.*?)\s*\/([^/]*)\/(.*)?$/)
if (slashMatch) {
const open = value.indexOf('/')
const close = open >= 0 ? value.indexOf('/', open + 1) : -1
if (open >= 0 && close > open) {
const before = value.slice(0, open).trim()
const after = value.slice(close + 1).trim()
return {
firstName: (slashMatch[1].trim() || slashMatch[3]?.trim() || '').trim(),
lastName: slashMatch[2].trim(),
firstName: before || after,
lastName: value.slice(open + 1, close).trim(),
}
}
return { firstName: value.trim(), lastName: '' }
@@ -297,7 +318,7 @@ export const gedcomRoutes: FastifyPluginAsync = async (app) => {
for (const person of persons) {
const xref = sanitizeXref(person.id)
lines.push(`0 @I${xref}@ INDI`)
lines.push(`1 NAME ${person.firstName} /${person.lastName}/`)
lines.push(`1 NAME ${oneLine(person.firstName)} /${oneLine(person.lastName).replace(/\//g, '')}/`)
if (person.sex === 'male' || person.sex === 'M') lines.push('1 SEX M')
else if (person.sex === 'female' || person.sex === 'F') lines.push('1 SEX F')
@@ -306,7 +327,7 @@ export const gedcomRoutes: FastifyPluginAsync = async (app) => {
if (birthDate || person.birthPlace) {
lines.push('1 BIRT')
if (birthDate) lines.push(`2 DATE ${birthDate}`)
if (person.birthPlace) lines.push(`2 PLAC ${person.birthPlace}`)
if (person.birthPlace) lines.push(`2 PLAC ${oneLine(person.birthPlace)}`)
}
const deathDate = isoToGedcomDate(person.deathDate)
@@ -315,10 +336,10 @@ export const gedcomRoutes: FastifyPluginAsync = async (app) => {
lines.push(`2 DATE ${deathDate}`)
}
if (person.profession) lines.push(`1 OCCU ${person.profession}`)
if (person.profession) lines.push(`1 OCCU ${oneLine(person.profession)}`)
if (person.notes) {
const noteLines = person.notes.split('\n')
const noteLines = person.notes.split(/\r\n|\r|\n/)
lines.push(`1 NOTE ${noteLines[0]}`)
for (let i = 1; i < noteLines.length; i++) {
lines.push(`2 CONT ${noteLines[i]}`)
@@ -378,7 +399,7 @@ export const gedcomRoutes: FastifyPluginAsync = async (app) => {
})
// Import
app.post('/trees/:id/import/gedcom', async (request, reply) => {
app.post('/trees/:id/import/gedcom', { config: { rateLimit: { max: 5, timeWindow: '1 minute' } } }, async (request, reply) => {
const params = treeIdParamSchema.safeParse(request.params)
if (!params.success) {
return reply.code(400).send({ error: 'invalid_tree_id' })
@@ -414,6 +435,10 @@ export const gedcomRoutes: FastifyPluginAsync = async (app) => {
const indiNodes = roots.filter((node) => node.tag === 'INDI' && node.xref)
const famNodes = roots.filter((node) => node.tag === 'FAM' && node.xref)
if (indiNodes.length > MAX_IMPORTED_PERSONS || famNodes.length > MAX_IMPORTED_FAMILIES) {
return reply.code(413).send({ error: 'gedcom_too_large' })
}
const xrefToPersonId = new Map<string, string>()
let personsCreated = 0
let unionsCreated = 0
@@ -431,25 +456,25 @@ export const gedcomRoutes: FastifyPluginAsync = async (app) => {
const birtNode = findChild(indi, 'BIRT')
const birthDateIso = gedcomDateToIso(findChild(birtNode, 'DATE')?.value ?? '')
const birthPlace = findChild(birtNode, 'PLAC')?.value?.trim() || null
const birthPlace = clip(findChild(birtNode, 'PLAC')?.value ?? null, 200)
const deatNode = findChild(indi, 'DEAT')
const deathDateIso = gedcomDateToIso(findChild(deatNode, 'DATE')?.value ?? '')
const profession = findChild(indi, 'OCCU')?.value?.trim() || null
const profession = clip(findChild(indi, 'OCCU')?.value ?? null, 200)
const noteNode = findChild(indi, 'NOTE')
let notes: string | null = null
if (noteNode?.value) {
const contLines = findAllChildren(noteNode, 'CONT').map((n) => n.value)
notes = [noteNode.value, ...contLines].join('\n').trim() || null
notes = clip([noteNode.value, ...contLines].join('\n'), 2000)
}
const person = await tx.person.create({
data: {
treeId,
firstName: firstName || '?',
lastName: lastName || '',
firstName: (firstName || '?').slice(0, 120),
lastName: (lastName || '').slice(0, 120),
sex,
birthDate: isoDateToDbDate(birthDateIso),
deathDate: isoDateToDbDate(deathDateIso),