mirror of
https://github.com/IliesAllali/parenthese.git
synced 2026-11-09 23:50:58 +00:00
Accès : annotations protégées, jeton médias, vraie adresse des visiteurs
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 <noreply@anthropic.com>
This commit is contained in:
1 parent
78445589de
commit
ced84333db
15 files changed
+308
-17
No files matched your search
+8
-1
@@ -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).
|
||||
|
||||
@@ -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"
|
||||
|
||||
+24
-3
@@ -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 (<img src> 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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<string, unknown> | 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<string, unknown>
|
||||
}
|
||||
} 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)
|
||||
}
|
||||
@@ -16,8 +16,10 @@ export async function matchSharePassword(
|
||||
hashes: string[],
|
||||
verify: (password: string, hash: string) => Promise<boolean>,
|
||||
): Promise<string | null> {
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<boolean> {
|
||||
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 <img src>, 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<Actor>): 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<boolean> {
|
||||
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
|
||||
}
|
||||
@@ -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<string, number[]>()
|
||||
|
||||
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()
|
||||
}
|
||||
@@ -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<string, unknown> = {}
|
||||
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<string, unknown> = {}
|
||||
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
|
||||
|
||||
|
||||
@@ -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<string> | null = null
|
||||
function getDummyHash(): Promise<string> {
|
||||
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' })
|
||||
}
|
||||
|
||||
|
||||
@@ -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 (<img src> n'envoie pas la session) : lecture des médias de cet arbre seulement
|
||||
mediaToken: signMediaToken(app, treeId, actor),
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user