diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 75a3631..59eac7e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -182,6 +182,13 @@ jobs: - name: Test de fumée par le port web run: bash tools/docker-smoke.sh http://localhost:8080 + # Les commandes de sauvegarde et de restauration du README, jouées pour de vrai + - name: Sauvegarde puis restauration + run: | + set -euo pipefail + bash tools/docker-restore-check.sh http://localhost:8080 + bash tools/docker-smoke.sh http://localhost:8080 + # Un second front qui joint l'API par le nom de son conteneur (API_UPSTREAM), comme sur Umbrel. - name: Front avec une autre adresse d'API run: | @@ -233,6 +240,9 @@ jobs: docker restart aio bash tools/docker-smoke.sh http://localhost:8080 docker logs aio 2>&1 | grep -c "première installation" | grep -qx 1 + # Base à jour au redémarrage : pas de sauvegarde avant migration, mais pg_dump marche dans l'image + [ -z "$(docker exec aio ls -A /data/backups 2>/dev/null)" ] + [ "$(docker exec aio runuser -u postgres -- pg_dump -Fc parenthese | wc -c)" -gt 1000 ] - name: Journaux en cas d'échec if: failure() diff --git a/README.fr.md b/README.fr.md index 910b661..8533931 100644 --- a/README.fr.md +++ b/README.fr.md @@ -99,7 +99,7 @@ docker run --rm -v parenthese_media:/data -v "$PWD":/backup alpine tar -czf /bac La première commande sauvegarde la base. Elle contient les personnes, leurs liens, les comptes et les contributions, et elle sait à qui appartient chaque photo, vidéo ou enregistrement. La seconde sauvegarde les fichiers eux-mêmes. Il faut les deux. Restaurez la base seule et les souvenirs pointent vers des fichiers disparus. Restaurez l'archive seule et vous avez un dossier de fichiers rattachés à personne. L'export GEDCOM n'est pas une sauvegarde non plus. Il sert à emmener l'arbre vers un autre logiciel de généalogie et laisse de côté les médias et les annotations. -Adaptez `-U parenthese` et le nom de base si vous avez changé `POSTGRES_USER` ou `POSTGRES_DB`. Pour restaurer la base : `docker compose exec -T db pg_restore -U parenthese -d parenthese --clean < parenthese-db-AAAA-MM-JJ.dump`, puis décompressez l'archive des médias dans le volume avec la même commande `docker run`, `tar -xzf` à la place de `-czf`. +Adaptez `-U parenthese` et le nom de base si vous avez changé `POSTGRES_USER` ou `POSTGRES_DB`. Pour restaurer la base : `docker compose exec -T db pg_restore -U parenthese -d parenthese --clean --if-exists < parenthese-db-AAAA-MM-JJ.dump`, puis décompressez l'archive des médias dans le volume avec la même commande `docker run`, `tar -xzf` à la place de `-czf`. Le script `deploy/backup.sh` fait la même chose (dump PostgreSQL, archive des médias, sommes de contrôle, rotation, envoi S3 optionnel) pour une installation sans Docker : il lit `backend/.env` et a besoin de `pg_dump` sur la machine. Ses réglages passent par les variables `BACKUP_ROOT`, `BACKUP_RETENTION_DAYS` et `BACKUP_S3_BUCKET`. @@ -111,7 +111,7 @@ docker compose pull && docker compose up -d `latest` suit la branche main. Pour rester sur une version, renseignez `PARENTHESE_VERSION` dans `.env` (par exemple `1.0`). Depuis un clone, `git pull && docker compose up -d --build` reconstruit depuis les sources. -Les migrations de base de données s'appliquent toutes seules au démarrage de l'API. Faites une sauvegarde avant, par habitude. +Les migrations de base de données s'appliquent toutes seules au démarrage de l'API, et une migration ne se défait pas. Lancez les deux commandes de sauvegarde ci-dessus avant chaque mise à jour. L'image tout-en-un le fait seule : avant d'appliquer une migration, elle copie la base dans `/data/backups` et garde les cinq dernières copies. ## Développer diff --git a/README.md b/README.md index 1d428d2..707f57a 100644 --- a/README.md +++ b/README.md @@ -105,7 +105,7 @@ docker run --rm -v parenthese_media:/data -v "$PWD":/backup alpine tar -czf /bac The first command saves the database. It holds the people, their links, the accounts and the contributions, plus the record of which photo, video or recording belongs to whom. The second saves the files themselves. You need both. Restore only the database and the memories point to files that are gone. Restore only the archive and you get a folder of files with no names attached. The GEDCOM export is not a backup either. It moves the tree to another genealogy program and leaves the media and the annotations behind. -Adjust `-U parenthese` and the database name if you changed `POSTGRES_USER` or `POSTGRES_DB`. To restore the database, run `docker compose exec -T db pg_restore -U parenthese -d parenthese --clean < parenthese-db-YYYY-MM-DD.dump`, then extract the media archive into the volume with the same `docker run` command, using `tar -xzf` instead of `-czf`. +Adjust `-U parenthese` and the database name if you changed `POSTGRES_USER` or `POSTGRES_DB`. To restore the database, run `docker compose exec -T db pg_restore -U parenthese -d parenthese --clean --if-exists < parenthese-db-YYYY-MM-DD.dump`, then extract the media archive into the volume with the same `docker run` command, using `tar -xzf` instead of `-czf`. The `deploy/backup.sh` script does the same (PostgreSQL dump, media archive, checksums, rotation, optional S3 upload) for an installation without Docker. It reads `backend/.env` and needs `pg_dump` on the machine. It is configured through the `BACKUP_ROOT`, `BACKUP_RETENTION_DAYS` and `BACKUP_S3_BUCKET` variables. @@ -117,7 +117,7 @@ docker compose pull && docker compose up -d `latest` follows the main branch. To stay on a version, set `PARENTHESE_VERSION` in `.env` (for example `1.0`). From a clone, `git pull && docker compose up -d --build` builds from the source instead. -Database migrations apply on their own when the API starts. Make a backup first, as a habit. +Database migrations apply on their own when the API starts, and a migration cannot be undone. Run the two backup commands above before each update. The all-in-one image does it by itself: before applying a migration, it copies the database to `/data/backups` and keeps the last five copies. ## Development diff --git a/backend/src/lib/gedcom-photos.ts b/backend/src/lib/gedcom-photos.ts index d790a82..9388577 100644 --- a/backend/src/lib/gedcom-photos.ts +++ b/backend/src/lib/gedcom-photos.ts @@ -148,10 +148,51 @@ export function plainNoteText(raw: string): string { return text.replace(/[ \t]+/g, ' ').replace(/\s*\n\s*/g, '\n').trim() } +// Une note ou une source partagée est citée par des milliers de fiches : son texte se calcule une fois +// par import (les nœuds sont neufs à chaque import, la WeakMap se vide avec eux) +const noteTextCache = new WeakMap() +const recordTitleCache = new WeakMap() +const hasMediaCache = new WeakMap() +const MAX_RECORD_DEPTH = 200 +const MAX_WALKED_NODES_PER_PERSON = 5000 + export function noteText(node: GedcomPhotoNode | undefined, records: GedcomRecords): string { if (!node) return '' const pointer = pointerOf(node.value) - return plainNoteText(joinedText(pointer ? records.note.get(pointer) : node)) + const target = pointer ? records.note.get(pointer) : node + if (!target) return '' + let text = noteTextCache.get(target) + if (text === undefined) { + text = plainNoteText(joinedText(target)) + noteTextCache.set(target, text) + } + return text +} + +function recordTitle(record: GedcomPhotoNode): string { + let title = recordTitleCache.get(record) + if (title === undefined) { + title = joinedText(child(record, 'TITL')) || joinedText(child(record, 'ABBR')) + recordTitleCache.set(record, title) + } + return title +} + +// Un nœud mène-t-il à un fichier (OBJE), directement ou par une source ou une union citée ? +// Un renvoi en boucle compte pour non pendant le calcul. +function hasMedia(node: GedcomPhotoNode, records: GedcomRecords, depth = 0): boolean { + const known = hasMediaCache.get(node) + if (known !== undefined) return known + if (depth > MAX_RECORD_DEPTH) return false + hasMediaCache.set(node, false) + let found = false + for (const c of node.children) { + const pointer = pointerOf(c.value) + const linked = !pointer ? undefined : c.tag === 'SOUR' ? records.sour.get(pointer) : c.tag === 'FAMS' ? records.fam.get(pointer) : undefined + if (c.tag === 'OBJE' || hasMedia(c, records, depth + 1) || (linked && hasMedia(linked, records, depth + 1))) { found = true; break } + } + hasMediaCache.set(node, found) + return found } function clipText(value: string, max: number): string | null { @@ -170,7 +211,7 @@ function baseNameWithoutExtension(path: string): string { export function sourceLabel(citation: GedcomPhotoNode, records: GedcomRecords): string | null { const pointer = pointerOf(citation.value) const record = pointer ? records.sour.get(pointer) : null - const title = joinedText(child(record ?? undefined, 'TITL')) || joinedText(child(record ?? undefined, 'ABBR')) || (pointer ? '' : citation.value) + const title = (record ? recordTitle(record) : '') || (pointer ? '' : citation.value) const page = joinedText(child(citation, 'PAGE')) return clipText([title, page].filter(Boolean).join(', '), MAX_SOURCE_LENGTH) } @@ -203,32 +244,37 @@ export function mediaRefsForPerson(indi: GedcomPhotoNode, records: GedcomRecords } } - const walk = (node: GedcomPhotoNode, source: string | null) => { + let budget = MAX_WALKED_NODES_PER_PERSON + const walk = (node: GedcomPhotoNode, source: string | null, depth: number) => { + if (depth > MAX_RECORD_DEPTH) return for (const c of node.children) { + if (--budget < 0 || refs.length >= MAX_MEDIA_PER_PERSON) return if (c.tag === 'OBJE') { addObje(c, source) } else if (c.tag === 'SOUR') { - const label = sourceLabel(c, records) ?? source - walk(c, label) const pointer = pointerOf(c.value) const record = pointer ? records.sour.get(pointer) : null - if (record && !visitedRecords.has(record)) { - visitedRecords.add(record) - walk(record, label) + const recordToWalk = record && !visitedRecords.has(record) && hasMedia(record, records) ? record : null + if (!hasMedia(c, records) && !recordToWalk) continue + const label = sourceLabel(c, records) ?? source + walk(c, label, depth + 1) + if (recordToWalk) { + visitedRecords.add(recordToWalk) + walk(recordToWalk, label, depth + 1) } } else if (c.tag === 'FAMS') { const pointer = pointerOf(c.value) const fam = pointer ? records.fam.get(pointer) : null - if (fam && !visitedRecords.has(fam)) { + if (fam && !visitedRecords.has(fam) && hasMedia(fam, records)) { visitedRecords.add(fam) - walk({ ...fam, children: fam.children.filter((f) => !['HUSB', 'WIFE', 'CHIL'].includes(f.tag)) }, source) + walk({ ...fam, children: fam.children.filter((f) => !['HUSB', 'WIFE', 'CHIL'].includes(f.tag)) }, source, depth + 1) } - } else if (!pointerOf(c.value) && c.children.length > 0) { - walk(c, source) + } else if (!pointerOf(c.value) && c.children.length > 0 && hasMedia(c, records)) { + walk(c, source, depth + 1) } } } - walk(indi, null) + walk(indi, null, 0) return refs } diff --git a/backend/src/lib/tree-visits.ts b/backend/src/lib/tree-visits.ts index e2a1098..84dd2d7 100644 --- a/backend/src/lib/tree-visits.ts +++ b/backend/src/lib/tree-visits.ts @@ -6,8 +6,11 @@ import { env } from '../config/env.js' // Une ouverture d'arbre = une visite. Les rechargements du même visiteur dans cette fenêtre // (graphe rechargé après une modification, onglet rouvert) ne comptent qu'une fois. export const VISIT_DEDUP_WINDOW_MS = 30 * 60 * 1000 -// Au-delà, les visites sont effacées (lecture de l'onglet Visites) +// Au-delà, les visites sont effacées : à l'enregistrement d'une visite (au plus une fois par heure, +// pour tous les arbres) et à la lecture de l'onglet Visites export const VISIT_RETENTION_DAYS = 365 +const PURGE_INTERVAL_MS = 60 * 60 * 1000 +let lastPurgeAt = 0 export type VisitAccessKind = 'share' | 'member' | 'shared_account' @@ -34,6 +37,18 @@ export function visitorKeyOf(treeId: string, ip: string, userAgent: string): str return createHash('sha256').update(`${env.JWT_SECRET}|${treeId}|${ip}|${userAgent}`).digest('hex').slice(0, 32) } +// Compte supprimé : ses visites restent comptées, sans son identifiant +export function deletedAccountVisitorKey(userId: string): string { + return createHash('sha256').update(`${env.JWT_SECRET}|deleted|${userId}`).digest('hex').slice(0, 32) +} + +async function purgeExpiredVisits(app: FastifyInstance): Promise { + const now = Date.now() + if (now - lastPurgeAt < PURGE_INTERVAL_MS) return + lastPurgeAt = now + await app.prisma.treeVisit.deleteMany({ where: { createdAt: { lt: new Date(now - VISIT_RETENTION_DAYS * 24 * 60 * 60 * 1000) } } }) +} + // N'échoue jamais : un journal qui casse ne doit pas empêcher d'ouvrir l'arbre export async function recordTreeVisit( app: FastifyInstance, @@ -69,6 +84,7 @@ export async function recordTreeVisit( device: deviceFromUserAgent(userAgent), }, }) + await purgeExpiredVisits(app) } catch (error) { request.log.warn({ err: error, treeId }, 'tree_visit_record_failed') } diff --git a/backend/src/routes/annotations.ts b/backend/src/routes/annotations.ts index 8429a91..21f547d 100644 --- a/backend/src/routes/annotations.ts +++ b/backend/src/routes/annotations.ts @@ -306,64 +306,67 @@ export const annotationRoutes: FastifyPluginAsync = async (app) => { return reply.code(400).send({ error: 'invalid_annotation_photo_path' }) } - // Creates - for (const [index, item] of payload.data.create.entries()) { - await app.prisma.annotation.create({ + // Créations, modifications, suppressions et trace : tout ou rien, un nouvel essai ne double rien + await app.prisma.$transaction(async (tx) => { + // Creates + for (const [index, item] of payload.data.create.entries()) { + await tx.annotation.create({ + data: { + treeId, + type: item.type, + x: item.x, + y: item.y, + content: createContents[index] as string, + style: (item.style ?? {}) as Prisma.InputJsonValue, + zIndex: item.zIndex ?? 0, + createdBy: user.userId, + }, + }) + results.created++ + } + + // Updates + for (const [index, item] of payload.data.update.entries()) { + const data: Record = {} + if (item.x !== undefined) data.x = item.x + if (item.y !== undefined) data.y = item.y + if (item.content !== undefined) data.content = updateContents[index] + if (item.style !== undefined) data.style = item.style + if (item.zIndex !== undefined) data.zIndex = item.zIndex + + if (Object.keys(data).length > 0) { + const updated = await tx.annotation.updateMany({ + where: { id: item.id, treeId, deletedAt: null }, + data, + }) + if (updated.count > 0) results.updated++ + } + } + + // Deletes + if (payload.data.delete.length > 0) { + const deleted = await tx.annotation.updateMany({ + where: { + id: { in: payload.data.delete }, + treeId, + deletedAt: null, + }, + data: { deletedAt: new Date() }, + }) + results.deleted = deleted.count + } + + await tx.auditLog.create({ data: { treeId, - type: item.type, - x: item.x, - y: item.y, - content: createContents[index] as string, - style: (item.style ?? {}) as Prisma.InputJsonValue, - zIndex: item.zIndex ?? 0, - createdBy: user.userId, + actorType: 'user', + actorId: user.userId, + action: 'annotations_batch', + entityType: 'annotation', + entityId: treeId, + payloadJson: results, }, }) - results.created++ - } - - // Updates - for (const [index, item] of payload.data.update.entries()) { - const data: Record = {} - if (item.x !== undefined) data.x = item.x - if (item.y !== undefined) data.y = item.y - if (item.content !== undefined) data.content = updateContents[index] - if (item.style !== undefined) data.style = item.style - if (item.zIndex !== undefined) data.zIndex = item.zIndex - - if (Object.keys(data).length > 0) { - const updated = await app.prisma.annotation.updateMany({ - where: { id: item.id, treeId, deletedAt: null }, - data, - }) - if (updated.count > 0) results.updated++ - } - } - - // Deletes - if (payload.data.delete.length > 0) { - const deleted = await app.prisma.annotation.updateMany({ - where: { - id: { in: payload.data.delete }, - treeId, - deletedAt: null, - }, - data: { deletedAt: new Date() }, - }) - results.deleted = deleted.count - } - - await app.prisma.auditLog.create({ - data: { - treeId, - actorType: 'user', - actorId: user.userId, - action: 'annotations_batch', - entityType: 'annotation', - entityId: treeId, - payloadJson: results, - }, }) return reply.send({ ok: true, ...results }) diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index 5ede013..8b0adeb 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -5,6 +5,7 @@ import { z } from 'zod' import { env } from '../config/env.js' import { hashPassword, verifyPassword } from '../lib/auth.js' import { revokeUserToken } from '../lib/token-revocation.js' +import { deletedAccountVisitorKey } from '../lib/tree-visits.js' import { deleteTreeRecords, removeTreeMediaDirectories } from '../lib/tree-purge.js' import type { AnyJwtPayload } from '../types/auth.js' @@ -381,6 +382,12 @@ export const authRoutes: FastifyPluginAsync = async (app) => { data: { submittedByLabel: 'Contributeur' }, }) + // Ses visites des autres arbres restent comptées, sans son identifiant + await tx.treeVisit.updateMany({ + where: { visitorKey: `user:${userId}` }, + data: { visitorKey: deletedAccountVisitorKey(userId) }, + }) + await tx.user.delete({ where: { id: userId } }) return treeIds }) diff --git a/backend/src/routes/gedcom.ts b/backend/src/routes/gedcom.ts index 5fe070c..3419d8f 100644 --- a/backend/src/routes/gedcom.ts +++ b/backend/src/routes/gedcom.ts @@ -4,7 +4,7 @@ import { z } from 'zod' import { UPLOAD_BODY_LIMIT } from '../config/env.js' import { parseGedcomDate } from '../lib/gedcom-dates.js' import { adoptedFamilies, FAMILY_EVENT_TYPES, familyExtras, PERSON_EVENT_TYPES, personExtras } from '../lib/gedcom-events.js' -import { indexGedcomRecords, mediaRefsForPerson, photoPathForPerson, plainNoteText } from '../lib/gedcom-photos.js' +import { indexGedcomRecords, mediaRefsForPerson, noteText, photoPathForPerson } from '../lib/gedcom-photos.js' import type { GedcomRecords } from '../lib/gedcom-photos.js' import type { MembershipRole } from '../types/auth.js' import { nextSiblingOrder } from '../lib/sibling-order.js' @@ -20,6 +20,9 @@ const importBodySchema = z.object({ // 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_GEDCOM_LEVEL = 99 +// Remontées d'ascendance permises sur tout l'import : au-delà, les liens ne sont plus contrôlés (fichier hors norme) +const MAX_ANCESTRY_STEPS = 5_000_000 const MAX_IMPORTED_PERSONS = 20000 const MAX_IMPORTED_FAMILIES = 20000 // Le parseur construit un objet par ligne : 10 Mo de lignes courtes montaient à 370 Mo de mémoire, et le @@ -94,6 +97,8 @@ export function parseGedcom(text: string): GedcomNode[] { if (!match) continue const level = parseInt(match[1], 10) + // La norme s'arrête à 99 niveaux : au-delà, la lecture récursive de l'import déborderait la pile + if (level > MAX_GEDCOM_LEVEL) continue const xref = match[2] ? match[2].slice(1, -1) : undefined const tag = match[3] const rawValue = match[4] ?? '' @@ -137,19 +142,7 @@ function gedcomDateToIso(value: string): string | null { // Notes d'une personne : écrites sous elle ou partagées (« 1 NOTE @N1@ »), lignes recollées (CONT, CONC), // RTF de Heredis Mac nettoyé. Plusieurs notes sont séparées par une ligne vide. export function personNotes(indi: GedcomNode, records: GedcomRecords): string | null { - const texts: string[] = [] - for (const note of findAllChildren(indi, 'NOTE')) { - const pointer = note.value.match(/^@([^@]+)@$/)?.[1] - const source = pointer ? records.note.get(pointer) : note - if (!source) continue - let text = (pointer ? source.rawValue ?? source.value : note.rawValue ?? note.value) - for (const part of source.children) { - if (part.tag === 'CONT') text += `\n${part.rawValue ?? part.value}` - else if (part.tag === 'CONC') text += part.rawValue ?? part.value - } - const clean = plainNoteText(text) - if (clean) texts.push(clean) - } + const texts = findAllChildren(indi, 'NOTE').map((note) => noteText(note, records)).filter(Boolean) return texts.length ? texts.join('\n\n') : null } @@ -645,12 +638,30 @@ export const gedcomRoutes: FastifyPluginAsync = async (app) => { } const unionByPair = new Map(existingUnions.map((u) => [pairKey(u.partner1PersonId, u.partner2PersonId), u.id])) const linkKeys = new Set(existingLinks.map((l) => `${l.parentPersonId}:${l.childPersonId}`)) + // Parents de chaque personne, pour refuser un lien qui ferait de quelqu'un son propre ancêtre + // (les validateurs de l'API relisent tout l'arbre à chaque lien : trop lent pour un import) + const parentsOf = new Map() + for (const link of existingLinks) parentsOf.set(link.childPersonId, [...(parentsOf.get(link.childPersonId) ?? []), link.parentPersonId]) + let ancestryBudget = MAX_ANCESTRY_STEPS + const isAncestorOf = (candidate: string, personId: string): boolean => { + const seen = new Set() + const stack = [personId] + while (stack.length) { + const current = stack.pop()! + if (current === candidate) return true + if (seen.has(current) || --ancestryBudget < 0) continue + seen.add(current) + stack.push(...(parentsOf.get(current) ?? [])) + } + return false + } let personsCreated = 0 let personsReused = 0 let unionsCreated = 0 let linksCreated = 0 let eventsCreated = 0 + let relationsSkipped = 0 // Adoptions déclarées sous l'enfant (« 2 PEDI adopted ») : enfant → familles où il est adopté const adoptedByChild = new Map(indiNodes.map((indi) => [indi.xref!, adoptedFamilies(indi)])) @@ -675,8 +686,6 @@ export const gedcomRoutes: FastifyPluginAsync = async (app) => { const profession = clip(findChild(indi, 'OCCU')?.value ?? null, 200) - const notes = clip(personNotes(indi, records), 2000) - // Prénom absent du fichier : chaîne vide, comme un prénom inconnu dans l'arbre const personFirstName = (firstName || '').slice(0, 120) const personLastName = (lastName || '').slice(0, 120) @@ -693,6 +702,7 @@ export const gedcomRoutes: FastifyPluginAsync = async (app) => { continue } + const notes = clip(personNotes(indi, records), 2000) const { events, ...extras } = personExtras(indi, records) const person = await tx.person.create({ data: { @@ -720,7 +730,8 @@ export const gedcomRoutes: FastifyPluginAsync = async (app) => { xrefToPersonId.set(indi.xref!, person.id) personsCreated++ if (photoPath) photoRefs.push({ personId: person.id, file: photoPath }) - for (const ref of mediaRefsForPerson(indi, records, photoPath)) { + const personMedia = mediaRefs.length < MAX_IMPORTED_MEDIA_REFS ? mediaRefsForPerson(indi, records, photoPath) : [] + for (const ref of personMedia) { if (mediaRefs.length >= MAX_IMPORTED_MEDIA_REFS) break mediaRefs.push({ personId: person.id, ...ref }) } @@ -731,7 +742,12 @@ export const gedcomRoutes: FastifyPluginAsync = async (app) => { const wifeXref = findChild(fam, 'WIFE')?.value?.replace(/@/g, '').trim() const partner1Id = husbXref ? xrefToPersonId.get(husbXref) : undefined - const partner2Id = wifeXref ? xrefToPersonId.get(wifeXref) : undefined + let partner2Id = wifeXref ? xrefToPersonId.get(wifeXref) : undefined + // Une même personne des deux côtés d'une union : un seul partenaire + if (partner2Id && partner2Id === partner1Id) { + partner2Id = undefined + relationsSkipped++ + } if (!partner1Id && !partner2Id) continue @@ -777,6 +793,10 @@ export const gedcomRoutes: FastifyPluginAsync = async (app) => { for (const parentId of parentIds) { const linkKey = `${parentId}:${childId}` if (linkKeys.has(linkKey)) continue + if (parentId === childId || isAncestorOf(childId, parentId)) { + relationsSkipped++ + continue + } await tx.parentChildLink.create({ data: { treeId, @@ -788,6 +808,7 @@ export const gedcomRoutes: FastifyPluginAsync = async (app) => { }, }) linkKeys.add(linkKey) + parentsOf.set(childId, [...(parentsOf.get(childId) ?? []), parentId]) linksCreated++ } } @@ -801,7 +822,7 @@ export const gedcomRoutes: FastifyPluginAsync = async (app) => { action: 'gedcom_imported', entityType: 'tree', entityId: treeId, - payloadJson: { personsCreated, personsReused, unionsCreated, linksCreated, eventsCreated, photoRefs: photoRefs.length, mediaRefs: mediaRefs.length }, + payloadJson: { personsCreated, personsReused, unionsCreated, linksCreated, eventsCreated, relationsSkipped, photoRefs: photoRefs.length, mediaRefs: mediaRefs.length }, }, }) }, { timeout: 60000 }) @@ -811,6 +832,6 @@ export const gedcomRoutes: FastifyPluginAsync = async (app) => { // photoRefs : chemin de la photo que le logiciel d'origine associait à chaque personne ; mediaRefs : les // autres fichiers cités, avec leur titre et leur source (rien n'est gardé en base) - return reply.send({ personsCreated, personsReused, unionsCreated, linksCreated, eventsCreated, photoRefs, mediaRefs }) + return reply.send({ personsCreated, personsReused, unionsCreated, linksCreated, eventsCreated, relationsSkipped, photoRefs, mediaRefs }) }) } diff --git a/backend/src/routes/media.ts b/backend/src/routes/media.ts index 9d1bf4e..99cee28 100644 --- a/backend/src/routes/media.ts +++ b/backend/src/routes/media.ts @@ -795,35 +795,38 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => { return reply.code(400).send({ error: 'invalid_media_order_payload' }) } - for (let index = 0; index < orderedIds.length; index += 1) { - await app.prisma.mediaItem.updateMany({ - where: { - id: orderedIds[index], - treeId: params.data.id, - personId: params.data.personId, - deletedAt: null, - }, + const actorId = request.actor.userId + await app.prisma.$transaction(async (tx) => { + for (let index = 0; index < orderedIds.length; index += 1) { + await tx.mediaItem.updateMany({ + where: { + id: orderedIds[index], + treeId: params.data.id, + personId: params.data.personId, + deletedAt: null, + }, + data: { + displayOrder: index + 1, + }, + }) + } + + await syncPersonMediaOrder(tx, params.data.id, params.data.personId) + + await tx.auditLog.create({ data: { - displayOrder: index + 1, + treeId: params.data.id, + actorType: 'user', + actorId, + action: 'media_reordered', + entityType: 'media_item', + entityId: params.data.personId, + payloadJson: { + personId: params.data.personId, + orderedMediaIds: payload.data.orderedMediaIds, + }, }, }) - } - - await syncPersonMediaOrder(app.prisma, params.data.id, params.data.personId) - - await app.prisma.auditLog.create({ - data: { - treeId: params.data.id, - actorType: 'user', - actorId: request.actor.userId, - action: 'media_reordered', - entityType: 'media_item', - entityId: params.data.personId, - payloadJson: { - personId: params.data.personId, - orderedMediaIds: payload.data.orderedMediaIds, - }, - }, }) return reply.send({ ok: true }) diff --git a/backend/src/routes/trees.ts b/backend/src/routes/trees.ts index a549262..3ce83c1 100644 --- a/backend/src/routes/trees.ts +++ b/backend/src/routes/trees.ts @@ -2095,21 +2095,23 @@ export const treeRoutes: FastifyPluginAsync = async (app) => { return reply.send({ link }) }) - // Réordonner une fratrie : displayOrder des liens parent-enfant réécrit en une transaction - // Souvenirs rattachables : approuvés, d'une de ces personnes, dans cet arbre - const validLifeEventMedia = async (treeId: string, ownerIds: string[], mediaIds: string[] | undefined) => { + // Souvenirs rattachables : approuvés, d'une de ces personnes, dans cet arbre. La famille ne prend + // que des souvenirs libres : elle ne détache pas un souvenir du moment d'un autre. + const validLifeEventMedia = async (treeId: string, ownerIds: string[], mediaIds: string[] | undefined, onlyFree = false) => { if (!mediaIds?.length) return true const unique = [...new Set(mediaIds)] - const count = await app.prisma.mediaItem.count({ where: { id: { in: unique }, treeId, personId: { in: ownerIds }, status: 'approved', deletedAt: null } }) + const count = await app.prisma.mediaItem.count({ + where: { id: { in: unique }, treeId, personId: { in: ownerIds }, status: 'approved', deletedAt: null, ...(onlyFree ? { lifeEventId: null } : {}) }, + }) return count === unique.length } // Rattache exactement ces souvenirs au moment (et détache ceux qui ne sont plus dans la liste) - const setLifeEventMedia = async (treeId: string, eventId: string, mediaIds: string[] | undefined) => { + const setLifeEventMedia = async (db: Prisma.TransactionClient, treeId: string, eventId: string, mediaIds: string[] | undefined) => { if (mediaIds === undefined) return const unique = [...new Set(mediaIds)] - await app.prisma.mediaItem.updateMany({ where: { treeId, lifeEventId: eventId, id: { notIn: unique } }, data: { lifeEventId: null } }) - if (unique.length) await app.prisma.mediaItem.updateMany({ where: { treeId, id: { in: unique } }, data: { lifeEventId: eventId } }) + await db.mediaItem.updateMany({ where: { treeId, lifeEventId: eventId, id: { notIn: unique } }, data: { lifeEventId: null } }) + if (unique.length) await db.mediaItem.updateMany({ where: { treeId, id: { in: unique } }, data: { lifeEventId: eventId } }) } app.post('/trees/:id/persons/:personId/life/events', async (request, reply) => { @@ -2170,55 +2172,59 @@ export const treeRoutes: FastifyPluginAsync = async (app) => { }) if (existing) return reply.code(409).send({ error: 'event_exists', eventId: existing.id }) } - if (!(await validLifeEventMedia(params.data.id, ownerIds, payload.data.mediaIds))) { + if (!(await validLifeEventMedia(params.data.id, ownerIds, payload.data.mediaIds, !isAdmin))) { return reply.code(400).send({ error: 'invalid_media_reference' }) } const isPending = contribution?.autoStatus === 'pending' const title = payload.data.label?.trim() || payload.data.description?.trim() || payload.data.type - const session = contribution - ? await app.prisma.contributionSession.create({ + // Session, moment, souvenirs et trace de la contribution : tout ou rien + const event = await app.prisma.$transaction(async (tx) => { + const session = contribution + ? await tx.contributionSession.create({ + data: { + treeId: params.data.id, + submittedByUserId: contribution.submittedByUserId, + submittedByLabel: payload.data.submittedByLabel?.trim() || contribution.submittedByLabel, + mode: contribution.mode, + status: contribution.autoStatus, + title: `Moment pour ${person.firstName || person.lastName}`.slice(0, 120), + reviewedAt: isPending ? undefined : new Date(), + reviewedBy: isPending ? undefined : contribution.submittedByUserId, + }, + }) + : null + + const event = await tx.lifeEvent.create({ data: { treeId: params.data.id, - submittedByUserId: contribution.submittedByUserId, - submittedByLabel: payload.data.submittedByLabel?.trim() || contribution.submittedByLabel, - mode: contribution.mode, - status: contribution.autoStatus, - title: `Moment pour ${person.firstName || person.lastName}`.slice(0, 120), - reviewedAt: isPending ? undefined : new Date(), - reviewedBy: isPending ? undefined : contribution.submittedByUserId, + personId: unionId ? null : person.id, + unionId, + type: payload.data.type, + ...lifeEventData(payload.data, reserved), + status: isPending ? 'pending' : 'approved', + contributionSessionId: session?.id ?? null, }, }) - : null + await setLifeEventMedia(tx, params.data.id, event.id, payload.data.mediaIds) - const event = await app.prisma.lifeEvent.create({ - data: { - treeId: params.data.id, - personId: unionId ? null : person.id, - unionId, - type: payload.data.type, - ...lifeEventData(payload.data, reserved), - status: isPending ? 'pending' : 'approved', - contributionSessionId: session?.id ?? null, - }, - }) - await setLifeEventMedia(params.data.id, event.id, payload.data.mediaIds) - - if (session) { - await app.prisma.contributionChange.create({ - data: { - sessionId: session.id, - entityType: 'life_event', - entityId: event.id, - action: 'create', - afterJson: { - personId: person.id, type: event.type, title, date: payload.data.date ?? null, dateEnd: payload.data.dateEnd ?? null, - datePrecision: payload.data.datePrecision ?? null, place: event.place, notes: event.notes, sources: event.sources, + if (session) { + await tx.contributionChange.create({ + data: { + sessionId: session.id, + entityType: 'life_event', + entityId: event.id, + action: 'create', + afterJson: { + personId: person.id, type: event.type, title, date: payload.data.date ?? null, dateEnd: payload.data.dateEnd ?? null, + datePrecision: payload.data.datePrecision ?? null, place: event.place, notes: event.notes, sources: event.sources, + }, + decision: isPending ? undefined : 'approved', }, - decision: isPending ? undefined : 'approved', - }, - }) - } + }) + } + return event + }) return reply.code(201).send({ event: { id: event.id, status: event.status } }) }) @@ -2249,11 +2255,13 @@ export const treeRoutes: FastifyPluginAsync = async (app) => { return reply.code(400).send({ error: 'invalid_media_reference' }) } - await app.prisma.lifeEvent.update({ - where: { id: params.data.eventId }, - data: lifeEventData(payload.data, RESERVED_LIFE_EVENT_TYPES.has(existing.type)), + await app.prisma.$transaction(async (tx) => { + await tx.lifeEvent.update({ + where: { id: params.data.eventId }, + data: lifeEventData(payload.data, RESERVED_LIFE_EVENT_TYPES.has(existing.type)), + }) + await setLifeEventMedia(tx, params.data.id, params.data.eventId, payload.data.mediaIds) }) - await setLifeEventMedia(params.data.id, params.data.eventId, payload.data.mediaIds) return reply.send({ ok: true }) }) @@ -2266,13 +2274,16 @@ export const treeRoutes: FastifyPluginAsync = async (app) => { const membership = await getAdminMembership(app, user.userId, params.data.id) if (!hasActiveAdminMembership(membership)) return reply.code(403).send({ error: 'forbidden' }) - const deleted = await app.prisma.lifeEvent.updateMany({ - where: { id: params.data.eventId, treeId: params.data.id, deletedAt: null, personId: { not: null }, type: { notIn: [...RESERVED_LIFE_EVENT_TYPES] } }, - data: { deletedAt: new Date() }, + const deletedCount = await app.prisma.$transaction(async (tx) => { + const deleted = await tx.lifeEvent.updateMany({ + where: { id: params.data.eventId, treeId: params.data.id, deletedAt: null, personId: { not: null }, type: { notIn: [...RESERVED_LIFE_EVENT_TYPES] } }, + data: { deletedAt: new Date() }, + }) + // Les souvenirs restent dans la fiche, simplement détachés du moment + if (deleted.count > 0) await tx.mediaItem.updateMany({ where: { treeId: params.data.id, lifeEventId: params.data.eventId }, data: { lifeEventId: null } }) + return deleted.count }) - if (deleted.count === 0) return reply.code(404).send({ error: 'event_not_found' }) - // Les souvenirs restent dans la fiche, simplement détachés du moment - await app.prisma.mediaItem.updateMany({ where: { treeId: params.data.id, lifeEventId: params.data.eventId }, data: { lifeEventId: null } }) + if (deletedCount === 0) return reply.code(404).send({ error: 'event_not_found' }) return reply.code(204).send() }) diff --git a/backend/test/api-routes.test.ts b/backend/test/api-routes.test.ts index 8fa3317..cb0a1d9 100644 --- a/backend/test/api-routes.test.ts +++ b/backend/test/api-routes.test.ts @@ -80,6 +80,9 @@ const { prismaMock } = vi.hoisted(() => ({ }, annotation: { findMany: vi.fn(), + count: vi.fn().mockResolvedValue(0), + create: vi.fn(), + updateMany: vi.fn().mockResolvedValue({ count: 1 }), }, contributionSession: { create: vi.fn(), @@ -753,6 +756,141 @@ describe('Moments de l’onglet Vie', () => { }) }) +describe('Écritures en plusieurs temps : tout passe par une transaction', () => { + // Le client de la transaction est un autre objet que app.prisma : une écriture faite hors transaction se voit + const txClient = () => ({ + annotation: { create: vi.fn().mockResolvedValue({ id: 'a' }), updateMany: vi.fn().mockResolvedValue({ count: 1 }) }, + mediaItem: { updateMany: vi.fn().mockResolvedValue({ count: 1 }), findMany: vi.fn().mockResolvedValue([]), update: vi.fn() }, + lifeEvent: { create: vi.fn().mockResolvedValue({ id: 'e-1', status: 'approved' }), update: vi.fn(), updateMany: vi.fn().mockResolvedValue({ count: 1 }) }, + contributionSession: { create: vi.fn() }, + contributionChange: { create: vi.fn() }, + auditLog: { create: vi.fn() }, + }) + + it('lot d’annotations : créations, modifications, suppressions et trace dans la même transaction', async () => { + const app = createApp() + const auth = { authorization: `Bearer ${await createUserToken(app)}` } + const tx = txClient() + prismaMock.$transaction.mockImplementationOnce(async (callback: (client: unknown) => Promise) => callback(tx)) + + const response = await app.inject({ + method: 'POST', url: '/trees/tree-1/annotations/batch', headers: auth, + payload: { create: [{ type: 'text', x: 0, y: 0, content: 'Été 1952' }, { type: 'text', x: 1, y: 1, content: 'Noël' }], update: [{ id: 'a-1', x: 5 }], delete: ['a-2'] }, + }) + expect(response.statusCode).toBe(200) + expect(response.json()).toEqual({ ok: true, created: 2, updated: 1, deleted: 1 }) + expect(tx.annotation.create).toHaveBeenCalledTimes(2) + expect(tx.annotation.updateMany).toHaveBeenCalledTimes(2) + expect(tx.auditLog.create).toHaveBeenCalledTimes(1) + expect(prismaMock.annotation.create).not.toHaveBeenCalled() + expect(prismaMock.annotation.updateMany).not.toHaveBeenCalled() + await app.close() + }) + + it('lot d’annotations : une erreur au milieu fait échouer toute la transaction', async () => { + const app = createApp() + const auth = { authorization: `Bearer ${await createUserToken(app)}` } + const tx = txClient() + tx.annotation.create.mockResolvedValueOnce({ id: 'a' }).mockRejectedValueOnce(new Error('base indisponible')) + prismaMock.$transaction.mockImplementationOnce(async (callback: (client: unknown) => Promise) => callback(tx)) + + const response = await app.inject({ + method: 'POST', url: '/trees/tree-1/annotations/batch', headers: auth, + payload: { create: [{ type: 'text', x: 0, y: 0, content: 'a' }, { type: 'text', x: 1, y: 1, content: 'b' }] }, + }) + expect(response.statusCode).toBe(500) + expect(tx.auditLog.create).not.toHaveBeenCalled() + await app.close() + }) + + it('réordonner les souvenirs : positions, renumérotation et trace dans la même transaction', async () => { + const app = createApp() + const auth = { authorization: `Bearer ${await createUserToken(app)}` } + const tx = txClient() + prismaMock.mediaItem.findMany.mockResolvedValueOnce([{ id: 'm-1' }, { id: 'm-2' }]) + prismaMock.$transaction.mockImplementationOnce(async (callback: (client: unknown) => Promise) => callback(tx)) + + const response = await app.inject({ + method: 'PATCH', url: '/trees/tree-1/persons/person-1/media/reorder', headers: auth, + payload: { orderedMediaIds: ['m-2', 'm-1'] }, + }) + expect(response.statusCode).toBe(200) + expect(tx.mediaItem.updateMany).toHaveBeenCalledTimes(2) + expect(tx.auditLog.create).toHaveBeenCalledTimes(1) + expect(prismaMock.mediaItem.updateMany).not.toHaveBeenCalled() + await app.close() + }) + + it('moment : création et rattachement des souvenirs dans la même transaction', async () => { + const app = createApp() + const auth = { authorization: `Bearer ${await createUserToken(app)}` } + const tx = txClient() + prismaMock.mediaItem.count.mockResolvedValue(1) + prismaMock.$transaction.mockImplementationOnce(async (callback: (client: unknown) => Promise) => callback(tx)) + + const response = await app.inject({ + method: 'POST', url: '/trees/tree-1/persons/person-1/life/events', headers: auth, + payload: { label: 'Départ pour Paris', mediaIds: ['m-1'] }, + }) + expect(response.statusCode).toBe(201) + expect(tx.lifeEvent.create).toHaveBeenCalledTimes(1) + expect(tx.mediaItem.updateMany).toHaveBeenCalledWith({ where: { treeId: 'tree-1', id: { in: ['m-1'] } }, data: { lifeEventId: 'e-1' } }) + expect(prismaMock.lifeEvent.create).not.toHaveBeenCalled() + await app.close() + }) + + it('la famille ne prend que des souvenirs libres, l’administrateur peut déplacer un souvenir d’un moment à l’autre', async () => { + const app = createApp() + const auth = { authorization: `Bearer ${await createUserToken(app)}` } + const post = () => app.inject({ method: 'POST', url: '/trees/tree-1/persons/person-1/life/events', headers: auth, payload: { label: 'Communion', mediaIds: ['m-1'] } }) + prismaMock.mediaItem.count.mockResolvedValue(1) + prismaMock.lifeEvent.create.mockResolvedValue({ id: 'e-1', status: 'approved' }) + + expect((await post()).statusCode).toBe(201) + expect(prismaMock.mediaItem.count.mock.calls.at(-1)[0].where).not.toHaveProperty('lifeEventId') + + prismaMock.treeMembership.findUnique.mockResolvedValue({ role: 'member', tree: { deletedAt: null, settings: null } }) + prismaMock.contributionSession.create.mockResolvedValue({ id: 's-1' }) + expect((await post()).statusCode).toBe(201) + expect(prismaMock.mediaItem.count.mock.calls.at(-1)[0].where).toMatchObject({ lifeEventId: null }) + await app.close() + }) +}) + +describe('Import GEDCOM', () => { + it('une personne des deux côtés d’une union garde un seul partenaire, une boucle parent-enfant est refusée', async () => { + const app = createApp() + const auth = { authorization: `Bearer ${await createUserToken(app)}` } + prismaMock.person.findMany.mockResolvedValue([]) + prismaMock.union.findMany.mockResolvedValue([]) + prismaMock.parentChildLink.findMany.mockResolvedValue([]) + prismaMock.parentChildLink.findFirst.mockResolvedValue(null) + prismaMock.person.create.mockImplementation(async ({ data }: { data: { firstName: string } }) => ({ id: `p-${data.firstName}` })) + let unionCount = 0 + prismaMock.union.create.mockImplementation(async () => ({ id: `u-${++unionCount}` })) + + const content = [ + '0 HEAD', + '0 @I1@ INDI', '1 NAME Jean /Martin/', + '0 @I2@ INDI', '1 NAME Paul /Martin/', + // Jean marié à lui-même (fichier abîmé) + '0 @F1@ FAM', '1 HUSB @I1@', '1 WIFE @I1@', + // Paul enfant de Jean, puis Jean enfant de Paul : la seconde famille ferait une boucle + '0 @F2@ FAM', '1 HUSB @I1@', '1 CHIL @I2@', + '0 @F3@ FAM', '1 HUSB @I2@', '1 CHIL @I1@', + '0 TRLR', + ].join('\n') + const response = await app.inject({ method: 'POST', url: '/trees/tree-1/import/gedcom', headers: auth, payload: { content } }) + + expect(response.statusCode).toBe(200) + expect(response.json()).toMatchObject({ personsCreated: 2, linksCreated: 1, relationsSkipped: 2 }) + expect(prismaMock.union.create.mock.calls[0][0].data).toMatchObject({ partner1PersonId: 'p-Jean', partner2PersonId: null }) + expect(prismaMock.parentChildLink.create).toHaveBeenCalledTimes(1) + expect(prismaMock.parentChildLink.create.mock.calls[0][0].data).toMatchObject({ parentPersonId: 'p-Jean', childPersonId: 'p-Paul' }) + await app.close() + }) +}) + describe('Visit log', () => { it('refuses the visit log to a non-admin member', async () => { const app = createApp() diff --git a/backend/test/data-deletion.test.ts b/backend/test/data-deletion.test.ts index b3ea86c..de7be4f 100644 --- a/backend/test/data-deletion.test.ts +++ b/backend/test/data-deletion.test.ts @@ -28,6 +28,9 @@ const { prismaMock } = vi.hoisted(() => ({ contributionSession: { updateMany: vi.fn(), }, + treeVisit: { + updateMany: vi.fn().mockResolvedValue({ count: 0 }), + }, $transaction: vi.fn(), $disconnect: vi.fn(), }, @@ -214,6 +217,10 @@ describe('DELETE /auth/me', () => { where: { submittedByUserId: 'user-1' }, data: { submittedByLabel: 'Contributeur' }, }) + // Ses visites d'autres arbres restent comptées, sans son identifiant + const visitUpdate = prismaMock.treeVisit.updateMany.mock.calls[0][0] + expect(visitUpdate.where).toEqual({ visitorKey: 'user:user-1' }) + expect(visitUpdate.data.visitorKey).toMatch(/^[0-9a-f]{32}$/) expect(prismaMock.user.delete).toHaveBeenCalledWith({ where: { id: 'user-1' } }) // Les arbres doivent partir avant l'utilisateur (relation propriétaire en Restrict). diff --git a/backend/test/gedcom-media.test.ts b/backend/test/gedcom-media.test.ts index 86923e0..5b1aa26 100644 --- a/backend/test/gedcom-media.test.ts +++ b/backend/test/gedcom-media.test.ts @@ -220,4 +220,13 @@ describe('localMediaPath', () => { expect(localMediaPath('C:\\Photos\\arbre.ged')).toBeNull() expect(localMediaPath('')).toBeNull() }) + + it('fichier piégé : sources en chaîne et niveaux trop profonds, sans débordement ni lenteur', () => { + const chain = Array.from({ length: 20000 }, (_, i) => [`0 @S${i}@ SOUR`, `1 SOUR @S${i + 1}@`]).flat() + const deep = Array.from({ length: 5000 }, (_, i) => `${i + 2} NOTE niveau`) + const text = ['0 @I1@ INDI', '1 NAME A /B/', '1 SOUR @S0@', '1 BIRT', ...deep, ...chain, '0 @S20000@ SOUR', '1 OBJE', '2 FILE C:/fin.jpg'].join('\n') + const started = Date.now() + expect(() => mediaOf(text)).not.toThrow() + expect(Date.now() - started).toBeLessThan(2000) + }) }) diff --git a/backend/test/tree-visits.test.ts b/backend/test/tree-visits.test.ts new file mode 100644 index 0000000..1a800cb --- /dev/null +++ b/backend/test/tree-visits.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it, vi } from 'vitest' + +import { recordTreeVisit, VISIT_RETENTION_DAYS } from '../src/lib/tree-visits' + +function fakeApp() { + return { + prisma: { + treeVisit: { + findFirst: vi.fn().mockResolvedValue(null), + create: vi.fn().mockResolvedValue({ id: 'v' }), + update: vi.fn(), + deleteMany: vi.fn().mockResolvedValue({ count: 0 }), + }, + }, + } +} + +const request = { headers: { 'user-agent': 'Mozilla/5.0 (iPhone)' }, ip: '203.0.113.10', log: { warn: vi.fn() } } + +describe('journal des visites', () => { + it('efface les visites de plus d’un an à l’enregistrement, au plus une fois par heure', async () => { + const app = fakeApp() + const before = Date.now() + await recordTreeVisit(app as never, request as never, 'tree-1', 'share', null) + await recordTreeVisit(app as never, request as never, 'tree-2', 'share', null) + + expect(app.prisma.treeVisit.create).toHaveBeenCalledTimes(2) + expect(app.prisma.treeVisit.deleteMany).toHaveBeenCalledTimes(1) + const cutoff = app.prisma.treeVisit.deleteMany.mock.calls[0][0].where.createdAt.lt.getTime() + // Tous les arbres, pas seulement celui qu'on ouvre : un arbre qu'on ne rouvre plus est purgé aussi + expect(app.prisma.treeVisit.deleteMany.mock.calls[0][0].where).not.toHaveProperty('treeId') + expect(before - cutoff).toBeGreaterThanOrEqual(VISIT_RETENTION_DAYS * 24 * 60 * 60 * 1000 - 1000) + }) +}) diff --git a/deploy/aio/entrypoint.sh b/deploy/aio/entrypoint.sh index edf3efe..4025ec7 100755 --- a/deploy/aio/entrypoint.sh +++ b/deploy/aio/entrypoint.sh @@ -78,6 +78,22 @@ stop_all() { trap 'stop_all; exit 0' TERM INT cd /app +# Migration en attente sur une base existante : copie de la base dans /data/backups avant de l'appliquer +# (une migration ne se défait pas). Les 5 dernières sont gardées. Si la copie échoue, le conteneur s'arrête. +if [ "$FIRST_START" = 0 ] && ! runuser -u node -- env PATH="$PATH" prisma migrate status >/dev/null 2>&1; then + mkdir -p "$DATA_DIR/backups" + chmod 700 "$DATA_DIR/backups" + dump="$DATA_DIR/backups/parenthese-avant-migration-$(date -u +%Y%m%dT%H%M%SZ).dump" + log "migration en attente : sauvegarde de la base dans $dump" + if ! (umask 077 && runuser -u postgres -- pg_dump -Fc "$DB_NAME" > "$dump"); then + rm -f "$dump" + log "la sauvegarde a échoué, migration annulée (place sur le disque ?)" + stop_all + exit 1 + fi + ls -1t "$DATA_DIR"/backups/parenthese-avant-migration-*.dump | tail -n +6 | xargs -r rm -f +fi + log "migrations" runuser -u node -- env PATH="$PATH" prisma migrate deploy diff --git a/deploy/backup.sh b/deploy/backup.sh index d852bf8..9b29689 100644 --- a/deploy/backup.sh +++ b/deploy/backup.sh @@ -98,12 +98,9 @@ echo "[3/5] Generate checksums..." ( cd "$BACKUP_ROOT" sha256sum "db/$(basename "$DB_BACKUP_FILE")" - if [ "$MEDIA_IS_NEW" = "1" ]; then - sha256sum "media/$(basename "$MEDIA_BACKUP_FILE")" - else - # empreinte déjà calculée le jour où l'archive a été faite - grep -h " media/$(basename "$MEDIA_BACKUP_FILE")" "$META_DIR"/genealogy-*.sha256 2>/dev/null | head -1 || true - fi + # Archive du jour ou archive gardée : son empreinte est recalculée, celle du jour où elle a été faite + # disparaît avec la rotation des fichiers d'empreintes (14 jours) alors que l'archive reste + sha256sum "media/$(basename "$MEDIA_BACKUP_FILE")" ) > "$CHECKSUM_FILE" echo "[4/5] Optional offsite upload..." diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js index 090df6a..c4310a7 100644 --- a/frontend/src/api/client.js +++ b/frontend/src/api/client.js @@ -1,3 +1,5 @@ +import { t } from '../i18n/index.js' + const rawApiBaseUrl = import.meta.env.VITE_API_BASE_URL const defaultApiBaseUrl = import.meta.env.DEV ? 'http://localhost:4000' : '' const API_BASE_URL = (rawApiBaseUrl ?? defaultApiBaseUrl).replace(/\/$/, '') @@ -23,7 +25,21 @@ async function safeParseJson(text) { } } +// Sans limite, un réseau mobile qui décroche laisse l'arbre « en chargement » pour toujours. +// Un corps de plus de 100 Ko est un envoi de fichier : il a plus de temps sur une connexion lente. +const DEFAULT_TIMEOUT_MS = 30_000 +const UPLOAD_TIMEOUT_MS = 5 * 60_000 +const LARGE_BODY_CHARS = 100_000 + +function timeoutSignal(options) { + if (options.signal) return options.signal + if (typeof AbortSignal === 'undefined' || typeof AbortSignal.timeout !== 'function') return undefined + const isLargeBody = typeof options.body === 'string' && options.body.length > LARGE_BODY_CHARS + return AbortSignal.timeout(options.timeoutMs ?? (isLargeBody ? UPLOAD_TIMEOUT_MS : DEFAULT_TIMEOUT_MS)) +} + async function apiRequest(path, options = {}) { + const { timeoutMs: _timeoutMs, ...fetchOptions } = options const providedHeaders = options.headers || {} const hasExplicitContentType = Object.keys(providedHeaders).some((key) => key.toLowerCase() === 'content-type') @@ -32,12 +48,21 @@ async function apiRequest(path, options = {}) { ...providedHeaders, } - const response = await fetch(`${API_BASE_URL}${path}`, { - ...options, - headers, - }) - - const text = await response.text() + let response + let text + try { + response = await fetch(`${API_BASE_URL}${path}`, { + ...fetchOptions, + headers, + signal: timeoutSignal(options), + }) + text = await response.text() + } catch (error) { + if (error?.name === 'TimeoutError') { + throw new ApiError(t('Le serveur ne répond pas. Vérifiez la connexion et réessayez.'), 0, { error: 'request_timeout' }) + } + throw error + } const payload = await safeParseJson(text) if (!response.ok) { diff --git a/frontend/src/api/client.test.js b/frontend/src/api/client.test.js new file mode 100644 index 0000000..f5a3de7 --- /dev/null +++ b/frontend/src/api/client.test.js @@ -0,0 +1,36 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { ApiError, apiRequest } from './client' + +afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllGlobals() +}) + +describe('apiRequest', () => { + it('une requête qui ne répond pas finit en erreur lisible', async () => { + const timeout = vi.spyOn(AbortSignal, 'timeout') + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new DOMException('trop long', 'TimeoutError'))) + await expect(apiRequest('/trees')).rejects.toMatchObject({ + name: 'ApiError', message: 'Le serveur ne répond pas. Vérifiez la connexion et réessayez.', status: 0, payload: { error: 'request_timeout' }, + }) + expect(timeout).toHaveBeenCalledWith(30_000) + }) + + it('un envoi de fichier a plus de temps, et un délai demandé passe devant', async () => { + const timeout = vi.spyOn(AbortSignal, 'timeout') + vi.stubGlobal('fetch', vi.fn().mockImplementation(async () => new Response('{"ok":true}', { status: 200 }))) + await apiRequest('/upload', { method: 'POST', body: 'x'.repeat(200_000) }) + expect(timeout).toHaveBeenLastCalledWith(5 * 60_000) + await apiRequest('/import', { method: 'POST', body: '{}', timeoutMs: 1234 }) + expect(timeout).toHaveBeenLastCalledWith(1234) + expect(fetch.mock.calls[1][1]).not.toHaveProperty('timeoutMs') + }) + + it('une autre panne réseau remonte telle quelle', async () => { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('Failed to fetch'))) + const error = await apiRequest('/trees').catch((e) => e) + expect(error).toBeInstanceOf(TypeError) + expect(error).not.toBeInstanceOf(ApiError) + }) +}) diff --git a/frontend/src/api/treeApi.js b/frontend/src/api/treeApi.js index 58fa8b5..ca950b5 100644 --- a/frontend/src/api/treeApi.js +++ b/frontend/src/api/treeApi.js @@ -538,6 +538,8 @@ export async function importGedcom(treeId, fileContent, token) { method: 'POST', headers: { Authorization: `Bearer ${token}` }, body: JSON.stringify({ content: fileContent }), + // Un gros arbre se crée en une transaction d'une minute au plus côté serveur + timeoutMs: 3 * 60_000, }) return data } diff --git a/frontend/src/components/Galaxy.jsx b/frontend/src/components/Galaxy.jsx index f1628ab..85f5574 100644 --- a/frontend/src/components/Galaxy.jsx +++ b/frontend/src/components/Galaxy.jsx @@ -70,7 +70,7 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig const selectedPersonIdRef = useRef(null) const selectedGlowScales = useRef(new Map()) const searchGlowScales = useRef(new Map()) - const searchHighlightIdsRef = useRef([]) + const searchHighlightSetRef = useRef(new Set()) const immediateRelativesRef = useRef(new Set()) const pathEdgesRef = useRef([]) const pathNodesRef = useRef([]) @@ -567,7 +567,7 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig // Sync searchHighlightIds useEffect(() => { - searchHighlightIdsRef.current = searchHighlightIds + searchHighlightSetRef.current = new Set(searchHighlightIds.map(id => `p-${id}`)) }, [searchHighlightIds]) // Recalculer les proches immédiats @@ -646,15 +646,6 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig if (!layoutData || !canvas) { rafId = requestAnimationFrame(animate); return } const { x, y, scale } = transformRef.current - // Positions animées - const posMap = new Map() - layoutData.children.forEach(node => { - posMap.set(node.id, getAnimatedPos(node, timestamp)) - }) - - const nodeMap = new Map() - layoutData.children.forEach(n => nodeMap.set(n.id, n)) - // Lissage hover states const hoverEasing = 0.12 layoutData.children.forEach(node => { @@ -667,11 +658,10 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig const pMedias = getPersonMedias(node._data.id) const orbitSlots = rd?.orbitSlots || [] const orbitCount = Math.min(pMedias.length, MAX_ORBIT_MEDIAS) - const pos = posMap.get(node.id) for (let i = 0; i < orbitCount; i++) { const key = `${node.id}:${i}` const slot = orbitSlots[i] - if (!slot || !pos) continue + if (!slot) continue const isHovered = hoveredMediaKey.current === key const prevMh = mediaHoverState.current.get(key) || { scale: 0, dx: 0, dy: 0 } const dirX = Math.cos(slot.angle) @@ -717,7 +707,7 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig // Animation search highlight glow const searchEasing = 0.12 - const searchHighlightSet = new Set(searchHighlightIdsRef.current.map(id => `p-${id}`)) + const searchHighlightSet = searchHighlightSetRef.current layoutData.children.forEach(node => { if (node._type !== 'person') return const targetSearchGlow = searchHighlightSet.has(node.id) ? 1.2 : 0 @@ -752,8 +742,6 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig const PATH_EDGE_DURATION = 300 const pathElapsed = pathStartTime.current > 0 ? timestamp - pathStartTime.current : -1 const pathEasing = 0.12 - const activePathEdges = new Set(pathEdgesRef.current.map(e => e.id)) - const activePathNodes = new Set(pathNodesRef.current) for (let i = 0; i < pathEdgesRef.current.length; i++) { const e = pathEdgesRef.current[i] @@ -771,12 +759,17 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig if (pathProgressRef.current.get(n) !== staggeredTarget) { dirty = true; pathProgressRef.current.set(n, staggeredTarget) } } - for (const [key, val] of pathProgressRef.current) { - if (!activePathEdges.has(key) && !activePathNodes.has(key)) { - dirty = true - const next = val + (0 - val) * pathEasing - if (Math.abs(next) < 0.001) pathProgressRef.current.delete(key) - else pathProgressRef.current.set(key, next) + // Au repos, rien à éteindre : pas d'ensembles reconstruits à chaque frame + if (pathProgressRef.current.size > 0) { + const activePathEdges = new Set(pathEdgesRef.current.map(e => e.id)) + const activePathNodes = new Set(pathNodesRef.current) + for (const [key, val] of pathProgressRef.current) { + if (!activePathEdges.has(key) && !activePathNodes.has(key)) { + dirty = true + const next = val + (0 - val) * pathEasing + if (Math.abs(next) < 0.001) pathProgressRef.current.delete(key) + else pathProgressRef.current.set(key, next) + } } } @@ -796,6 +789,15 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig // Rien n'a changé : on ne redessine pas if (!dirty) { rafId = requestAnimationFrame(animate); return } + // Positions animées, calculées seulement quand on redessine + const posMap = new Map() + layoutData.children.forEach(node => { + posMap.set(node.id, getAnimatedPos(node, timestamp)) + }) + + const nodeMap = new Map() + layoutData.children.forEach(n => nodeMap.set(n.id, n)) + // === RENDU === const ctx = canvas.getContext('2d') ctx.setTransform(dprRef.current, 0, 0, dprRef.current, 0, 0) diff --git a/frontend/src/i18n/en/app.js b/frontend/src/i18n/en/app.js index 4681889..885661f 100644 --- a/frontend/src/i18n/en/app.js +++ b/frontend/src/i18n/en/app.js @@ -100,4 +100,5 @@ export default { 'Ressource introuvable.': 'Not found.', 'Conflit détecté (email ou slug déjà utilisé).': 'Conflict detected (email or slug already in use).', "Erreur backend. Vérifiez que l'API et la base sont lancées.": 'Server error. Check that the API and database are running.', + 'Le serveur ne répond pas. Vérifiez la connexion et réessayez.': 'The server is not responding. Check your connection and try again.', } diff --git a/tools/docker-restore-check.sh b/tools/docker-restore-check.sh new file mode 100755 index 0000000..3148bf0 --- /dev/null +++ b/tools/docker-restore-check.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +# Sauvegarde puis restauration d'une installation compose avec les commandes du README, et contrôle +# que rien ne s'est perdu : comptes, arbres, personnes, migrations et fichiers des médias. +# +# Usage : bash tools/docker-restore-check.sh [http://localhost:8080] (depuis le dossier du dépôt, installation démarrée) +# Nécessite curl et jq. La CI le lance après le test de fumée. +# Attention : la base et les médias sont effacés puis restaurés. Jamais sur une installation en service. +set -euo pipefail + +cd "$(dirname "$0")/.." +BASE_URL="${1:-http://localhost}" +BASE_URL="${BASE_URL%/}" +DB_USER="${POSTGRES_USER:-parenthese}" +DB_NAME="${POSTGRES_DB:-parenthese}" +MEDIA_VOLUME="parenthese_media" +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT + +fail() { echo "ÉCHEC : $*" >&2; exit 1; } +sql() { docker compose exec -T db psql -U "$DB_USER" -d "$DB_NAME" -v ON_ERROR_STOP=1 -At -c "$1"; } +counts() { sql "SELECT (SELECT count(*) FROM users) || ' ' || (SELECT count(*) FROM trees) || ' ' || (SELECT count(*) FROM persons) || ' ' || (SELECT count(*) FROM _prisma_migrations)"; } + +# Données à retrouver : un compte, un arbre et une personne créés par l'API, un fichier dans les médias +api() { + local method="$1" path="$2" body="$3" token="${4:-}" + curl -fsS --max-time 30 -X "$method" "$BASE_URL$path" -H 'Content-Type: application/json' \ + ${token:+-H "Authorization: Bearer $token"} --data "$body" +} +stamp="$(date +%s)-$RANDOM" +token="$(api POST /auth/register "$(jq -nc --arg e "restore-$stamp@example.com" '{firstName: "Test", email: $e, password: "restauration-test-1"}')" | jq -r .token)" +tree_id="$(api POST /trees "$(jq -nc --arg s "restore-$stamp" '{name: "Arbre de restauration", slug: $s, visitorPassword: "visiteur-test-1", contributorPassword: "contributeur-test-1"}')" "$token" | jq -r .tree.id)" +api POST "/trees/$tree_id/persons" '{"firstName": "Jeanne", "lastName": "Test"}' "$token" >/dev/null +docker run --rm -v "$MEDIA_VOLUME":/data alpine sh -c 'echo "souvenir de test" > /data/restore-check.txt' + +before="$(counts)" +read -r users trees persons _ <<< "$before" +[ "$users" -ge 1 ] && [ "$trees" -ge 1 ] && [ "$persons" -ge 1 ] || fail "données de test absentes avant la sauvegarde ($before)" +echo "Avant : $before (comptes, arbres, personnes, migrations)" + +# Sauvegarde, mêmes commandes que le README (fichiers écrits dans un dossier temporaire) +DUMP="$WORK/parenthese-db-$(date +%F).dump" +ARCHIVE="parenthese-media-$(date +%F).tar.gz" +docker compose exec -T db pg_dump -U "$DB_USER" -Fc "$DB_NAME" > "$DUMP" +docker run --rm -v "$MEDIA_VOLUME":/data -v "$WORK":/backup alpine tar -czf "/backup/$ARCHIVE" -C /data . +[ -s "$DUMP" ] || fail "dump vide" + +# Perte simulée : base vidée, médias effacés +docker compose stop api +sql "DROP SCHEMA public CASCADE; CREATE SCHEMA public;" >/dev/null +docker run --rm -v "$MEDIA_VOLUME":/data alpine find /data -mindepth 1 -delete +[ "$(sql "SELECT count(*) FROM information_schema.tables WHERE table_schema = 'public'")" = 0 ] || fail "la base n'a pas été vidée" + +# Restauration, mêmes commandes que le README +docker compose exec -T db pg_restore -U "$DB_USER" -d "$DB_NAME" --clean --if-exists < "$DUMP" +docker run --rm -v "$MEDIA_VOLUME":/data -v "$WORK":/backup alpine tar -xzf "/backup/$ARCHIVE" -C /data + +after="$(counts)" +echo "Après : $after" +[ "$after" = "$before" ] || fail "la restauration ne rend pas les mêmes données ($before avant, $after après)" +[ "$(docker run --rm -v "$MEDIA_VOLUME":/data alpine cat /data/restore-check.txt)" = "souvenir de test" ] || fail "fichier des médias absent après restauration" +docker run --rm -v "$MEDIA_VOLUME":/data alpine rm -f /data/restore-check.txt + +docker compose start api +echo "OK : sauvegarde et restauration du README vérifiées"