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,