From 0132ecb3423ce07de017fa7e4e2aed038d915dd3 Mon Sep 17 00:00:00 2001 From: IliesAllali Date: Tue, 27 Oct 2026 15:14:32 +0100 Subject: [PATCH] =?UTF-8?q?Envois=20de=20la=20famille=20sans=20doublon=20:?= =?UTF-8?q?=20en-t=C3=AAte=20Idempotency-Key?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sur un réseau faible, une proposition, un souvenir, une photo posée sur l'arbre ou un moment pouvait être créé deux fois : la réponse se perdait (délai dépassé), la personne renvoyait, le serveur recréait tout. - Navigateur (api/client.js) : un envoi qui n'a pas abouti garde sa clé, et le même envoi refait (même adresse, même contenu) la réutilise. Une réussite libère la clé. - Serveur (lib/idempotency.ts) : sur les quatre routes de création, une clé déjà vue par la même personne renvoie la réponse du premier envoi ; deux essais simultanés n'en traitent qu'un ; un échec n'est pas gardé. En mémoire, 24 h, 5 000 clés au plus. Co-Authored-By: Claude Opus 5.5 --- backend/src/app.ts | 4 ++ backend/src/lib/idempotency.ts | 112 +++++++++++++++++++++++++++++++ backend/test/idempotency.test.ts | 87 ++++++++++++++++++++++++ frontend/src/api/client.js | 34 +++++++++- frontend/src/api/client.test.js | 57 +++++++++------- frontend/src/api/treeApi.js | 4 ++ 6 files changed, 274 insertions(+), 24 deletions(-) create mode 100644 backend/src/lib/idempotency.ts create mode 100644 backend/test/idempotency.test.ts diff --git a/backend/src/app.ts b/backend/src/app.ts index eb95053..bb92d21 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -9,6 +9,7 @@ import { Prisma } from './generated/prisma/client.js' import { env } from './config/env.js' import { prisma } from './lib/prisma.js' import { isUserTokenRevoked, loadRevokedTokens } from './lib/token-revocation.js' +import { registerIdempotency } from './lib/idempotency.js' import { authRoutes } from './routes/auth.js' import { contributionRoutes } from './routes/contributions.js' import { graphRoutes } from './routes/graph.js' @@ -156,6 +157,9 @@ export function createApp() { app.get('/health', async () => ({ ok: true })) + // Après l'identification : une clé d'idempotence ne vaut que pour la personne qui l'a envoyée + registerIdempotency(app) + app.register(authRoutes, { prefix: '/auth' }) app.register(oidcRoutes, { prefix: '/auth/oidc' }) app.register(publicRoutes) diff --git a/backend/src/lib/idempotency.ts b/backend/src/lib/idempotency.ts new file mode 100644 index 0000000..1583db2 --- /dev/null +++ b/backend/src/lib/idempotency.ts @@ -0,0 +1,112 @@ +import { createHash } from 'node:crypto' +import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify' + +// Envois rejoués sans doublon (en-tête Idempotency-Key). Sur un réseau faible, la famille renvoie une proposition +// ou une photo dont la réponse s'est perdue : le serveur avait déjà tout créé, et l'arbre recevait deux fois la +// même chose. Le navigateur tire une clé au début d'un envoi et la garde pour chaque nouvel essai ; le serveur +// répond au second essai avec la réponse du premier, sans rien recréer. Deux essais simultanés : le second attend +// le premier. +// +// En mémoire, sur une seule instance de l'API (le cas de Parenthèse et de l'auto-hébergement) : un redémarrage +// oublie les clés, ce qui ne recrée de doublon que si un essai tombe pile pendant le redémarrage. + +// Les créations faites par la famille : propositions, souvenirs, photos posées sur l'arbre, moments +const IDEMPOTENT_ROUTES = new Set([ + 'POST /trees/:id/contributions/sessions', + 'POST /trees/:id/persons/:personId/media', + 'POST /trees/:id/annotation-photos', + 'POST /trees/:id/persons/:personId/life/events', +]) + +const TTL_MS = 24 * 60 * 60 * 1000 +const MAX_ENTRIES = 5000 +const KEY_PATTERN = /^[A-Za-z0-9_-]{16,100}$/ + +type Stored = { statusCode: number, contentType: string | undefined, payload: string } +type Entry = { expires: number, done: Stored | null, settled: Promise, settle: () => void } + +const entries = new Map() + +function prune(now: number) { + for (const [scope, entry] of entries) { + if (entry.expires <= now) entries.delete(scope) + } + // Au-delà du plafond, les plus anciennes partent d'abord (la Map garde l'ordre d'insertion) + while (entries.size > MAX_ENTRIES) { + const oldest = entries.keys().next().value + if (oldest === undefined) break + entries.get(oldest)?.settle() + entries.delete(oldest) + } +} + +// Une clé ne vaut que pour la même route, la même adresse et la même personne : celle d'un autre ne rejoue rien +function scopeOf(request: FastifyRequest, key: string) { + const who = createHash('sha256').update(request.headers.authorization ?? `ip:${request.ip}`).digest('hex').slice(0, 32) + return `${request.method} ${request.url} ${who} ${key}` +} + +function replay(reply: FastifyReply, stored: Stored) { + if (stored.contentType) reply.header('content-type', stored.contentType) + return reply.code(stored.statusCode).header('idempotent-replayed', 'true').send(stored.payload) +} + +export function registerIdempotency(app: FastifyInstance) { + app.addHook('preHandler', async (request, reply) => { + if (!IDEMPOTENT_ROUTES.has(`${request.method} ${request.routeOptions.url}`)) return + const key = request.headers['idempotency-key'] + if (typeof key !== 'string' || !KEY_PATTERN.test(key)) return + + const now = Date.now() + prune(now) + const scope = scopeOf(request, key) + + // Un essai précédent est en cours : on attend sa fin, puis on rejoue sa réponse s'il a abouti + for (let existing = entries.get(scope); existing; existing = entries.get(scope)) { + if (existing.done) return replay(reply, existing.done) + await existing.settled + } + + let settle = () => {} + const settled = new Promise((resolve) => { settle = resolve }) + entries.set(scope, { expires: now + TTL_MS, done: null, settled, settle }) + request.idempotencyScope = scope + }) + + app.addHook('onSend', async (request, reply, payload) => { + const scope = request.idempotencyScope + if (!scope) return payload + const entry = entries.get(scope) + if (!entry) return payload + // Seule une réussite se rejoue ; un échec laisse le prochain essai refaire la requête + if (reply.statusCode < 300 && typeof payload === 'string') { + const contentType = reply.getHeader('content-type') + entry.done = { statusCode: reply.statusCode, contentType: typeof contentType === 'string' ? contentType : undefined, payload } + } else { + entries.delete(scope) + } + entry.settle() + return payload + }) + + // Requête coupée avant toute réponse : l'essai suivant ne doit pas attendre pour rien + app.addHook('onResponse', async (request) => { + const scope = request.idempotencyScope + const entry = scope ? entries.get(scope) : undefined + if (entry && !entry.done) { + entries.delete(scope as string) + entry.settle() + } + }) +} + +declare module 'fastify' { + interface FastifyRequest { + idempotencyScope?: string + } +} + +// Tests +export function resetIdempotency() { + entries.clear() +} diff --git a/backend/test/idempotency.test.ts b/backend/test/idempotency.test.ts new file mode 100644 index 0000000..0f0a65e --- /dev/null +++ b/backend/test/idempotency.test.ts @@ -0,0 +1,87 @@ +import Fastify, { type FastifyInstance } from 'fastify' +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest' +import { registerIdempotency, resetIdempotency } from '../src/lib/idempotency.js' + +// Un envoi rejoué avec la même clé (réponse perdue sur un réseau faible) ne crée rien une seconde fois +let app: FastifyInstance +let created = 0 +let failNext = false + +const KEY = 'a1b2c3d4-e5f6-4789-9abc-def012345678' +const send = (headers: Record = {}, url = '/trees/t1/contributions/sessions') => + app.inject({ method: 'POST', url, headers: { authorization: 'Bearer famille', ...headers }, payload: { title: 'x' } }) + +beforeAll(async () => { + app = Fastify() + registerIdempotency(app) + app.post('/trees/:id/contributions/sessions', async (_request, reply) => { + await new Promise((resolve) => setTimeout(resolve, 30)) + if (failNext) { + failNext = false + return reply.code(503).send({ error: 'busy' }) + } + created += 1 + return reply.code(201).send({ session: { id: `s${created}` } }) + }) + app.post('/trees/:id/autre', async () => { + created += 1 + return { ok: created } + }) + await app.ready() +}) + +afterAll(async () => { + await app.close() +}) + +beforeEach(() => { + created = 0 + failNext = false + resetIdempotency() +}) + +describe('Idempotency-Key', () => { + it('rejoue la première réponse sans recréer', async () => { + const first = await send({ 'idempotency-key': KEY }) + const second = await send({ 'idempotency-key': KEY }) + expect(created).toBe(1) + expect(second.statusCode).toBe(201) + expect(second.json()).toEqual(first.json()) + expect(second.headers['idempotent-replayed']).toBe('true') + }) + + it('deux essais simultanés : un seul est traité', async () => { + const [a, b] = await Promise.all([send({ 'idempotency-key': KEY }), send({ 'idempotency-key': KEY })]) + expect(created).toBe(1) + expect(a.json()).toEqual(b.json()) + }) + + it('un échec n’est pas gardé : l’essai suivant refait la requête', async () => { + failNext = true + const first = await send({ 'idempotency-key': KEY }) + const second = await send({ 'idempotency-key': KEY }) + expect(first.statusCode).toBe(503) + expect(second.statusCode).toBe(201) + expect(created).toBe(1) + }) + + it('la clé d’une autre personne ne rejoue rien', async () => { + await send({ 'idempotency-key': KEY }) + await send({ 'idempotency-key': KEY, authorization: 'Bearer quelquun-dautre' }) + expect(created).toBe(2) + }) + + it('sans clé, ou avec une clé mal formée, chaque envoi compte', async () => { + await send() + await send() + await send({ 'idempotency-key': 'court' }) + await send({ 'idempotency-key': 'court' }) + expect(created).toBe(4) + }) + + it('les autres routes ne sont pas concernées', async () => { + await send({ 'idempotency-key': KEY }, '/trees/t1/autre') + await send({ 'idempotency-key': KEY }, '/trees/t1/autre') + expect(created).toBe(2) + }) +}) diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js index c4310a7..b6e6efb 100644 --- a/frontend/src/api/client.js +++ b/frontend/src/api/client.js @@ -38,13 +38,44 @@ function timeoutSignal(options) { return AbortSignal.timeout(options.timeoutMs ?? (isLargeBody ? UPLOAD_TIMEOUT_MS : DEFAULT_TIMEOUT_MS)) } +// Envois sans doublon (backend/src/lib/idempotency.ts). Un envoi qui n'a pas abouti (délai dépassé, réseau coupé, +// erreur) garde sa clé, et le même envoi refait (même adresse, même contenu) la réutilise : si le premier avait en +// fait abouti, le serveur renvoie sa réponse au lieu de tout recréer. Une réussite libère la clé, et renvoyer le même +// contenu plus tard (un souvenir supprimé puis remis) est un nouvel envoi. +const unsettledKeys = new Map() + +// Empreinte rapide du contenu (cyrb53) : crypto.subtle n'existe pas hors HTTPS (auto-hébergement en réseau local) +function fingerprint(text) { + let h1 = 0xdeadbeef + let h2 = 0x41c6ce57 + for (let i = 0; i < text.length; i += 1) { + const ch = text.charCodeAt(i) + h1 = Math.imul(h1 ^ ch, 2654435761) + h2 = Math.imul(h2 ^ ch, 1597334677) + } + h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909) + h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909) + return `${text.length}:${(h2 >>> 0).toString(36)}${(h1 >>> 0).toString(36)}` +} + +// getRandomValues existe aussi hors HTTPS, contrairement à crypto.randomUUID +function newIdempotencyKey() { + const bytes = new Uint8Array(16) + crypto.getRandomValues(bytes) + return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('') +} + async function apiRequest(path, options = {}) { - const { timeoutMs: _timeoutMs, ...fetchOptions } = options + const { timeoutMs: _timeoutMs, idempotent = false, ...fetchOptions } = options const providedHeaders = options.headers || {} const hasExplicitContentType = Object.keys(providedHeaders).some((key) => key.toLowerCase() === 'content-type') + const print = idempotent ? `${path}|${fingerprint(typeof options.body === 'string' ? options.body : '')}` : null + if (print && !unsettledKeys.has(print)) unsettledKeys.set(print, newIdempotencyKey()) + const headers = { ...(options.body !== undefined && !hasExplicitContentType ? { 'Content-Type': 'application/json' } : {}), + ...(print ? { 'Idempotency-Key': unsettledKeys.get(print) } : {}), ...providedHeaders, } @@ -68,6 +99,7 @@ async function apiRequest(path, options = {}) { if (!response.ok) { throw new ApiError(payload?.error || `request_failed_${response.status}`, response.status, payload) } + if (print) unsettledKeys.delete(print) return payload } diff --git a/frontend/src/api/client.test.js b/frontend/src/api/client.test.js index f5a3de7..e4a12b7 100644 --- a/frontend/src/api/client.test.js +++ b/frontend/src/api/client.test.js @@ -1,36 +1,47 @@ import { afterEach, describe, expect, it, vi } from 'vitest' +import { apiRequest } from './client.js' -import { ApiError, apiRequest } from './client' +// Clé d'idempotence : gardée tant que l'envoi n'a pas abouti, libérée par une réussite +const keysSent = [] +const respond = (ok) => vi.fn(async (_url, init) => { + keysSent.push(init.headers['Idempotency-Key']) + if (ok === 'network') throw new TypeError('Failed to fetch') + return { ok, status: ok ? 201 : 503, text: async () => '{}' } +}) afterEach(() => { - vi.restoreAllMocks() + keysSent.length = 0 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) +const send = (body = '{"a":1}') => apiRequest('/trees/t/annotation-photos', { method: 'POST', idempotent: true, body }) + +describe('apiRequest, envois idempotents', () => { + it('un envoi refait après une coupure garde la même clé', async () => { + vi.stubGlobal('fetch', respond('network')) + await expect(send()).rejects.toThrow() + vi.stubGlobal('fetch', respond(false)) + await expect(send()).rejects.toThrow() + vi.stubGlobal('fetch', respond(true)) + await send() + expect(keysSent).toHaveLength(3) + expect(new Set(keysSent).size).toBe(1) + expect(keysSent[0]).toMatch(/^[0-9a-f]{32}$/) }) - 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('après une réussite, le même contenu repart avec une clé neuve', async () => { + vi.stubGlobal('fetch', respond(true)) + await send('{"b":2}') + await send('{"b":2}') + expect(keysSent[0]).not.toBe(keysSent[1]) }) - 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) + it('un autre contenu a sa propre clé, et un appel ordinaire n’en envoie pas', async () => { + vi.stubGlobal('fetch', respond('network')) + await expect(send('{"c":3}')).rejects.toThrow() + await expect(send('{"c":4}')).rejects.toThrow() + await expect(apiRequest('/trees/t', { method: 'POST', body: '{}' })).rejects.toThrow() + expect(keysSent[0]).not.toBe(keysSent[1]) + expect(keysSent[2]).toBeUndefined() }) }) diff --git a/frontend/src/api/treeApi.js b/frontend/src/api/treeApi.js index 776435e..993f2f0 100644 --- a/frontend/src/api/treeApi.js +++ b/frontend/src/api/treeApi.js @@ -71,6 +71,7 @@ export async function fetchPersonLife(treeId, personId, token) { export async function createLifeEvent(treeId, personId, payload, token) { return apiRequest(`/trees/${encodeURIComponent(treeId)}/persons/${encodeURIComponent(personId)}/life/events`, { method: 'POST', + idempotent: true, headers: { Authorization: `Bearer ${token}` }, body: JSON.stringify(payload), }) @@ -373,6 +374,7 @@ export async function listTreeVisits(treeId, token) { export async function submitContributionSession(treeId, payload, token) { const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/contributions/sessions`, { method: 'POST', + idempotent: true, headers: { Authorization: `Bearer ${token}`, }, @@ -421,6 +423,7 @@ export async function fetchContributionStatus(treeId, sessionId, token) { export async function uploadAnnotationPhoto(treeId, payload, token) { const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/annotation-photos`, { method: 'POST', + idempotent: true, headers: { Authorization: `Bearer ${token}`, }, @@ -433,6 +436,7 @@ export async function uploadAnnotationPhoto(treeId, payload, token) { export async function uploadPersonMedia(treeId, personId, payload, token) { const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/persons/${encodeURIComponent(personId)}/media`, { method: 'POST', + idempotent: true, headers: { Authorization: `Bearer ${token}`, },