mirror of
https://github.com/IliesAllali/parenthese.git
synced 2026-11-13 16:22:03 +00:00
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 <noreply@anthropic.com>
This commit is contained in:
1 parent
51232d8150
commit
9c84bd0efc
4 files changed
+65
-1
No files matched your search
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user