diff --git a/backend/src/routes/contributions.ts b/backend/src/routes/contributions.ts index f3f9a00..2635c30 100644 --- a/backend/src/routes/contributions.ts +++ b/backend/src/routes/contributions.ts @@ -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, diff --git a/backend/src/routes/gedcom.ts b/backend/src/routes/gedcom.ts index 8f52df6..6769554 100644 --- a/backend/src/routes/gedcom.ts +++ b/backend/src/routes/gedcom.ts @@ -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() 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),