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,