From 9c84bd0efcb4cc70498a470204f14550c6bd1f2e Mon Sep 17 00:00:00 2001 From: IliesAllali Date: Mon, 19 Oct 2026 00:00:36 +0200 Subject: [PATCH] fix(visites): duree de conservation d'un an tenue, comptes supprimes anonymises La purge des visites de plus d'un an ne passait qu'a l'ouverture de l'onglet Visites par un administrateur : un arbre jamais consulte gardait tout. Elle passe aussi a l'enregistrement d'une visite, au plus une fois par heure, pour tous les arbres. A la suppression d'un compte, ses visites d'autres arbres gardaient son identifiant dans la cle du visiteur : elles restent comptees, sous une cle anonyme. Co-Authored-By: Claude Opus 5.5 --- backend/src/lib/tree-visits.ts | 18 +++++++++++++++- backend/src/routes/auth.ts | 7 ++++++ backend/test/data-deletion.test.ts | 7 ++++++ backend/test/tree-visits.test.ts | 34 ++++++++++++++++++++++++++++++ 4 files changed, 65 insertions(+), 1 deletion(-) create mode 100644 backend/test/tree-visits.test.ts 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/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/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/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) + }) +})