Merge pull request #97 from IliesAllali/fix/audit-1-1

Audit du 18/10 : import GEDCOM, transactions, arbre au repos, délai réseau, visites, sauvegardes
This commit is contained in:
IliesAllali authored and GitHub committed 2026-10-19 00:21:23 +02:00
commit bc5c31f594
22 files changed
+655 -207

No files matched your search

+10
View File
@@ -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()
+2 -2
View File
@@ -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
+2 -2
View File
@@ -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
+59 -13
View File
@@ -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<GedcomPhotoNode, string>()
const recordTitleCache = new WeakMap<GedcomPhotoNode, string>()
const hasMediaCache = new WeakMap<GedcomPhotoNode, boolean>()
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
}
+17 -1
View File
@@ -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<void> {
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')
}
+57 -54
View File
@@ -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<string, unknown> = {}
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<string, unknown> = {}
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 })
+7
View File
@@ -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
})
+41 -20
View File
@@ -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<string, string[]>()
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<string>()
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 })
})
}
+29 -26
View File
@@ -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 })
+66 -55
View File
@@ -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()
})
+138
View File
@@ -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<unknown>) => 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<unknown>) => 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<unknown>) => 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<unknown>) => 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()
+7
View File
@@ -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).
+9
View File
@@ -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)
})
})
+34
View File
@@ -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)
})
})
+16
View File
@@ -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
+3 -6
View File
@@ -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..."
+31 -6
View File
@@ -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) {
+36
View File
@@ -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)
})
})
+2
View File
@@ -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
}
+24 -22
View File
@@ -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)
+1
View File
@@ -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.',
}
+64
View File
@@ -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"