From ced84333dbf99bb6fd9550d38db18a20d582a17e Mon Sep 17 00:00:00 2001 From: IliesAllali Date: Wed, 30 Sep 2026 17:57:26 +0200 Subject: [PATCH] =?UTF-8?q?Acc=C3=A8s=20:=20annotations=20prot=C3=A9g?= =?UTF-8?q?=C3=A9es,=20jeton=20m=C3=A9dias,=20vraie=20adresse=20des=20visi?= =?UTF-8?q?teurs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Les annotations se lisaient avec n'importe quel compte, et une annotation photo enregistrait l'adresse de l'image avec la session de son auteur. Accès vérifié, adresse retirée à l'écriture comme à la lecture. Les images portent un jeton de lecture des médias (12 h, un arbre) au lieu de la session du compte. trustProxy ne croit plus que nginx (TRUST_PROXY), l'API peut n'écouter que la boucle locale (HOST), plafond de 30 échecs par arbre sur le mot de passe de partage, changer ce mot de passe coupe les comptes rattachés, connexion à durée constante, secrets d'exemple refusés en production. Co-Authored-By: Claude Opus 5.5 --- .env.example | 9 ++- backend/.env.production.example | 4 ++ backend/src/app.ts | 27 ++++++++- backend/src/config/env.ts | 33 ++++++++++- backend/src/lib/annotation-content.ts | 45 +++++++++++++++ backend/src/lib/password-variants.ts | 4 +- backend/src/lib/tree-access.ts | 81 +++++++++++++++++++++++++++ backend/src/lib/unlock-guard.ts | 33 +++++++++++ backend/src/routes/annotations.ts | 47 +++++++++++++--- backend/src/routes/auth.ts | 9 +++ backend/src/routes/graph.ts | 3 + backend/src/routes/trees.ts | 12 ++++ backend/src/server.ts | 2 +- backend/src/types/auth.ts | 12 +++- backend/src/utils/graph-payload.ts | 4 +- 15 files changed, 308 insertions(+), 17 deletions(-) create mode 100644 backend/src/lib/annotation-content.ts create mode 100644 backend/src/lib/tree-access.ts create mode 100644 backend/src/lib/unlock-guard.ts diff --git a/.env.example b/.env.example index fba8a38..c6dcdee 100644 --- a/.env.example +++ b/.env.example @@ -13,7 +13,7 @@ POSTGRES_DB=parenthese POSTGRES_PASSWORD= # ── API ─────────────────────────────────────────────────────────────────────── -# À remplir, 16 caractères minimum. Générer avec : +# À remplir, 32 caractères conseillés. Générer avec : # openssl rand -base64 48 # ou : node -e "console.log(require('crypto').randomBytes(48).toString('base64'))" # L'API refuse de démarrer en production avec la valeur de développement. @@ -26,6 +26,13 @@ JWT_TREE_ACCESS_EXPIRES_IN=24h # Derrière un reverse proxy HTTPS : https://arbre.votre-domaine.fr # Plusieurs adresses : séparées par des virgules. CORS_ORIGIN=http://localhost +# Proxys dont l'API croit l'en-tête X-Forwarded-For (adresse des visiteurs, limites d'essais). +# Par défaut, le nginx du conteneur web (réseau Docker privé). Si vous ajoutez un reverse proxy +# devant, ajoutez son adresse, par exemple loopback,uniquelocal,203.0.113.10 +# TRUST_PROXY=loopback,uniquelocal +# Place disque par arbre (Mo), et part réservée aux envois de la famille en attente de relecture. +# MEDIA_QUOTA_PER_TREE_MB=5120 +# MEDIA_PENDING_QUOTA_PER_TREE_MB=500 # ── Web ─────────────────────────────────────────────────────────────────────── # Port publié sur la machine hôte (80 = http://localhost sans préciser de port). diff --git a/backend/.env.production.example b/backend/.env.production.example index 4687321..75296d2 100644 --- a/backend/.env.production.example +++ b/backend/.env.production.example @@ -4,6 +4,10 @@ NODE_ENV=production PORT=4000 +# nginx sur la même machine : l'API n'écoute que la boucle locale, et ne croit que nginx pour +# l'adresse des visiteurs (limites d'essais sur les mots de passe). +HOST=127.0.0.1 +TRUST_PROXY=loopback # Base PostgreSQL dédiée. L'API refuse de démarrer en production avec l'URL de développement. DATABASE_URL="postgresql://UTILISATEUR:MOT_DE_PASSE@localhost:5432/parenthese" diff --git a/backend/src/app.ts b/backend/src/app.ts index a3cf2f3..6c08fdf 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -18,14 +18,34 @@ import { treeRoutes } from './routes/trees.js' import { visitRoutes } from './routes/visits.js' import type { AnyJwtPayload } from './types/auth.js' +// Les adresses des médias portent un jeton ( n'envoie pas d'en-tête) : jamais dans les journaux +export function redactUrl(url: string | undefined): string | undefined { + return url?.replace(/([?&]token=)[^&#]*/g, '$1[masqué]') +} + export function createApp() { const app = Fastify({ - logger: env.NODE_ENV !== 'test', + logger: env.NODE_ENV === 'test' + ? false + : { + serializers: { + req(request) { + return { + method: request.method, + url: redactUrl(request.url), + host: request.host, + remoteAddress: request.ip, + } + }, + }, + }, bodyLimit: 12 * 1024 * 1024, // Accept both `/path` et `/path/` to avoid 301 → 400 issues via nginx routerOptions: { ignoreTrailingSlash: true }, - // Trust X-Forwarded-For from Nginx proxy so rate limiting uses real client IPs - trustProxy: true, + // Seuls les proxys connus (nginx) sont crus : l'adresse du client est la dernière qu'ils ont ajoutée à + // X-Forwarded-For. Faire confiance à tout l'en-tête laissait le client choisir son adresse et contourner + // les limites d'essais (voir TRUST_PROXY dans config/env.ts). + trustProxy: env.TRUST_PROXY, }) app.decorate('prisma', prisma) @@ -99,6 +119,7 @@ export function createApp() { kind: 'tree_access', treeId: payload.treeId, role: payload.role, + accessVersion: payload.accessVersion, } } } catch { diff --git a/backend/src/config/env.ts b/backend/src/config/env.ts index 233b2ee..38b9d31 100644 --- a/backend/src/config/env.ts +++ b/backend/src/config/env.ts @@ -6,9 +6,31 @@ loadDotenv() const DEV_DATABASE_URL = 'postgresql://postgres:postgres@localhost:5432/genealogy' const DEV_JWT_SECRET = 'dev-secret-change-me-please' +// Valeurs d'exemple publiées dans le dépôt : une instance qui les garde a un secret connu de tous +const PUBLISHED_PLACEHOLDER_SECRETS = [ + DEV_JWT_SECRET, + 'change-me-in-production', + 'changeme', +] + +// Adresses des proxys de confiance devant l'API (liste séparée par des virgules, mots-clés de proxy-addr : +// loopback, uniquelocal, linklocal, ou IP/CIDR). Par défaut : nginx sur la même machine (pm2) ou dans le +// réseau Docker (compose). L'adresse du client est la dernière entrée de X-Forwarded-For ajoutée par un +// proxy de confiance. `true` lirait l'adresse dans un en-tête que le client écrit lui-même, et Fastify +// ignore un nombre de sauts : ni l'un ni l'autre n'est accepté. +function parseTrustProxy(value: string): boolean | string { + if (value === 'false') return false + if (value === 'true' || /^\d+$/.test(value)) { + throw new Error('TRUST_PROXY must list the proxy addresses (e.g. "loopback,uniquelocal"), not true or a hop count') + } + return value +} + const envSchema = z.object({ NODE_ENV: z.enum(['development', 'test', 'production']).default('development'), PORT: z.coerce.number().int().positive().default(4000), + HOST: z.string().min(1).default('0.0.0.0'), + TRUST_PROXY: z.string().default('loopback,uniquelocal').transform(parseTrustProxy), DATABASE_URL: z.string().min(1).default(DEV_DATABASE_URL), JWT_SECRET: z.string().min(16).default(DEV_JWT_SECRET), JWT_EXPIRES_IN: z.string().default('7d'), @@ -18,13 +40,20 @@ const envSchema = z.object({ .default('http://localhost:5173,http://localhost:5174,http://localhost:5175,http://localhost:5176') .transform((value) => value.split(',').map((origin) => origin.trim()).filter(Boolean)), MEDIA_STORAGE_PATH: z.string().default('./storage/media'), + // Place disque par arbre, et part réservée aux envois de la famille en attente de relecture + MEDIA_QUOTA_PER_TREE_MB: z.coerce.number().int().positive().default(5120), + MEDIA_PENDING_QUOTA_PER_TREE_MB: z.coerce.number().int().positive().default(500), }) export const env = envSchema.parse(process.env) if (env.NODE_ENV === 'production') { - if (env.JWT_SECRET === DEV_JWT_SECRET) { - throw new Error('JWT_SECRET must be overridden in production') + if (PUBLISHED_PLACEHOLDER_SECRETS.includes(env.JWT_SECRET)) { + throw new Error('JWT_SECRET is a published example value: generate one with `openssl rand -base64 48`') + } + + if (env.JWT_SECRET.length < 32) { + console.warn('JWT_SECRET is shorter than 32 characters: generate a longer one with `openssl rand -base64 48`') } if (env.DATABASE_URL === DEV_DATABASE_URL) { diff --git a/backend/src/lib/annotation-content.ts b/backend/src/lib/annotation-content.ts new file mode 100644 index 0000000..92eef4b --- /dev/null +++ b/backend/src/lib/annotation-content.ts @@ -0,0 +1,45 @@ +// Annotation photo : le client enregistrait `url`, l'adresse de l'image AVEC la session de son auteur +// (?token=…), lisible par tous ceux qui ouvrent l'arbre. Le serveur ne garde que `photoPath` et le +// client reconstruit l'adresse au chargement. À la lecture, les anciennes valeurs sont nettoyées aussi. + +function parsePhotoContent(content: string): Record | null { + if (!content.startsWith('{')) return null + try { + const parsed = JSON.parse(content) + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed) && ('photoPath' in parsed || 'url' in parsed)) { + return parsed as Record + } + } catch { + // pas du JSON : texte libre + } + return null +} + +export class InvalidAnnotationContentError extends Error {} + +// Écriture : photoPath obligatoirement une photo de cet arbre, url retirée +export function sanitizeAnnotationContentForWrite(content: string, treeId: string): string { + const parsed = parsePhotoContent(content) + if (!parsed) return content + + const photoPath = parsed.photoPath + const expectedPrefix = `/trees/${treeId}/annotation-photos/` + if ( + typeof photoPath !== 'string' || + !photoPath.startsWith(expectedPrefix) || + !/^[A-Za-z0-9-]+$/.test(photoPath.slice(expectedPrefix.length)) + ) { + throw new InvalidAnnotationContentError('invalid_annotation_photo_path') + } + + const { url: _url, ...rest } = parsed + return JSON.stringify(rest) +} + +// Lecture : jamais d'adresse enregistrée renvoyée +export function sanitizeAnnotationContentForRead(content: string): string { + const parsed = parsePhotoContent(content) + if (!parsed || !('url' in parsed)) return content + const { url: _url, ...rest } = parsed + return JSON.stringify(rest) +} diff --git a/backend/src/lib/password-variants.ts b/backend/src/lib/password-variants.ts index b62d932..14bbd52 100644 --- a/backend/src/lib/password-variants.ts +++ b/backend/src/lib/password-variants.ts @@ -16,8 +16,10 @@ export async function matchSharePassword( hashes: string[], verify: (password: string, hash: string) => Promise, ): Promise { + // Un mot de passe unique est rangé dans les deux colonnes avec le même hash : une seule comparaison bcrypt + const distinctHashes = [...new Set(hashes)] for (const candidate of sharePasswordVariants(input)) { - for (const hash of hashes) { + for (const hash of distinctHashes) { if (await verify(candidate, hash)) return candidate } } diff --git a/backend/src/lib/tree-access.ts b/backend/src/lib/tree-access.ts new file mode 100644 index 0000000..03685c5 --- /dev/null +++ b/backend/src/lib/tree-access.ts @@ -0,0 +1,81 @@ +import type { FastifyInstance } from 'fastify' + +import type { Actor, MediaJwtPayload } from '../types/auth.js' + +// Lecture d'un arbre : membre, compte rattaché par le mot de passe de partage, ou jeton d'arbre valide +export async function canReadTree(app: FastifyInstance, treeId: string, actor: Actor): Promise { + if (!actor) { + return false + } + + if (actor.kind === 'user') { + const membership = await app.prisma.treeMembership.findUnique({ + where: { treeId_userId: { treeId, userId: actor.userId } }, + include: { tree: { select: { deletedAt: true } } }, + }) + if (membership && !membership.tree.deletedAt) { + return true + } + + const shared = await app.prisma.userTreeAccess.findUnique({ + where: { treeId_userId: { treeId, userId: actor.userId } }, + include: { tree: { select: { deletedAt: true } } }, + }) + return Boolean(shared && !shared.tree.deletedAt) + } + + if (actor.treeId !== treeId) { + return false + } + + // Le jeton d'arbre a déjà été confronté à la version des mots de passe dans le preHandler + const tree = await app.prisma.tree.findFirst({ where: { id: treeId, deletedAt: null }, select: { id: true } }) + return Boolean(tree) +} + +// Les images sont chargées par , qui ne peut pas envoyer d'en-tête : leur adresse porte un jeton. +// Ce jeton ne sert qu'à lire les médias d'un arbre, jamais la session du compte (qui finissait dans les +// journaux, l'historique et les liens copiés). +export const MEDIA_TOKEN_TTL = '12h' + +export function signMediaToken(app: FastifyInstance, treeId: string, actor: NonNullable): string { + const payload: MediaJwtPayload = { + kind: 'media', + sub: `media:${treeId}`, + treeId, + ...(actor.kind === 'user' ? { userId: actor.userId } : { accessVersion: actor.accessVersion }), + } + return app.jwt.sign(payload, { expiresIn: MEDIA_TOKEN_TTL }) +} + +export async function canReadTreeWithMediaToken(app: FastifyInstance, treeId: string, token: string): Promise { + let payload: { kind?: string; treeId?: string; userId?: string; accessVersion?: number } + try { + payload = await app.jwt.verify(token) + } catch { + return false + } + + if (payload.kind !== 'media' || payload.treeId !== treeId) { + return false + } + + // Émis pour un compte : ses droits sont relus, un accès retiré coupe aussi les médias + if (payload.userId) { + const user = await app.prisma.user.count({ where: { id: payload.userId } }) + if (user === 0) { + return false + } + return canReadTree(app, treeId, { kind: 'user', userId: payload.userId, email: '' }) + } + + // Émis pour un mot de passe de partage : invalide dès que le mot de passe change + const accessState = await app.prisma.treeAccessPasswords.findUnique({ + where: { treeId }, + select: { updatedAt: true, tree: { select: { deletedAt: true } } }, + }) + if (!accessState || accessState.tree.deletedAt) { + return false + } + return accessState.updatedAt.getTime() === payload.accessVersion +} diff --git a/backend/src/lib/unlock-guard.ts b/backend/src/lib/unlock-guard.ts new file mode 100644 index 0000000..25db29d --- /dev/null +++ b/backend/src/lib/unlock-guard.ts @@ -0,0 +1,33 @@ +// Plafond d'échecs par arbre, en plus de la limite par adresse : un mot de passe de partage est choisi par +// une famille (« 123soleil »), un dictionnaire envoyé depuis beaucoup d'adresses finirait par le trouver. +// Au-delà du plafond, l'arbre refuse les essais jusqu'à la fin de la fenêtre, même le bon mot de passe. +// En mémoire : l'API tourne en un seul processus (pm2 fork, un conteneur). + +const WINDOW_MS = 15 * 60 * 1000 +const MAX_FAILURES = 30 + +const failuresByTree = new Map() + +function recentFailures(treeId: string, now: number): number[] { + const recent = (failuresByTree.get(treeId) ?? []).filter((at) => now - at < WINDOW_MS) + if (recent.length > 0) { + failuresByTree.set(treeId, recent) + } else { + failuresByTree.delete(treeId) + } + return recent +} + +export function isTreeUnlockLocked(treeId: string, now = Date.now()): boolean { + return recentFailures(treeId, now).length >= MAX_FAILURES +} + +export function recordTreeUnlockFailure(treeId: string, now = Date.now()): void { + const recent = recentFailures(treeId, now) + recent.push(now) + failuresByTree.set(treeId, recent) +} + +export function resetTreeUnlockGuard(): void { + failuresByTree.clear() +} diff --git a/backend/src/routes/annotations.ts b/backend/src/routes/annotations.ts index 00d8f6f..3b4b6a0 100644 --- a/backend/src/routes/annotations.ts +++ b/backend/src/routes/annotations.ts @@ -2,6 +2,12 @@ import type { FastifyInstance, FastifyPluginAsync, FastifyReply, FastifyRequest import { Prisma } from '@prisma/client' import { z } from 'zod' +import { + InvalidAnnotationContentError, + sanitizeAnnotationContentForRead, + sanitizeAnnotationContentForWrite, +} from '../lib/annotation-content.js' +import { canReadTree } from '../lib/tree-access.js' import type { MembershipRole } from '../types/auth.js' const treeIdParamSchema = z.object({ @@ -59,6 +65,16 @@ function requireUser(request: FastifyRequest, reply: FastifyReply): { userId: st } } +// null = chemin de photo refusé (voir lib/annotation-content.ts) +function cleanContent(content: string, treeId: string): string | null { + try { + return sanitizeAnnotationContentForWrite(content, treeId) + } catch (error) { + if (error instanceof InvalidAnnotationContentError) return null + throw error + } +} + function hasAdminRight(role: MembershipRole): boolean { return role === 'owner' || role === 'admin' } @@ -97,13 +113,16 @@ export const annotationRoutes: FastifyPluginAsync = async (app) => { const membership = await getAdminMembership(app, user.userId, params.data.id) if (!hasActiveAdminMembership(membership)) return reply.code(403).send({ error: 'forbidden' }) + const content = cleanContent(payload.data.content, params.data.id) + if (content === null) return reply.code(400).send({ error: 'invalid_annotation_photo_path' }) + const annotation = await app.prisma.annotation.create({ data: { treeId: params.data.id, type: payload.data.type, x: payload.data.x, y: payload.data.y, - content: payload.data.content, + content, style: (payload.data.style ?? {}) as Prisma.InputJsonValue, zIndex: payload.data.zIndex ?? 0, createdBy: user.userId, @@ -131,6 +150,7 @@ export const annotationRoutes: FastifyPluginAsync = async (app) => { if (!params.success) return reply.code(400).send({ error: 'invalid_tree_id' }) if (!request.actor) return reply.code(401).send({ error: 'authentication_required' }) + if (!(await canReadTree(app, params.data.id, request.actor))) return reply.code(403).send({ error: 'forbidden' }) const annotations = await app.prisma.annotation.findMany({ where: { @@ -140,7 +160,9 @@ export const annotationRoutes: FastifyPluginAsync = async (app) => { orderBy: [{ zIndex: 'asc' }, { createdAt: 'asc' }, { id: 'asc' }], }) - return reply.send({ annotations }) + return reply.send({ + annotations: annotations.map((annotation) => ({ ...annotation, content: sanitizeAnnotationContentForRead(annotation.content) })), + }) }) // UPDATE annotation @@ -160,7 +182,11 @@ export const annotationRoutes: FastifyPluginAsync = async (app) => { const data: Record = {} if (payload.data.x !== undefined) data.x = payload.data.x if (payload.data.y !== undefined) data.y = payload.data.y - if (payload.data.content !== undefined) data.content = payload.data.content + if (payload.data.content !== undefined) { + const content = cleanContent(payload.data.content, params.data.id) + if (content === null) return reply.code(400).send({ error: 'invalid_annotation_photo_path' }) + data.content = content + } if (payload.data.style !== undefined) data.style = payload.data.style if (payload.data.zIndex !== undefined) data.zIndex = payload.data.zIndex @@ -249,15 +275,22 @@ export const annotationRoutes: FastifyPluginAsync = async (app) => { const treeId = params.data.id const results = { created: 0, updated: 0, deleted: 0 } + // Tout est vérifié avant la première écriture + const createContents = payload.data.create.map((item) => cleanContent(item.content, treeId)) + const updateContents = payload.data.update.map((item) => (item.content === undefined ? undefined : cleanContent(item.content, treeId))) + if (createContents.includes(null) || updateContents.includes(null)) { + return reply.code(400).send({ error: 'invalid_annotation_photo_path' }) + } + // Creates - for (const item of payload.data.create) { + for (const [index, item] of payload.data.create.entries()) { await app.prisma.annotation.create({ data: { treeId, type: item.type, x: item.x, y: item.y, - content: item.content, + content: createContents[index] as string, style: (item.style ?? {}) as Prisma.InputJsonValue, zIndex: item.zIndex ?? 0, createdBy: user.userId, @@ -267,11 +300,11 @@ export const annotationRoutes: FastifyPluginAsync = async (app) => { } // Updates - for (const item of payload.data.update) { + for (const [index, item] of payload.data.update.entries()) { const data: Record = {} if (item.x !== undefined) data.x = item.x if (item.y !== undefined) data.y = item.y - if (item.content !== undefined) data.content = item.content + if (item.content !== undefined) data.content = updateContents[index] if (item.style !== undefined) data.style = item.style if (item.zIndex !== undefined) data.zIndex = item.zIndex diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index bb22acf..0701a78 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -8,6 +8,13 @@ import { revokeUserToken } from '../lib/token-revocation.js' import { deleteTreeRecords, removeTreeMediaDirectories } from '../lib/tree-purge.js' import type { AnyJwtPayload } from '../types/auth.js' +// Hash de comparaison pour une adresse inconnue, calculé une fois au premier besoin +let dummyHash: Promise | null = null +function getDummyHash(): Promise { + dummyHash ??= hashPassword(randomUUID()) + return dummyHash +} + const credentialsSchema = z.object({ email: z.string().email(), password: z.string().min(8).max(128), @@ -152,6 +159,8 @@ export const authRoutes: FastifyPluginAsync = async (app) => { }) if (!user) { + // Même durée qu'un mauvais mot de passe : la réponse ne dit pas si l'adresse a un compte + await verifyPassword(parsed.data.password, await getDummyHash()) return reply.code(401).send({ error: 'invalid_credentials' }) } diff --git a/backend/src/routes/graph.ts b/backend/src/routes/graph.ts index 5100ddc..79824ce 100644 --- a/backend/src/routes/graph.ts +++ b/backend/src/routes/graph.ts @@ -1,6 +1,7 @@ import type { FastifyPluginAsync } from 'fastify' import { z } from 'zod' +import { signMediaToken } from '../lib/tree-access.js' import { recordTreeVisit, type VisitAccessKind } from '../lib/tree-visits.js' import { buildGraphPayload } from '../utils/graph-payload.js' @@ -143,6 +144,8 @@ export const graphRoutes: FastifyPluginAsync = async (app) => { treeId, rootPersonId, graph, + // Pour l'adresse des images ( n'envoie pas la session) : lecture des médias de cet arbre seulement + mediaToken: signMediaToken(app, treeId, actor), }) }) } diff --git a/backend/src/routes/trees.ts b/backend/src/routes/trees.ts index fd3715e..9d8096f 100644 --- a/backend/src/routes/trees.ts +++ b/backend/src/routes/trees.ts @@ -7,6 +7,7 @@ import { hashPassword, verifyPassword } from '../lib/auth.js' import { matchSharePassword } from '../lib/password-variants.js' import { decryptSharePassword, encryptSharePassword } from '../lib/share-password.js' import { purgeTrees } from '../lib/tree-purge.js' +import { isTreeUnlockLocked, recordTreeUnlockFailure } from '../lib/unlock-guard.js' import type { MembershipRole } from '../types/auth.js' import { findParentChildValidationError, findUnionValidationError } from '../utils/relationship-guards.js' @@ -694,6 +695,10 @@ export const treeRoutes: FastifyPluginAsync = async (app) => { return reply.code(404).send({ error: 'tree_not_found' }) } + if (isTreeUnlockLocked(params.data.id)) { + return reply.code(429).send({ error: 'too_many_attempts' }) + } + // Un seul mot de passe de partage (25/09/2026) : regarder et proposer. Les deux colonnes restent, // les arbres créés avant ont encore deux mots de passe différents, les deux ouvrent en contributeur. // Apostrophe typographique ou espace finale ajoutées par un clavier de téléphone : acceptées (password-variants.ts) @@ -704,6 +709,7 @@ export const treeRoutes: FastifyPluginAsync = async (app) => { ) if (!matches) { + recordTreeUnlockFailure(params.data.id) return reply.code(401).send({ error: 'invalid_password' }) } @@ -972,6 +978,12 @@ export const treeRoutes: FastifyPluginAsync = async (app) => { data, }) + // Changer le mot de passe met dehors ceux qui l'avaient : les jetons d'arbre tombent avec la version, + // les comptes rattachés par ce mot de passe perdent aussi leur accès (ils le retapent s'ils ont le nouveau) + if (Object.keys(data).length > 0) { + await app.prisma.userTreeAccess.deleteMany({ where: { treeId: params.data.id } }) + } + // Le mot de passe lisible suit toujours celui en vigueur ; deux mots de passe distincts = plus de valeur unique if (payload.data.password) { const passwordEnc = encryptSharePassword(payload.data.password) diff --git a/backend/src/server.ts b/backend/src/server.ts index f9c48ac..e0a6289 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -7,7 +7,7 @@ async function bootstrap() { try { await app.listen({ port: env.PORT, - host: '0.0.0.0', + host: env.HOST, }) } catch (error) { app.log.error(error) diff --git a/backend/src/types/auth.ts b/backend/src/types/auth.ts index b0e0c00..f9e36cb 100644 --- a/backend/src/types/auth.ts +++ b/backend/src/types/auth.ts @@ -17,7 +17,16 @@ export type TreeAccessJwtPayload = { accessVersion: number } -export type AnyJwtPayload = UserJwtPayload | TreeAccessJwtPayload +// Lecture seule des médias d'un arbre, dans l'adresse des images (voir lib/tree-access.ts) +export type MediaJwtPayload = { + kind: 'media' + sub: string + treeId: string + userId?: string + accessVersion?: number +} + +export type AnyJwtPayload = UserJwtPayload | TreeAccessJwtPayload | MediaJwtPayload export type UserActor = { kind: 'user' @@ -29,6 +38,7 @@ export type TreeAccessActor = { kind: 'tree_access' treeId: string role: TreeAccessRole + accessVersion: number } export type Actor = UserActor | TreeAccessActor | null diff --git a/backend/src/utils/graph-payload.ts b/backend/src/utils/graph-payload.ts index 7316904..ea847dc 100644 --- a/backend/src/utils/graph-payload.ts +++ b/backend/src/utils/graph-payload.ts @@ -1,5 +1,7 @@ import type { Annotation, MediaItem, ParentChildLink, Person, Union } from '@prisma/client' +import { sanitizeAnnotationContentForRead } from '../lib/annotation-content.js' + export type GraphPayload = { persons: Array<{ id: string @@ -122,7 +124,7 @@ export function buildGraphPayload( type: ann.type, x: ann.x, y: ann.y, - content: ann.content, + content: sanitizeAnnotationContentForRead(ann.content), style: ann.style, zIndex: ann.zIndex, createdBy: ann.createdBy,