diff --git a/backend/src/lib/annotation-changes.ts b/backend/src/lib/annotation-changes.ts index 2175bfe..d701610 100644 --- a/backend/src/lib/annotation-changes.ts +++ b/backend/src/lib/annotation-changes.ts @@ -82,6 +82,16 @@ export function validateAnnotationChange(change: AnnotationChange, treeId: strin throw new AnnotationChangeError('unsupported_change_action_for_annotation') } +// L'« après » d'une proposition d'annotation tel qu'il est gardé : contenu nettoyé comme à l'écriture directe +// (photoPath canonique, sans adresse), pour qu'on le relise toujours de la même façon +export function normalizedAnnotationAfter(change: AnnotationChange, treeId: string): unknown { + const after = change.after + if (!after || typeof after !== 'object' || Array.isArray(after)) return after + const content = (after as { content?: unknown }).content + if (typeof content !== 'string') return after + return { ...(after as Record), content: cleanContent(content, treeId) } +} + // Identifiant de la photo d'une annotation (contenu JSON { photoPath }), ou null export function annotationPhotoId(after: unknown, treeId: string): string | null { if (!after || typeof after !== 'object' || Array.isArray(after)) return null @@ -147,29 +157,41 @@ export async function applyAnnotationChange(tx: Tx, treeId: string, change: Anno return { entityId: change.entityId ?? null } } -// Une proposition encore en attente cite-t-elle cette photo ? (en dehors d'une session donnée) -async function isPhotoInPendingProposal(db: Db, treeId: string, photoId: string, excludeSessionId?: string): Promise { - const needle = `%/trees/${treeId}/annotation-photos/${photoId}%` - const rows = await db.$queryRaw>` - SELECT c.id FROM contribution_changes c - JOIN contribution_sessions s ON s.id = c."sessionId" - WHERE s."treeId" = ${treeId} AND s.status = 'pending' AND c."entityType" = 'annotation' - AND c."afterJson"::text LIKE ${needle} - AND (${excludeSessionId ?? null}::text IS NULL OR s.id <> ${excludeSessionId ?? null}::text) - LIMIT 1` - return rows.length > 0 +// Photos citées par les propositions d'annotation encore en attente sur cet arbre (hors une session donnée). +// Lu dans le JSON décodé, pas dans son texte : un chemin échappé (\/trees\/…) reste reconnu. Un arbre a au plus +// quelques centaines de propositions en attente. +async function photosInPendingProposals(db: Db, treeId: string, excludeSessionId?: string): Promise> { + const changes = await db.contributionChange.findMany({ + where: { + entityType: 'annotation', + session: { treeId, status: 'pending', ...(excludeSessionId ? { id: { not: excludeSessionId } } : {}) }, + }, + select: { afterJson: true }, + }) + const cited = new Set() + for (const change of changes) { + const photoId = annotationPhotoId(change.afterJson, treeId) + if (photoId) cited.add(photoId) + } + return cited } -// Photo d'une famille refusée : son fichier est à effacer s'il n'est cité par aucune autre proposition en -// attente. Renvoie les chemins à effacer une fois la transaction validée. -export async function releaseRejectedPhoto(tx: Tx, treeId: string, after: unknown, sessionId: string): Promise { - const photoId = annotationPhotoId(after, treeId) - if (!photoId) return [] - const upload = await tx.annotationPhotoUpload.findFirst({ where: { id: photoId, treeId }, select: { filePath: true } }) - if (!upload) return [] - if (await isPhotoInPendingProposal(tx, treeId, photoId, sessionId)) return [] - await tx.annotationPhotoUpload.delete({ where: { id: photoId } }) - return [upload.filePath] +// Photos d'une famille refusées pendant une relecture : effacées si leur ligne d'attente existe encore (une +// acceptation la retire) et si aucune autre proposition en attente ne les cite. Appelé une fois, après toutes les +// décisions. Renvoie les chemins à effacer une fois la transaction validée. +export async function releaseRejectedPhotos(tx: Tx, treeId: string, rejectedAfters: unknown[], sessionId: string): Promise { + const candidates = new Set(rejectedAfters.map((after) => annotationPhotoId(after, treeId)).filter((id): id is string => Boolean(id))) + if (candidates.size === 0) return [] + const cited = await photosInPendingProposals(tx, treeId, sessionId) + const removed: string[] = [] + for (const photoId of candidates) { + if (cited.has(photoId)) continue + const upload = await tx.annotationPhotoUpload.findFirst({ where: { id: photoId, treeId }, select: { filePath: true } }) + if (!upload) continue + await tx.annotationPhotoUpload.delete({ where: { id: photoId } }) + removed.push(upload.filePath) + } + return removed } // Envois de photos jamais proposés depuis 7 jours : effacés. Appelé à chaque nouvel envoi de la famille, sur @@ -179,15 +201,33 @@ export async function sweepStalePendingPhotos(db: Db, treeId: string, now = new where: { treeId, createdAt: { lt: new Date(now.getTime() - PENDING_PHOTO_TTL_MS) } }, select: { id: true, filePath: true }, }) + if (stale.length === 0) return [] + const cited = await photosInPendingProposals(db, treeId) const removed: string[] = [] for (const upload of stale) { - if (await isPhotoInPendingProposal(db, treeId, upload.id)) continue + if (cited.has(upload.id)) continue await db.annotationPhotoUpload.delete({ where: { id: upload.id } }) removed.push(upload.filePath) } return removed } +// Réserve une place d'envoi en attente sous un verrou par arbre : deux envois simultanés ne dépassent pas le +// plafond. Renvoie false si l'arbre est plein. La ligne est créée avant l'écriture du fichier ; si l'écriture +// échoue, l'appelant la retire (forgetPendingPhoto). +export async function reservePendingPhoto(prisma: PrismaClient, treeId: string, photoId: string, filePath: string): Promise { + return prisma.$transaction(async (tx) => { + await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${'annotation-photos:' + treeId}))` + if (await tx.annotationPhotoUpload.count({ where: { treeId } }) >= MAX_PENDING_PHOTOS_PER_TREE) return false + await tx.annotationPhotoUpload.create({ data: { id: photoId, treeId, filePath } }) + return true + }) +} + +export async function forgetPendingPhoto(db: Db, photoId: string): Promise { + await db.annotationPhotoUpload.deleteMany({ where: { id: photoId } }) +} + // Chemin relatif sûr d'une photo d'annotation (sous /annotation-photos/) export function annotationPhotoRelativePath(treeId: string, photoId: string, extension: string): string { return path.posix.join(treeId, 'annotation-photos', `${photoId}${extension}`) diff --git a/backend/src/routes/contributions.ts b/backend/src/routes/contributions.ts index eed5fc6..de8c8ce 100644 --- a/backend/src/routes/contributions.ts +++ b/backend/src/routes/contributions.ts @@ -5,7 +5,7 @@ import { Prisma } from '@prisma/client' import { z } from 'zod' import { env, UPLOAD_BODY_LIMIT } from '../config/env.js' import { sanitizeAnnotationContentForRead } from '../lib/annotation-content.js' -import { AnnotationChangeError, applyAnnotationChange, releaseRejectedPhoto, validateAnnotationChange } from '../lib/annotation-changes.js' +import { AnnotationChangeError, applyAnnotationChange, normalizedAnnotationAfter, releaseRejectedPhotos, validateAnnotationChange } from '../lib/annotation-changes.js' import { softDeletePersonCascade, softDeleteUnionCascade } from '../lib/entity-deletion.js' import { removeStoredFile } from '../lib/media-files.js' import { canReadTree } from '../lib/tree-access.js' @@ -1295,7 +1295,8 @@ export const contributionRoutes: FastifyPluginAsync = async (app) => { entityId: effectiveEntityId, action: change.action, beforeJson: toPrismaJson(change.entityType === 'annotation' ? stripAnnotationUrl(before) : before), - afterJson: toPrismaJson(change.entityType === 'annotation' ? stripAnnotationUrl(change.after) : change.after), + // Annotation : gardée nettoyée (photoPath canonique), pour être relue sans ambiguïté à la relecture + afterJson: toPrismaJson(change.entityType === 'annotation' ? stripAnnotationUrl(normalizedAnnotationAfter(change, params.data.id)) : change.after), conflictState: change.conflictState ?? 'none', decision: context.autoStatus === 'approved' ? 'approved' : undefined, }, @@ -1464,6 +1465,10 @@ export const contributionRoutes: FastifyPluginAsync = async (app) => { let rejectedCount = 0 let conflictCount = 0 const refs: RefMap = new Map() + // Photos posées sur l'arbre par la famille : celles des annotations refusées sont libérées après toutes les + // décisions. Une annotation acceptée dans cette même relecture a déjà retiré la ligne d'attente de sa photo, + // qui n'est donc plus effacée (avant, la libération dans la boucle effaçait une photo acceptée plus loin). + const rejectedAnnotationAfters: unknown[] = [] for (const change of byApplicationOrder(session.changes)) { let decision = explicitDecisions.get(change.id) ?? payload.data.decision @@ -1507,6 +1512,7 @@ export const contributionRoutes: FastifyPluginAsync = async (app) => { if (error instanceof ContributionRouteError && (error.code === 'unresolved_reference' || SKIPPABLE_ANNOTATION_ERRORS.has(error.code))) { decision = 'rejected' rejectedCount += 1 + if (change.entityType === 'annotation') rejectedAnnotationAfters.push(change.afterJson) } else { throw error } @@ -1518,10 +1524,7 @@ export const contributionRoutes: FastifyPluginAsync = async (app) => { data: { status: 'rejected', deletedAt: new Date() }, }) } - // Photo posée sur l'arbre par la famille et refusée : son fichier part avec elle - if (change.entityType === 'annotation') { - filesToRemove.push(...await releaseRejectedPhoto(tx, params.data.id, change.afterJson, session.id)) - } + if (change.entityType === 'annotation') rejectedAnnotationAfters.push(change.afterJson) if (change.entityType === 'media' && change.entityId) { await tx.mediaItem.updateMany({ where: { id: change.entityId, treeId: params.data.id }, @@ -1542,6 +1545,8 @@ export const contributionRoutes: FastifyPluginAsync = async (app) => { }) } + filesToRemove.push(...await releaseRejectedPhotos(tx, params.data.id, rejectedAnnotationAfters, session.id)) + const status: ContributionStatus = approvedCount > 0 ? 'approved' : 'rejected' const updatedSession = await tx.contributionSession.update({ diff --git a/backend/src/routes/media.ts b/backend/src/routes/media.ts index 2f9b33c..e387573 100644 --- a/backend/src/routes/media.ts +++ b/backend/src/routes/media.ts @@ -25,7 +25,7 @@ import { resolveTreeMediaDirectory } from '../lib/tree-purge.js' import { canReadTree, canReadTreeWithMediaToken } from '../lib/tree-access.js' import type { MembershipRole } from '../types/auth.js' import { ContributionRouteError, resolveSubmissionContext } from './contributions.js' -import { MAX_PENDING_PHOTOS_PER_TREE, sweepStalePendingPhotos } from '../lib/annotation-changes.js' +import { forgetPendingPhoto, reservePendingPhoto, sweepStalePendingPhotos } from '../lib/annotation-changes.js' const MAX_IMAGE_BYTES = 5 * 1024 * 1024 const MAX_FILE_BYTES = 20 * 1024 * 1024 @@ -1151,9 +1151,6 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => { } const stale = await sweepStalePendingPhotos(app.prisma, params.data.id) await Promise.all(stale.map((filePath) => removeStoredFile(getStorageRoot(), filePath))) - if (await app.prisma.annotationPhotoUpload.count({ where: { treeId: params.data.id } }) >= MAX_PENDING_PHOTOS_PER_TREE) { - return reply.code(429).send({ error: 'too_many_pending_photos' }) - } } const fileBuffer = Buffer.from(stripDataUrlPrefix(payload.data.dataBase64), 'base64') @@ -1193,10 +1190,17 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => { return reply.code(400).send({ error: 'invalid_photo_path' }) } - await mkdir(path.dirname(absolutePath), { recursive: true }) - await writeFile(absolutePath, prepared.buffer) - if (!isManager) { - await app.prisma.annotationPhotoUpload.create({ data: { id: photoId, treeId: params.data.id, filePath: relativePath } }) + // Famille : la place est réservée sous un verrou par arbre avant d'écrire, pour tenir le plafond même quand + // plusieurs envois arrivent ensemble + if (!isManager && !(await reservePendingPhoto(app.prisma, params.data.id, photoId, relativePath))) { + return reply.code(429).send({ error: 'too_many_pending_photos' }) + } + try { + await mkdir(path.dirname(absolutePath), { recursive: true }) + await writeFile(absolutePath, prepared.buffer) + } catch (error) { + if (!isManager) await forgetPendingPhoto(app.prisma, photoId) + throw error } return reply.code(201).send({ diff --git a/backend/test/propositions-fantomes.db.test.ts b/backend/test/propositions-fantomes.db.test.ts index 6a2704c..21b4408 100644 --- a/backend/test/propositions-fantomes.db.test.ts +++ b/backend/test/propositions-fantomes.db.test.ts @@ -164,6 +164,48 @@ describe.skipIf(!TEST_DB)('propositions de la famille sur l\'arbre, vraie base', expect(await fileOf(kept.body.photoId)).toBeTruthy() }) + it('revue : refusée puis acceptée dans la même relecture, la photo reste sur le disque', async () => { + const sent = await upload(family) + const filePath = await fileOf(sent.body.photoId) as string + const proposed = await propose(family, [ + { entityType: 'annotation', action: 'create', after: photoAnnotation(sent.body.photoPath) }, + { entityType: 'annotation', action: 'create', after: { ...photoAnnotation(sent.body.photoPath), x: 99 } }, + ]) + const session = await prisma.contributionSession.findUnique({ where: { id: proposed.body.session.id }, include: { changes: { orderBy: { createdAt: 'asc' } } } }) + const [first, second] = session!.changes + const res = await call('POST', `/trees/${treeId}/contributions/sessions/${session!.id}/review`, owner.token, { + decision: 'approved', + changeDecisions: [{ changeId: first.id, decision: 'rejected' }, { changeId: second.id, decision: 'approved' }], + }) + expect(res.status).toBe(200) + expect(existsSync(path.join(storage, filePath))).toBe(true) + }) + + it("revue : un chemin de photo échappé dans le JSON compte quand même comme cité par une proposition en attente", async () => { + const sent = await upload(family) + const filePath = await fileOf(sent.body.photoId) as string + const plain = await propose(family, [{ entityType: 'annotation', action: 'create', after: photoAnnotation(sent.body.photoPath) }]) + const escaped = `{"photoPath":"${sent.body.photoPath.replaceAll('/', '\/')}","width":1,"height":1}` + const other = await propose(family, [{ entityType: 'annotation', action: 'create', after: { type: 'photo', x: 1, y: 1, content: escaped } }]) + expect(other.status).toBe(201) + await review(plain.body.session.id, 'rejected') + expect(existsSync(path.join(storage, filePath))).toBe(true) + await review(other.body.session.id, 'approved') + expect((await annotations()).some((a) => a.content.includes(sent.body.photoId))).toBe(true) + expect(existsSync(path.join(storage, filePath))).toBe(true) + }) + + it('revue : 70 envois simultanés sur un arbre neuf ne dépassent pas le plafond de 50', async () => { + const fresh = await call('POST', '/trees', owner.token, { name: 'Arbre neuf', slug: `neuf-${run}`, visitorPassword: 'partage-neuf-1', contributorPassword: 'partage-neuf-1' }) + const freshId = fresh.body.tree?.id ?? fresh.body.id + const access = (await call('POST', `/trees/${freshId}/access/unlock`, undefined, { password: 'partage-neuf-1' })).body.token + const results = await Promise.all(Array.from({ length: 70 }, () => call('POST', `/trees/${freshId}/annotation-photos`, access, { + fileName: 'photo.png', mimeType: 'image/png', sizeBytes: Buffer.from(PIXEL, 'base64').length, dataBase64: PIXEL, + }))) + expect(results.filter((r) => r.status === 201).length).toBe(50) + expect(await prisma.annotationPhotoUpload.count({ where: { treeId: freshId } })).toBe(50) + }) + it('au-delà de 50 photos en attente sur un arbre, la famille est arrêtée ; un gestionnaire non', async () => { const existing = await prisma.annotationPhotoUpload.count({ where: { treeId } }) await prisma.annotationPhotoUpload.createMany({