Sécurité 1.0.1 : audit indépendant de la 1.0

- Vidéo piégée : l'expression régulière de position était quadratique, un faux MP4 de 2 Mo figeait l'API (bornée, linéaire)
- Sessions émises avant le 01/10/2026 refusées (SESSIONS_NOT_BEFORE) : des sessions ont fuité dans les annotations et les journaux jusqu'à la 1.0
- Contributions : l'« avant » montré au propriétaire vient de la base ; une suppression approuvée part en cascade (unions, liens, souvenirs, portrait, fichiers)
- Mot de passe de partage : essai compté avant bcrypt, comparaisons une par une, changement et rattachement dans une transaction
- Quota mesuré sur le disque, 10 arbres par compte, corps à 1 Mo hors envois, bornes sur annotations, contributions et GEDCOM
- GIF réencodés ; anciennes propositions d'annotation sans adresse stockée ; clé d'exemple refusée sans NODE_ENV
- Déploiement du commit validé par la CI, clé d'hôte obligatoire ; nginx : X-Forwarded-For écrasé, version masquée, journal sans query string
- PostHog verrouillé dans le code, sans script externe ; CSP sans *.posthog.com ; sauvegardes hors serveur chiffrées par défaut

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
IliesAllaliandClaude Opus 5.5 committed 2026-10-01 12:19:41 +02:00
1 parent ee7d2809fc
commit 75922f3ed3
23 files changed
+717 -296

No files matched your search

+28 -16
View File
@@ -3,8 +3,9 @@ name: Deploy Hostinger
# Déploie Parenthèse sur le VPS quand le workflow « CI » a réussi sur un push sur main, ou à la main.
# Le travail est fait sur le serveur par deploy/deploy.sh, incrémental et verrouillé : seules les parties
# modifiées sont reconstruites, et un run sans changement ne reconstruit rien.
# Le serveur déploie origin/main au moment du run (fetch + reset) : si deux pushs se suivent de près,
# c'est le plus récent qui part, sa propre CI déclenchera ensuite un run sans changement.
# Le serveur déploie le commit exact que la CI a validé (head_sha du run), jamais la tête de main au
# moment du run : un push arrivé entre-temps, CI rouge ou en cours, partait sinon en production sans contrôle.
# Un run plus ancien qu'un déploiement déjà fait ne revient pas en arrière.
on:
workflow_run:
workflows:
@@ -53,24 +54,20 @@ jobs:
ssh-private-key: ${{ secrets.HOSTINGER_SSH_PRIVATE_KEY }}
# Clé d'hôte épinglée par le secret SSH_KNOWN_HOSTS (sortie de ssh-keyscan relue à la main, voir
# deploy/HOSTINGER_GITHUB_SETUP.md). Sans ce secret, repli sur ssh-keyscan au moment du run, qui
# fait confiance à la clé présentée ce jour-là : un avertissement le signale dans le run.
# deploy/HOSTINGER_GITHUB_SETUP.md). Sans ce secret, le déploiement s'arrête : faire confiance à la clé
# présentée le jour du run enverrait la clé de déploiement à n'importe quel serveur intercalé.
- name: Hostinger host key
env:
HOSTINGER_HOST: ${{ secrets.HOSTINGER_HOST }}
HOSTINGER_PORT: ${{ secrets.HOSTINGER_PORT }}
SSH_KNOWN_HOSTS: ${{ secrets.SSH_KNOWN_HOSTS }}
run: |
set -euo pipefail
PORT="${HOSTINGER_PORT:-22}"
if [ -z "$SSH_KNOWN_HOSTS" ]; then
echo "::error::Secret SSH_KNOWN_HOSTS absent : ajoutez la clé d'hôte du VPS (voir deploy/HOSTINGER_GITHUB_SETUP.md)."
exit 1
fi
mkdir -p ~/.ssh
chmod 700 ~/.ssh
if [ -n "$SSH_KNOWN_HOSTS" ]; then
printf '%s\n' "$SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts
else
echo "::warning::Secret SSH_KNOWN_HOSTS absent : clé d'hôte récupérée par ssh-keyscan, sans vérification. Ajoutez le secret."
ssh-keyscan -T 5 -p "$PORT" -H "$HOSTINGER_HOST" >> ~/.ssh/known_hosts || true
fi
printf '%s\n' "$SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts
chmod 600 ~/.ssh/known_hosts
- name: Deploy on Hostinger
@@ -79,19 +76,34 @@ jobs:
HOSTINGER_PORT: ${{ secrets.HOSTINGER_PORT }}
HOSTINGER_USER: ${{ secrets.HOSTINGER_USER }}
FORCE_FULL: ${{ inputs.full && '1' || '0' }}
DEPLOY_SHA: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || github.sha }}
run: |
set -euo pipefail
PORT="${HOSTINGER_PORT:-22}"
if ! printf '%s' "$DEPLOY_SHA" | grep -Eq '^[0-9a-f]{40}$'; then
echo "::error::Commit à déployer invalide : $DEPLOY_SHA"
exit 1
fi
# backend/.env vit sur le serveur, jamais dans GitHub (dépôt public)
ssh -p "$PORT" -o StrictHostKeyChecking=yes -o ServerAliveInterval=30 "$HOSTINGER_USER@$HOSTINGER_HOST" "FORCE_FULL=$FORCE_FULL bash -s" <<'EOF'
ssh -p "$PORT" -o StrictHostKeyChecking=yes -o ServerAliveInterval=30 "$HOSTINGER_USER@$HOSTINGER_HOST" "FORCE_FULL=$FORCE_FULL DEPLOY_SHA=$DEPLOY_SHA bash -s" <<'EOF'
set -euo pipefail
cd /var/www/parenthese
# Sous le même verrou que deploy.sh : récupération du code, puis déploiement
flock -w 900 /tmp/parenthese-deploy.lock env PARENTHESE_DEPLOY_LOCKED=1 FORCE_FULL="$FORCE_FULL" bash -c '
flock -w 900 /tmp/parenthese-deploy.lock env PARENTHESE_DEPLOY_LOCKED=1 FORCE_FULL="$FORCE_FULL" DEPLOY_SHA="$DEPLOY_SHA" bash -c '
set -euo pipefail
git fetch -q origin main
git reset -q --hard origin/main
# Le commit validé doit appartenir à main
if ! git merge-base --is-ancestor "$DEPLOY_SHA" origin/main; then
echo "Commit $DEPLOY_SHA absent de main : rien n est déployé" >&2
exit 1
fi
# Un déploiement plus récent est déjà en ligne : pas de retour en arrière
if [ "$(git rev-parse HEAD)" != "$DEPLOY_SHA" ] && git merge-base --is-ancestor "$DEPLOY_SHA" HEAD; then
echo "Version plus récente déjà déployée, $DEPLOY_SHA ignoré"
exit 0
fi
git reset -q --hard "$DEPLOY_SHA"
bash deploy/deploy.sh
'
EOF
+27
View File
@@ -2,6 +2,33 @@
Notable changes to Parenthèse. Dates are in YYYY-MM-DD format.
## 1.0.1 (2026-10-01)
Security release, from an independent review of 1.0.0.
### Security
- A crafted video file could block the whole API for hours (the location-scrubbing pattern ran in quadratic time). It now runs in linear time.
- Account sessions issued before 2026-10-01 are no longer accepted, so every user signs in again once. Until 1.0.0, a session could end up in photo annotations and in server logs. `SESSIONS_NOT_BEFORE` moves this date without rotating `JWT_SECRET`.
- In a contribution, the "before" state shown to the owner now comes from the database, not from the contributor. A relative could previously label a deletion with another person's name.
- Deleting a person through an approved contribution now removes their unions, links, memories and portrait, exactly like the owner's own deletion. Their files are erased from disk on both paths.
- Sharing-password attempts are counted before the check, so a burst of parallel requests can no longer go past the per-tree limit. Password checks run one at a time, so password guessing cannot saturate the API.
- Changing the sharing password and linking an account now run in a single transaction, and an unlock started just before the change can no longer keep access.
- The storage quota counts the actual disk usage of a tree, portraits and annotation photos included. An account can own at most 10 trees.
- Size limits: request bodies are capped at 1 MB outside upload routes; contributions, annotations (number per tree, batch size, style size) and GEDCOM imports (lines, links, one import at a time) are bounded.
- GIF files are re-encoded, which bounds their size and strips their metadata.
- Old annotation contributions no longer return a stored image address.
- The server refuses to start with a published example `JWT_SECRET` when `NODE_ENV` is not set.
### Deployment and self-hosting
- Production deploys the exact commit the CI validated, never an older one, and stops if the SSH host key is not pinned.
- nginx: `X-Forwarded-For` is set to the connecting address instead of being appended to; the version is hidden; the production access log drops query strings, which carried media tokens.
- The PostHog script no longer loads anything from PostHog's servers, and the dashboard can no longer turn on click, heatmap or error capture. The CSP now names the two PostHog hosts instead of `*.posthog.com`.
- `deploy/backup.sh` no longer uploads unencrypted backups unless `BACKUP_ALLOW_PLAINTEXT=1` is set.
Correction to 1.0.0: not every fix listed there came with an automated test.
## 1.0.0 (2026-09-30)
First public release. The code has been public since 2026-09-14; this version marks the app as ready to self-host.
+8 -1
View File
@@ -39,7 +39,9 @@ export function createApp() {
},
},
},
bodyLimit: 12 * 1024 * 1024,
// 1 Mo par défaut : le JSON est lu avant toute vérification d'identité. Les envois de fichiers, l'import
// GEDCOM, les contributions et les annotations groupées montent à UPLOAD_BODY_LIMIT sur leur route.
bodyLimit: 1024 * 1024,
// Accept both `/path` et `/path/` to avoid 301 → 400 issues via nginx
routerOptions: { ignoreTrailingSlash: true },
// Seuls les proxys connus (nginx) sont crus : l'adresse du client est la dernière qu'ils ont ajoutée à
@@ -81,6 +83,11 @@ export function createApp() {
return
}
// Sessions émises avant SESSIONS_NOT_BEFORE refusées (voir config/env.ts)
if (typeof payload.iat !== 'number' || payload.iat * 1000 < env.SESSIONS_NOT_BEFORE.getTime()) {
return
}
// Un compte supprimé invalide aussi les sessions ouvertes sur d'autres appareils
const accountCount = await app.prisma.user.count({ where: { id: payload.userId } })
if (accountCount === 0) {
+15
View File
@@ -36,6 +36,11 @@ const envSchema = z.object({
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'),
// Les sessions de compte émises avant cette date sont refusées. Jusqu'au 30/09/2026, des sessions complètes
// ont pu être écrites dans les annotations photo et les journaux : elles tombent toutes, chacun se reconnecte
// une fois. Avancer cette date (ISO 8601) déconnecte tout le monde, sans changer JWT_SECRET (qui chiffre aussi
// les mots de passe de partage lisibles).
SESSIONS_NOT_BEFORE: z.coerce.date().default(new Date('2026-10-01T00:00:00Z')),
JWT_TREE_ACCESS_EXPIRES_IN: z.string().default('24h'),
CORS_ORIGIN: z
.string()
@@ -49,6 +54,16 @@ const envSchema = z.object({
export const env = envSchema.parse(process.env)
// Corps maximal des routes qui reçoivent un fichier en base64 ou un gros envoi (le reste de l'API : 1 Mo)
export const UPLOAD_BODY_LIMIT = 12 * 1024 * 1024
// Sans NODE_ENV, le schéma suppose « development » et la clé de signature retomberait sur une valeur publiée :
// un `node dist/server.js` lancé à la main avec un .env incomplet signerait alors les sessions avec une clé
// connue de tous. Hors développement ou test déclaré, une valeur publiée est refusée.
if (!process.env.NODE_ENV && PUBLISHED_PLACEHOLDER_SECRETS.includes(env.JWT_SECRET)) {
throw new Error('JWT_SECRET is a published example value and NODE_ENV is not set: set NODE_ENV=development for local work, or generate a secret with `openssl rand -base64 48`')
}
if (env.NODE_ENV === '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`')
+114
View File
@@ -0,0 +1,114 @@
import type { Prisma, PrismaClient } from '@prisma/client'
type Db = PrismaClient | Prisma.TransactionClient
// Suppression d'une personne ou d'une union, avec tout ce qui en dépend. Un seul chemin pour la route du
// propriétaire et pour une contribution approuvée : la voie contribution ne marquait que la personne, et ses
// photos, unions et liens restaient servis à toute la famille.
//
// Les fichiers ne sont pas effacés ici (on est souvent dans une transaction qui peut encore échouer) : la
// fonction renvoie leurs chemins, à passer à removeStoredFile une fois la transaction validée. Les garder sur
// le disque laissait aussi contourner le quota, qui ne compte que les médias non supprimés.
export type DeletionResult = {
unions: number
links: number
medias: number
filePaths: string[]
}
export async function softDeletePersonCascade(
db: Db,
treeId: string,
personId: string,
actorUserId: string | null,
now = new Date(),
): Promise<DeletionResult | null> {
const person = await db.person.findFirst({
where: { id: personId, treeId, deletedAt: null },
select: { avatarPath: true },
})
if (!person) return null
await db.person.updateMany({
where: { id: personId, treeId, deletedAt: null },
data: { deletedAt: now, updatedBy: actorUserId, avatarPath: null, avatarMimeType: null },
})
const unions = await db.union.findMany({
where: {
treeId,
deletedAt: null,
OR: [{ partner1PersonId: personId }, { partner2PersonId: personId }],
},
select: { id: true },
})
const unionIds = unions.map((union) => union.id)
const medias = await db.mediaItem.findMany({
where: { treeId, personId, deletedAt: null },
select: { filePath: true, thumbPath: true },
})
const [deletedUnions, deletedLinks, deletedMedia] = await Promise.all([
db.union.updateMany({
where: { treeId, id: { in: unionIds }, deletedAt: null },
data: { deletedAt: now },
}),
// Liens de la personne, et liens qui passaient par une de ses unions
db.parentChildLink.updateMany({
where: {
treeId,
deletedAt: null,
OR: [
{ parentPersonId: personId },
{ childPersonId: personId },
...(unionIds.length > 0 ? [{ viaUnionId: { in: unionIds } }] : []),
],
},
data: { deletedAt: now },
}),
db.mediaItem.updateMany({
where: { treeId, personId, deletedAt: null },
data: { deletedAt: now },
}),
])
// Racine de l'arbre supprimée : le graphe repart sans racine plutôt que sur une personne effacée
await db.tree.updateMany({
where: { id: treeId, rootPersonId: personId },
data: { rootPersonId: null },
})
const filePaths = [
person.avatarPath,
...medias.flatMap((media) => [media.filePath, media.thumbPath]),
].filter((value): value is string => Boolean(value) && !/^https?:\/\//.test(value as string))
return {
unions: deletedUnions.count,
links: deletedLinks.count,
medias: deletedMedia.count,
filePaths,
}
}
export async function softDeleteUnionCascade(
db: Db,
treeId: string,
unionId: string,
now = new Date(),
): Promise<{ links: number } | null> {
const deletedUnion = await db.union.updateMany({
where: { id: unionId, treeId, deletedAt: null },
data: { deletedAt: now },
})
if (deletedUnion.count === 0) return null
const deletedLinks = await db.parentChildLink.updateMany({
where: { treeId, viaUnionId: unionId, deletedAt: null },
data: { deletedAt: now },
})
return { links: deletedLinks.count }
}
+8 -4
View File
@@ -136,11 +136,13 @@ export function extensionForMime(mimeType: string): string {
export class InvalidMediaError extends Error {}
// Photos : rotation du téléphone appliquée aux pixels, puis réencodage sans aucune métadonnée
// (GPS, appareil, date). Un GIF n'a pas d'EXIF et peut être animé : il passe tel quel.
// (GPS, appareil, date). Un GIF peut être animé : réencodé image par image, ce qui borne ses dimensions et
// retire ses blocs XMP et commentaires (il passait tel quel, sans aucune limite).
export async function sanitizeImage(buf: Buffer, mimeType: string): Promise<Buffer> {
if (mimeType === 'image/gif') return buf
try {
if (mimeType === 'image/gif') {
return await sharp(buf, { animated: true, limitInputPixels: 50_000_000, failOn: 'error' }).gif().toBuffer()
}
const image = sharp(buf, { limitInputPixels: 50_000_000, failOn: 'error' }).rotate()
if (mimeType === 'image/jpeg') return await image.jpeg({ quality: 90, mozjpeg: true }).toBuffer()
if (mimeType === 'image/png') return await image.png().toBuffer()
@@ -173,7 +175,9 @@ export function scrubMp4Location(buf: Buffer): Buffer {
const end = Math.min(offset + size, buf.length)
const region = buf.subarray(offset + header, end)
const text = region.toString('latin1')
const pattern = /[+-]\d{1,3}(?:\.\d+)?[+-]\d{1,3}(?:\.\d+)?(?:[+-]\d+(?:\.\d+)?)?(?:CRS[^/]*)?\//g
// Chaque partie est bornée : la forme d'avant, `(?:CRS[^/]*)?`, rebalayait le reste de la boîte depuis
// chaque position, et un faux MP4 de 8 Mo rempli de « +1+1CRS » bloquait l'API pendant des heures.
const pattern = /[+-]\d{1,3}(?:\.\d{1,12})?[+-]\d{1,3}(?:\.\d{1,12})?(?:[+-]\d{1,6}(?:\.\d{1,12})?)?(?:CRS[^/]{0,40})?\//g
for (const match of text.matchAll(pattern)) {
const zeroed = match[0].replace(/\d/g, '0')
region.write(zeroed, match.index ?? 0, 'latin1')
+45 -9
View File
@@ -2,32 +2,68 @@
// 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).
//
// L'essai est compté avant la comparaison bcrypt, puis rendu s'il réussit : compter après coup laissait
// passer en entier une rafale de requêtes simultanées, toutes vérifiées avant que le premier échec soit noté.
const WINDOW_MS = 15 * 60 * 1000
const MAX_FAILURES = 30
const failuresByTree = new Map<string, number[]>()
const attemptsByTree = new Map<string, number[]>()
function recentFailures(treeId: string, now: number): number[] {
const recent = (failuresByTree.get(treeId) ?? []).filter((at) => now - at < WINDOW_MS)
function recentAttempts(treeId: string, now: number): number[] {
const recent = (attemptsByTree.get(treeId) ?? []).filter((at) => now - at < WINDOW_MS)
if (recent.length > 0) {
failuresByTree.set(treeId, recent)
attemptsByTree.set(treeId, recent)
} else {
failuresByTree.delete(treeId)
attemptsByTree.delete(treeId)
}
return recent
}
export function isTreeUnlockLocked(treeId: string, now = Date.now()): boolean {
return recentFailures(treeId, now).length >= MAX_FAILURES
return recentAttempts(treeId, now).length >= MAX_FAILURES
}
// Réserve une place d'essai : renvoie de quoi la rendre si le mot de passe est bon, ou null si l'arbre est bloqué
export function reserveTreeUnlockAttempt(treeId: string, now = Date.now()): (() => void) | null {
const recent = recentAttempts(treeId, now)
if (recent.length >= MAX_FAILURES) return null
recent.push(now)
attemptsByTree.set(treeId, recent)
return () => {
const current = attemptsByTree.get(treeId)
if (!current) return
const index = current.indexOf(now)
if (index >= 0) current.splice(index, 1)
if (current.length === 0) attemptsByTree.delete(treeId)
}
}
export function recordTreeUnlockFailure(treeId: string, now = Date.now()): void {
const recent = recentFailures(treeId, now)
const recent = recentAttempts(treeId, now)
recent.push(now)
failuresByTree.set(treeId, recent)
attemptsByTree.set(treeId, recent)
}
export function resetTreeUnlockGuard(): void {
failuresByTree.clear()
attemptsByTree.clear()
}
// bcryptjs calcule en JavaScript sur le processus de l'API (≈ 250 ms par comparaison au coût 12) : des essais
// lancés en parallèle depuis quelques adresses suffisaient à le saturer pour toutes les familles. Les
// comparaisons de mot de passe de partage passent une par une, le reste de l'API garde la main entre deux.
// Au-delà de MAX_WAITING comparaisons en attente, on refuse tout de suite (null) plutôt que d'allonger la file.
const MAX_WAITING = 20
let queue: Promise<unknown> = Promise.resolve()
let waiting = 0
export function serializePasswordCheck<T>(task: () => Promise<T>): Promise<T> | null {
if (waiting >= MAX_WAITING) return null
waiting += 1
const run = queue.then(task, task).finally(() => {
waiting -= 1
})
queue = run.catch(() => undefined)
return run
}
+32 -8
View File
@@ -7,6 +7,7 @@ import {
sanitizeAnnotationContentForRead,
sanitizeAnnotationContentForWrite,
} from '../lib/annotation-content.js'
import { UPLOAD_BODY_LIMIT } from '../config/env.js'
import { canReadTree } from '../lib/tree-access.js'
import type { MembershipRole } from '../types/auth.js'
@@ -19,12 +20,23 @@ const annotationParamsSchema = z.object({
annotationId: z.string().min(1),
})
// Bornes : sans elles, un envoi groupé de 12 Mo par requête et sans limite de fréquence remplissait la base
const MAX_BATCH_ITEMS = 500
const MAX_STYLE_CHARS = 4000
const MAX_ANNOTATIONS_PER_TREE = 5000
const ANNOTATION_RATE_LIMIT = { max: 60, timeWindow: '1 minute' }
const styleSchema = z.record(z.string(), z.unknown()).refine(
(value) => JSON.stringify(value).length <= MAX_STYLE_CHARS,
{ message: 'style_too_large' },
)
const createAnnotationSchema = z.object({
type: z.enum(['drawing', 'sticker', 'text', 'photo']),
x: z.number(),
y: z.number(),
content: z.string().min(1).max(50000),
style: z.record(z.string(), z.unknown()).optional(),
style: styleSchema.optional(),
zIndex: z.number().int().optional(),
})
@@ -33,7 +45,7 @@ const patchAnnotationSchema = z
x: z.number().optional(),
y: z.number().optional(),
content: z.string().min(1).max(50000).optional(),
style: z.record(z.string(), z.unknown()).optional(),
style: styleSchema.optional(),
zIndex: z.number().int().optional(),
})
.refine((value) => Object.keys(value).length > 0, {
@@ -41,16 +53,16 @@ const patchAnnotationSchema = z
})
const batchAnnotationSchema = z.object({
create: z.array(createAnnotationSchema).optional().default([]),
create: z.array(createAnnotationSchema).max(MAX_BATCH_ITEMS).optional().default([]),
update: z.array(z.object({
id: z.string().min(1),
x: z.number().optional(),
y: z.number().optional(),
content: z.string().min(1).max(50000).optional(),
style: z.record(z.string(), z.unknown()).optional(),
style: styleSchema.optional(),
zIndex: z.number().int().optional(),
})).optional().default([]),
delete: z.array(z.string().min(1)).optional().default([]),
})).max(MAX_BATCH_ITEMS).optional().default([]),
delete: z.array(z.string().min(1)).max(MAX_BATCH_ITEMS).optional().default([]),
})
function requireUser(request: FastifyRequest, reply: FastifyReply): { userId: string; email: string } | null {
@@ -98,9 +110,15 @@ function hasActiveAdminMembership(membership: Awaited<ReturnType<typeof getAdmin
return Boolean(membership && !membership.tree.deletedAt && hasAdminRight(membership.role))
}
async function exceedsAnnotationCap(app: FastifyInstance, treeId: string, incoming: number): Promise<boolean> {
if (incoming === 0) return false
const count = await app.prisma.annotation.count({ where: { treeId, deletedAt: null } })
return count + incoming > MAX_ANNOTATIONS_PER_TREE
}
export const annotationRoutes: FastifyPluginAsync = async (app) => {
// CREATE annotation
app.post('/trees/:id/annotations', async (request, reply) => {
app.post('/trees/:id/annotations', { config: { rateLimit: ANNOTATION_RATE_LIMIT } }, async (request, reply) => {
const user = requireUser(request, reply)
if (!user) return
@@ -113,6 +131,8 @@ 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' })
if (await exceedsAnnotationCap(app, params.data.id, 1)) return reply.code(413).send({ error: 'annotation_limit_reached' })
const content = cleanContent(payload.data.content, params.data.id)
if (content === null) return reply.code(400).send({ error: 'invalid_annotation_photo_path' })
@@ -259,7 +279,7 @@ export const annotationRoutes: FastifyPluginAsync = async (app) => {
})
// BATCH annotations (create + update + delete in one request)
app.post('/trees/:id/annotations/batch', async (request, reply) => {
app.post('/trees/:id/annotations/batch', { bodyLimit: UPLOAD_BODY_LIMIT, config: { rateLimit: ANNOTATION_RATE_LIMIT } }, async (request, reply) => {
const user = requireUser(request, reply)
if (!user) return
@@ -272,6 +292,10 @@ 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' })
if (await exceedsAnnotationCap(app, params.data.id, payload.data.create.length)) {
return reply.code(413).send({ error: 'annotation_limit_reached' })
}
const treeId = params.data.id
const results = { created: 0, updated: 0, deleted: 0 }
+108 -65
View File
@@ -3,7 +3,9 @@ import path from 'node:path'
import type { FastifyInstance, FastifyPluginAsync } from 'fastify'
import { Prisma } from '@prisma/client'
import { z } from 'zod'
import { env } from '../config/env.js'
import { env, UPLOAD_BODY_LIMIT } from '../config/env.js'
import { sanitizeAnnotationContentForRead } from '../lib/annotation-content.js'
import { softDeletePersonCascade, softDeleteUnionCascade } from '../lib/entity-deletion.js'
import { removeStoredFile } from '../lib/media-files.js'
import { syncPersonMediaOrder } from '../lib/media-order.js'
@@ -21,12 +23,21 @@ const sessionParamsSchema = z.object({
sessionId: z.string().min(1),
})
// Une fiche de personne ou une annotation tient en quelques Ko : sans plafond, un détenteur du mot de passe
// remplissait la base à 12 Mo par envoi
const MAX_CHANGE_JSON_CHARS = 64_000
const MAX_PENDING_SESSIONS_PER_TREE = 200
const boundedJson = z.unknown().optional().refine(
(value) => value === undefined || JSON.stringify(value).length <= MAX_CHANGE_JSON_CHARS,
{ message: 'change_too_large' },
)
const contributionChangeSchema = z.object({
entityType: z.enum(['person', 'union', 'parent_child_link', 'annotation', 'media']),
action: z.enum(['create', 'update', 'delete', 'reorder']),
entityId: z.string().min(1).optional().nullable(),
before: z.unknown().optional(),
after: z.unknown().optional(),
before: boundedJson,
after: boundedJson,
conflictState: z.enum(['none', 'needs_review']).optional(),
})
@@ -210,6 +221,12 @@ function toTrimmedStringOrNull(value?: string | null): string | null {
return trimmed.length > 0 ? trimmed : null
}
// Contenu d'annotation dans une proposition : même nettoyage qu'à la lecture des annotations
function stripAnnotationUrl<T>(value: T): T {
if (!isRecord(value) || typeof value.content !== 'string') return value
return { ...value, content: sanitizeAnnotationContentForRead(value.content) } as T
}
function toPrismaJson(
value: unknown,
): Prisma.InputJsonValue | Prisma.NullTypes.DbNull | Prisma.NullTypes.JsonNull | undefined {
@@ -288,6 +305,8 @@ async function applyContributionChange(
change: ParsedChange,
actorUserId: string | null,
refs: RefMap = new Map(),
// Fichiers à effacer une fois la transaction validée (suppression d'une personne)
filesToRemove: string[] = [],
): Promise<{ entityId: string | null }> {
// Les annotations sont appliquées par le client après la relecture (batchAnnotations) :
// rien à faire ici. Avant, elles tombaient dans la branche des liens et faisaient échouer la relecture.
@@ -401,22 +420,14 @@ async function applyContributionChange(
}
if (change.action === 'delete') {
const result = await tx.person.updateMany({
where: {
id: change.entityId,
treeId,
deletedAt: null,
},
data: {
deletedAt: new Date(),
updatedBy: actorUserId,
},
})
// Même cascade que la route du propriétaire : unions, liens, souvenirs et portrait partent avec la personne
const deleted = await softDeletePersonCascade(tx, treeId, change.entityId, actorUserId)
if (result.count === 0) {
if (!deleted) {
throw new ContributionRouteError(400, 'invalid_entity_reference')
}
filesToRemove.push(...deleted.filePaths)
return { entityId: change.entityId }
}
@@ -542,18 +553,10 @@ async function applyContributionChange(
}
if (change.action === 'delete') {
const result = await tx.union.updateMany({
where: {
id: change.entityId,
treeId,
deletedAt: null,
},
data: {
deletedAt: new Date(),
},
})
// Les liens parent-enfant qui passaient par cette union partent avec elle (comme côté propriétaire)
const deleted = await softDeleteUnionCascade(tx, treeId, change.entityId)
if (result.count === 0) {
if (!deleted) {
throw new ContributionRouteError(400, 'invalid_entity_reference')
}
@@ -773,20 +776,18 @@ async function applyContributionChange(
throw new ContributionRouteError(400, 'unsupported_change_action_for_link')
}
async function detectConflict(
const SNAPSHOT_ENTITY_TYPES = new Set(['person', 'union', 'parent_child_link'])
// État actuel d'une personne, union ou lien, tel qu'on le montre à la relecture. null : introuvable dans l'arbre.
async function snapshotEntity(
tx: TxClient,
treeId: string,
change: { entityType: string; action: string; entityId: string | null; beforeJson: unknown },
): Promise<boolean> {
if (change.action !== 'update' || !change.entityId || !isRecord(change.beforeJson)) {
return false
}
const before = change.beforeJson
if (change.entityType === 'person') {
entityType: string,
entityId: string,
): Promise<Record<string, unknown> | null> {
if (entityType === 'person') {
const current = await tx.person.findFirst({
where: { id: change.entityId, treeId, deletedAt: null },
where: { id: entityId, treeId, deletedAt: null },
select: {
firstName: true,
lastName: true,
@@ -801,48 +802,53 @@ async function detectConflict(
notes: true,
},
})
if (!current) return true
const snap: Record<string, unknown> = {
if (!current) return null
return {
...current,
birthDate: current.birthDate?.toISOString().slice(0, 10) ?? null,
deathDate: current.deathDate?.toISOString().slice(0, 10) ?? null,
}
for (const key of Object.keys(before)) {
if (key in snap && String(snap[key] ?? '') !== String(before[key] ?? '')) return true
}
return false
}
if (change.entityType === 'union') {
if (entityType === 'union') {
const current = await tx.union.findFirst({
where: { id: change.entityId, treeId, deletedAt: null },
where: { id: entityId, treeId, deletedAt: null },
select: { partner1PersonId: true, partner2PersonId: true, unionType: true, startDate: true, endDate: true, displayOrder: true },
})
if (!current) return true
const snap: Record<string, unknown> = {
if (!current) return null
return {
...current,
startDate: current.startDate?.toISOString().slice(0, 10) ?? null,
endDate: current.endDate?.toISOString().slice(0, 10) ?? null,
}
for (const key of Object.keys(before)) {
if (key in snap && String(snap[key] ?? '') !== String(before[key] ?? '')) return true
}
return false
}
if (change.entityType === 'parent_child_link') {
if (entityType === 'parent_child_link') {
const current = await tx.parentChildLink.findFirst({
where: { id: change.entityId, treeId, deletedAt: null },
where: { id: entityId, treeId, deletedAt: null },
select: { parentPersonId: true, childPersonId: true, viaUnionId: true, parentageType: true, displayOrder: true },
})
if (!current) return true
const snap = current as Record<string, unknown>
for (const key of Object.keys(before)) {
if (key in snap && String(snap[key] ?? '') !== String(before[key] ?? '')) return true
}
return current ? { ...current } : null
}
return null
}
async function detectConflict(
tx: TxClient,
treeId: string,
change: { entityType: string; action: string; entityId: string | null; beforeJson: unknown },
): Promise<boolean> {
if (change.action !== 'update' || !change.entityId || !isRecord(change.beforeJson) || !SNAPSHOT_ENTITY_TYPES.has(change.entityType)) {
return false
}
const before = change.beforeJson
const snap = await snapshotEntity(tx, treeId, change.entityType, change.entityId)
if (!snap) return true
for (const key of Object.keys(before)) {
if (key in snap && String(snap[key] ?? '') !== String(before[key] ?? '')) return true
}
return false
}
@@ -993,7 +999,7 @@ export async function resolveSubmissionContext(app: FastifyInstance, actor: Acto
}
export const contributionRoutes: FastifyPluginAsync = async (app) => {
app.post('/trees/:id/contributions/sessions', { config: { rateLimit: { max: 30, timeWindow: '1 minute' } } }, async (request, reply) => {
app.post('/trees/:id/contributions/sessions', { bodyLimit: UPLOAD_BODY_LIMIT, config: { rateLimit: { max: 30, timeWindow: '1 minute' } } }, async (request, reply) => {
const params = treeIdParamSchema.safeParse(request.params)
if (!params.success) {
return reply.code(400).send({ error: 'invalid_tree_id' })
@@ -1012,6 +1018,18 @@ export const contributionRoutes: FastifyPluginAsync = async (app) => {
const context = await resolveSubmissionContext(app, request.actor, params.data.id)
const now = new Date()
// Une famille n'a jamais des centaines de propositions en attente : au-delà, c'est un envoi en boucle
if (context.autoStatus === 'pending') {
const pendingCount = await app.prisma.contributionSession.count({
where: { treeId: params.data.id, status: 'pending' },
})
if (pendingCount >= MAX_PENDING_SESSIONS_PER_TREE) {
return reply.code(429).send({ error: 'too_many_pending_contributions' })
}
}
const filesToRemove: string[] = []
// Tout ou rien : une erreur au milieu n'applique rien
const { session, createdChanges } = await app.prisma.$transaction(async (tx) => {
const session = await tx.contributionSession.create({
@@ -1034,8 +1052,19 @@ export const contributionRoutes: FastifyPluginAsync = async (app) => {
for (const change of byApplicationOrder(payload.data.changes)) {
let effectiveEntityId = change.entityId ?? null
// L'« avant » montré au propriétaire vient de la base, jamais du contributeur : un « avant » inventé
// faisait approuver « Suppression : Doublon Test » pour effacer en réalité la grand-mère.
let before = change.before
if ((change.action === 'update' || change.action === 'delete') && change.entityId && SNAPSHOT_ENTITY_TYPES.has(change.entityType)) {
const snapshot = await snapshotEntity(tx, params.data.id, change.entityType, change.entityId)
if (!snapshot) {
throw new ContributionRouteError(400, 'invalid_entity_reference')
}
before = snapshot
}
if (context.autoStatus === 'approved') {
const applied = await applyContributionChange(tx, params.data.id, change, context.submittedByUserId, refs)
const applied = await applyContributionChange(tx, params.data.id, change, context.submittedByUserId, refs, filesToRemove)
effectiveEntityId = applied.entityId
}
@@ -1045,8 +1074,8 @@ export const contributionRoutes: FastifyPluginAsync = async (app) => {
entityType: change.entityType,
entityId: effectiveEntityId,
action: change.action,
beforeJson: toPrismaJson(change.before),
afterJson: toPrismaJson(change.after),
beforeJson: toPrismaJson(change.entityType === 'annotation' ? stripAnnotationUrl(before) : before),
afterJson: toPrismaJson(change.entityType === 'annotation' ? stripAnnotationUrl(change.after) : change.after),
conflictState: change.conflictState ?? 'none',
decision: context.autoStatus === 'approved' ? 'approved' : undefined,
},
@@ -1073,6 +1102,9 @@ export const contributionRoutes: FastifyPluginAsync = async (app) => {
return { session, createdChanges }
}, { timeout: 30_000 })
const storageRoot = path.resolve(env.MEDIA_STORAGE_PATH)
await Promise.all(filesToRemove.map((filePath) => removeStoredFile(storageRoot, filePath)))
return reply.code(201).send({
session,
changesCount: createdChanges.length,
@@ -1124,7 +1156,15 @@ export const contributionRoutes: FastifyPluginAsync = async (app) => {
},
})
return reply.send({ sessions })
// Anciennes propositions d'annotation photo : leur `url` portait la session du contributeur
const cleaned = sessions.map((session) => ({
...session,
changes: session.changes.map((change) => change.entityType === 'annotation'
? { ...change, beforeJson: stripAnnotationUrl(change.beforeJson), afterJson: stripAnnotationUrl(change.afterJson) }
: change),
}))
return reply.send({ sessions: cleaned })
} catch (error) {
if (error instanceof ContributionRouteError) {
return reply.code(error.statusCode).send({ error: error.code })
@@ -1173,6 +1213,7 @@ export const contributionRoutes: FastifyPluginAsync = async (app) => {
}
const rejectedMediaIds: string[] = []
const filesToRemove: string[] = []
const { updatedSession, approvedCount, rejectedCount, conflictCount, status } = await app.prisma.$transaction(async (tx) => {
// Deux relectures simultanées : la seconde attend le verrou de la ligne, puis ne trouve plus
// la session en attente. Sans ce verrou, les deux appliquaient les mêmes ajouts.
@@ -1222,6 +1263,7 @@ export const contributionRoutes: FastifyPluginAsync = async (app) => {
},
membership.userId,
refs,
filesToRemove,
)
approvedCount += 1
} catch (error) {
@@ -1287,15 +1329,16 @@ export const contributionRoutes: FastifyPluginAsync = async (app) => {
return { updatedSession, approvedCount, rejectedCount, conflictCount, status }
}, { timeout: 30_000 })
// Photo refusée = effacée du disque, une fois la relecture enregistrée
// Photo refusée, ou souvenirs d'une personne supprimée = effacés du disque, une fois la relecture enregistrée
const storageRoot = path.resolve(env.MEDIA_STORAGE_PATH)
if (rejectedMediaIds.length > 0) {
const rejected = await app.prisma.mediaItem.findMany({
where: { id: { in: rejectedMediaIds }, treeId: params.data.id },
select: { filePath: true },
})
const storageRoot = path.resolve(env.MEDIA_STORAGE_PATH)
await Promise.all(rejected.map((media) => removeStoredFile(storageRoot, media.filePath)))
}
await Promise.all(filesToRemove.map((filePath) => removeStoredFile(storageRoot, filePath)))
return reply.send({
session: updatedSession,
+34 -3
View File
@@ -1,6 +1,7 @@
import type { FastifyPluginAsync } from 'fastify'
import { z } from 'zod'
import { UPLOAD_BODY_LIMIT } from '../config/env.js'
import type { MembershipRole } from '../types/auth.js'
const treeIdParamSchema = z.object({
@@ -16,6 +17,15 @@ const importBodySchema = z.object({
const MAX_LINE_LENGTH = 4096
const MAX_IMPORTED_PERSONS = 20000
const MAX_IMPORTED_FAMILIES = 20000
// Le parseur construit un objet par ligne : 10 Mo de lignes courtes montaient à 370 Mo de mémoire, et le
// redémarrage pm2 qui suivait effaçait les limites d'essais gardées en mémoire. Au-delà, le fichier est refusé.
const MAX_GEDCOM_NODES = 300_000
const MAX_IMPORTED_LINKS = 60_000
class GedcomTooLargeError extends Error {}
// Un import à la fois sur toute l'instance : chacun tient une connexion de la base pendant sa transaction
let importInProgress = false
function clip(value: string | null, max: number): string | null {
if (value === null) return null
@@ -46,10 +56,13 @@ function parseGedcom(text: string): GedcomNode[] {
const lines = text.split(/\r\n|\r|\n/)
const roots: GedcomNode[] = []
const stack: GedcomNode[] = []
let nodeCount = 0
for (const raw of lines) {
const line = raw.trim()
if (!line || line.length > MAX_LINE_LENGTH) continue
nodeCount += 1
if (nodeCount > MAX_GEDCOM_NODES) throw new GedcomTooLargeError()
const match = line.match(/^(\d+)\s+(?:(@[^@]+@)\s+)?(\w+)(?:\s+(.*))?$/)
if (!match) continue
@@ -399,7 +412,7 @@ export const gedcomRoutes: FastifyPluginAsync = async (app) => {
})
// Import
app.post('/trees/:id/import/gedcom', { config: { rateLimit: { max: 5, timeWindow: '1 minute' } } }, async (request, reply) => {
app.post('/trees/:id/import/gedcom', { bodyLimit: UPLOAD_BODY_LIMIT, config: { rateLimit: { max: 5, timeWindow: '1 minute' } } }, async (request, reply) => {
const params = treeIdParamSchema.safeParse(request.params)
if (!params.success) {
return reply.code(400).send({ error: 'invalid_tree_id' })
@@ -430,12 +443,25 @@ export const gedcomRoutes: FastifyPluginAsync = async (app) => {
return reply.code(403).send({ error: 'forbidden' })
}
const roots = parseGedcom(body.data.content)
if (importInProgress) {
return reply.code(429).send({ error: 'gedcom_import_busy' })
}
let roots: GedcomNode[]
try {
roots = parseGedcom(body.data.content)
} catch (error) {
if (error instanceof GedcomTooLargeError) {
return reply.code(413).send({ error: 'gedcom_too_large' })
}
throw error
}
const indiNodes = roots.filter((node) => node.tag === 'INDI' && node.xref)
const famNodes = roots.filter((node) => node.tag === 'FAM' && node.xref)
const childLinkCount = famNodes.reduce((total, fam) => total + findAllChildren(fam, 'CHIL').length, 0)
if (indiNodes.length > MAX_IMPORTED_PERSONS || famNodes.length > MAX_IMPORTED_FAMILIES) {
if (indiNodes.length > MAX_IMPORTED_PERSONS || famNodes.length > MAX_IMPORTED_FAMILIES || childLinkCount > MAX_IMPORTED_LINKS) {
return reply.code(413).send({ error: 'gedcom_too_large' })
}
@@ -444,6 +470,8 @@ export const gedcomRoutes: FastifyPluginAsync = async (app) => {
let unionsCreated = 0
let linksCreated = 0
importInProgress = true
try {
await app.prisma.$transaction(async (tx) => {
for (const indi of indiNodes) {
const nameNode = findChild(indi, 'NAME')
@@ -552,6 +580,9 @@ export const gedcomRoutes: FastifyPluginAsync = async (app) => {
},
})
}, { timeout: 60000 })
} finally {
importInProgress = false
}
return reply.send({ personsCreated, unionsCreated, linksCreated })
})
+46 -10
View File
@@ -1,11 +1,11 @@
import { randomUUID } from 'node:crypto'
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises'
import path from 'node:path'
import type { FastifyPluginAsync, FastifyRequest } from 'fastify'
import { z } from 'zod'
import { env } from '../config/env.js'
import { env, UPLOAD_BODY_LIMIT } from '../config/env.js'
import {
applyMediaResponseHeaders,
extensionForMime,
@@ -18,6 +18,7 @@ import {
type BinaryMediaType,
} from '../lib/media-files.js'
import { syncPersonMediaOrder } from '../lib/media-order.js'
import { resolveTreeMediaDirectory } from '../lib/tree-purge.js'
import { canReadTree, canReadTreeWithMediaToken } from '../lib/tree-access.js'
import type { MembershipRole } from '../types/auth.js'
import { ContributionRouteError, resolveSubmissionContext } from './contributions.js'
@@ -69,7 +70,7 @@ const uploadYoutubeVideoSchema = z.object({
submittedByLabel: z.string().trim().min(1).max(120).optional(),
type: z.literal('video'),
mimeType: z.literal('video/youtube'),
youtubeUrl: z.string().regex(YOUTUBE_URL_RE),
youtubeUrl: z.string().max(500).regex(YOUTUBE_URL_RE),
caption: z.string().max(280).optional().nullable(),
source: mediaSourceSchema,
})
@@ -135,14 +136,40 @@ async function prepareUploadedFile(
return { buffer: cleaned, mimeType, extension: extensionForMime(mimeType) }
}
// Octets réellement occupés par le dossier d'un arbre. Le total des médias en base ne voyait ni les portraits,
// ni les photos d'annotation, ni les fichiers restés après une suppression : le quota se contournait.
async function treeDiskUsage(treeId: string): Promise<number> {
const directory = resolveTreeMediaDirectory(treeId, getStorageRoot())
if (!directory) return 0
let total = 0
const walk = async (dir: string): Promise<void> => {
let entries
try {
entries = await readdir(dir, { withFileTypes: true })
} catch {
return
}
for (const entry of entries) {
const entryPath = path.join(dir, entry.name)
if (entry.isDirectory()) {
await walk(entryPath)
} else if (entry.isFile()) {
try {
total += (await stat(entryPath)).size
} catch {
// effacé entre-temps
}
}
}
}
await walk(directory)
return total
}
// Place disque d'un arbre : total, et part des envois en attente de relecture (limite l'envoi en boucle)
async function exceedsStorageQuota(app: FastifyRequest['server'], treeId: string, incomingBytes: number, isPending: boolean): Promise<boolean> {
const MB = 1024 * 1024
const total = await app.prisma.mediaItem.aggregate({
where: { treeId, deletedAt: null },
_sum: { sizeBytes: true },
})
if ((total._sum.sizeBytes ?? 0) + incomingBytes > env.MEDIA_QUOTA_PER_TREE_MB * MB) {
if ((await treeDiskUsage(treeId)) + incomingBytes > env.MEDIA_QUOTA_PER_TREE_MB * MB) {
return true
}
@@ -157,7 +184,8 @@ async function exceedsStorageQuota(app: FastifyRequest['server'], treeId: string
return (pending._sum.sizeBytes ?? 0) + incomingBytes > env.MEDIA_PENDING_QUOTA_PER_TREE_MB * MB
}
const UPLOAD_RATE_LIMIT = { config: { rateLimit: { max: 30, timeWindow: '1 minute' } } }
// Seules les routes d'envoi acceptent un corps de 12 Mo (fichier en base64), le reste de l'API est à 1 Mo (app.ts)
const UPLOAD_RATE_LIMIT = { bodyLimit: UPLOAD_BODY_LIMIT, config: { rateLimit: { max: 30, timeWindow: '1 minute' } } }
async function requireAdminMembership(app: FastifyRequest['server'], userId: string, treeId: string) {
const membership = await app.prisma.treeMembership.findUnique({
@@ -433,7 +461,7 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => {
return reply.code(201).send({ media, status: media.status })
})
app.post('/trees/:id/persons/:personId/avatar', async (request, reply) => {
app.post('/trees/:id/persons/:personId/avatar', UPLOAD_RATE_LIMIT, async (request, reply) => {
if (!request.actor || request.actor.kind !== 'user') {
return reply.code(401).send({ error: 'authentication_required' })
}
@@ -493,6 +521,10 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => {
throw error
}
if (await exceedsStorageQuota(app, params.data.id, prepared.buffer.length, false)) {
return reply.code(413).send({ error: 'storage_quota_exceeded' })
}
const relativePath = path.posix.join(
params.data.id,
params.data.personId,
@@ -897,6 +929,10 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => {
throw error
}
if (await exceedsStorageQuota(app, params.data.id, prepared.buffer.length, false)) {
return reply.code(413).send({ error: 'storage_quota_exceeded' })
}
const photoId = randomUUID()
const relativePath = path.posix.join(params.data.id, 'annotation-photos', `${photoId}${prepared.extension}`)
const storageRoot = getStorageRoot()
+2 -1
View File
@@ -6,7 +6,8 @@ const slugParamSchema = z.object({
})
export const publicRoutes: FastifyPluginAsync = async (app) => {
app.get('/arbre/:slug', async (request, reply) => {
// Limité : sans route authentifiée, c'est la porte pour énumérer les noms de famille et trouver l'id d'un arbre
app.get('/arbre/:slug', { config: { rateLimit: { max: 60, timeWindow: '1 minute' } } }, async (request, reply) => {
const params = slugParamSchema.safeParse(request.params)
if (!params.success) {
return reply.code(400).send({ error: 'invalid_slug' })
+115 -166
View File
@@ -1,16 +1,22 @@
import path from 'node:path'
import type { FastifyInstance, FastifyPluginAsync, FastifyReply, FastifyRequest } from 'fastify'
import { Prisma } from '@prisma/client'
import { z } from 'zod'
import { env } from '../config/env.js'
import { hashPassword, verifyPassword } from '../lib/auth.js'
import { softDeletePersonCascade, softDeleteUnionCascade } from '../lib/entity-deletion.js'
import { removeStoredFile } from '../lib/media-files.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 { reserveTreeUnlockAttempt, serializePasswordCheck } from '../lib/unlock-guard.js'
import type { MembershipRole } from '../types/auth.js'
import { findParentChildValidationError, findUnionValidationError } from '../utils/relationship-guards.js'
const MAX_TREES_PER_ACCOUNT = 10
const treeIdParamSchema = z.object({
id: z.string().min(1),
})
@@ -179,6 +185,38 @@ function hasAdminRight(role: MembershipRole): boolean {
return role === 'owner' || role === 'admin'
}
// Rattache un compte à l'arbre ouvert par le mot de passe de partage, si ce mot de passe est toujours celui en
// vigueur (accessVersion). Le verrou partagé sur la ligne fait attendre un changement de mot de passe concurrent
// jusqu'à la fin du rattachement, qu'il supprime ensuite ; passé avant, il change la version et on ne rattache pas.
// Sans lui, un unlock lancé juste avant un changement recréait le rattachement après la purge.
async function attachShareAccess(app: FastifyInstance, treeId: string, userId: string, accessVersion: number): Promise<boolean> {
return app.prisma.$transaction(async (tx) => {
const rows = await tx.$queryRaw<Array<{ updatedAt: Date }>>`
SELECT "updatedAt" FROM tree_access_passwords WHERE "treeId" = ${treeId} FOR SHARE`
if (rows.length === 0 || rows[0].updatedAt.getTime() !== accessVersion) {
return false
}
const now = new Date()
const role = 'contributor' as const
await tx.userTreeAccess.upsert({
where: { treeId_userId: { treeId, userId } },
create: { treeId, userId, role },
update: { role, updatedAt: now },
})
await tx.user.update({
where: { id: userId },
data: {
lastOpenedTreeId: treeId,
lastOpenedAccessMode: 'share',
lastOpenedRole: role,
lastOpenedAt: now,
},
})
return true
})
}
async function getMembership(app: FastifyInstance, userId: string, treeId: string) {
return app.prisma.treeMembership.findUnique({
where: {
@@ -386,7 +424,7 @@ export const treeRoutes: FastifyPluginAsync = async (app) => {
})
})
app.post('/trees', async (request, reply) => {
app.post('/trees', { config: { rateLimit: { max: 10, timeWindow: '1 hour' } } }, async (request, reply) => {
const user = requireUser(request, reply)
if (!user) {
return
@@ -397,6 +435,14 @@ export const treeRoutes: FastifyPluginAsync = async (app) => {
return reply.code(400).send({ error: 'invalid_payload', details: parsed.error.flatten() })
}
// Le quota disque est par arbre : sans plafond d'arbres, un compte multipliait sa place sans limite
const ownedTrees = await app.prisma.treeMembership.count({
where: { userId: user.userId, role: 'owner', tree: { deletedAt: null } },
})
if (ownedTrees >= MAX_TREES_PER_ACCOUNT) {
return reply.code(403).send({ error: 'tree_limit_reached' })
}
const normalizedSlug = normalizeSlug(parsed.data.slug)
if (!isValidSlug(normalizedSlug)) {
return reply.code(400).send({
@@ -695,23 +741,29 @@ export const treeRoutes: FastifyPluginAsync = async (app) => {
return reply.code(404).send({ error: 'tree_not_found' })
}
if (isTreeUnlockLocked(params.data.id)) {
const releaseAttempt = reserveTreeUnlockAttempt(params.data.id)
if (!releaseAttempt) {
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)
const matches = await matchSharePassword(
const check = serializePasswordCheck(() => matchSharePassword(
payload.data.password,
[accessConfig.contributorHash, accessConfig.visitorHash],
verifyPassword,
)
))
if (!check) {
releaseAttempt()
return reply.code(429).send({ error: 'too_many_attempts' })
}
const matches = await check
if (!matches) {
recordTreeUnlockFailure(params.data.id)
return reply.code(401).send({ error: 'invalid_password' })
}
releaseAttempt()
const role = 'contributor' as const
@@ -729,37 +781,10 @@ export const treeRoutes: FastifyPluginAsync = async (app) => {
)
if (request.actor && request.actor.kind === 'user') {
const now = new Date()
await app.prisma.userTreeAccess.upsert({
where: {
treeId_userId: {
treeId: params.data.id,
userId: request.actor.userId,
},
},
create: {
treeId: params.data.id,
userId: request.actor.userId,
role,
},
update: {
role,
updatedAt: now,
},
})
await app.prisma.user.update({
where: {
id: request.actor.userId,
},
data: {
lastOpenedTreeId: params.data.id,
lastOpenedAccessMode: 'share',
lastOpenedRole: role,
lastOpenedAt: now,
},
})
const attached = await attachShareAccess(app, params.data.id, request.actor.userId, accessConfig.updatedAt.getTime())
if (!attached) {
return reply.code(409).send({ error: 'access_changed' })
}
}
return reply.send({
@@ -822,36 +847,10 @@ export const treeRoutes: FastifyPluginAsync = async (app) => {
return reply.code(401).send({ error: 'invalid_access_token' })
}
const now = new Date()
await app.prisma.userTreeAccess.upsert({
where: {
treeId_userId: {
treeId: params.data.id,
userId: user.userId,
},
},
create: {
treeId: params.data.id,
userId: user.userId,
role,
},
update: {
role,
updatedAt: now,
},
})
await app.prisma.user.update({
where: {
id: user.userId,
},
data: {
lastOpenedTreeId: params.data.id,
lastOpenedAccessMode: 'share',
lastOpenedRole: role,
lastOpenedAt: now,
},
})
const attached = await attachShareAccess(app, params.data.id, user.userId, accessPayload.accessVersion)
if (!attached) {
return reply.code(401).send({ error: 'invalid_access_token' })
}
return reply.send({ treeId: params.data.id, role })
})
@@ -879,7 +878,7 @@ export const treeRoutes: FastifyPluginAsync = async (app) => {
// Arbres d'avant : le propriétaire tape le mot de passe déjà donné, le serveur le vérifie et le retient
// sans rien changer aux accès (les hash et leur updatedAt ne bougent pas).
app.post('/trees/:id/access/passwords/remember', async (request, reply) => {
app.post('/trees/:id/access/passwords/remember', { config: { rateLimit: { max: 10, timeWindow: '1 minute' } } }, async (request, reply) => {
const user = requireUser(request, reply)
if (!user) {
return
@@ -906,11 +905,15 @@ export const treeRoutes: FastifyPluginAsync = async (app) => {
}
// On garde la variante exacte qui correspond au hash, c'est elle que la famille doit recevoir
const password = await matchSharePassword(
const check = serializePasswordCheck(() => matchSharePassword(
payload.data.password,
[accessConfig.contributorHash, accessConfig.visitorHash],
verifyPassword,
)
))
if (!check) {
return reply.code(429).send({ error: 'too_many_attempts' })
}
const password = await check
if (!password) {
return reply.code(422).send({ error: 'password_mismatch' })
}
@@ -971,30 +974,34 @@ export const treeRoutes: FastifyPluginAsync = async (app) => {
data.contributorHash = await hashPassword(payload.data.contributorPassword)
}
await app.prisma.treeAccessPasswords.update({
where: {
treeId: params.data.id,
},
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)
await app.prisma.treeSharePassword.upsert({
where: { treeId: params.data.id },
create: { treeId: params.data.id, passwordEnc },
update: { passwordEnc, setAt: new Date() },
// Une seule transaction : nouveau mot de passe, rattachements purgés et mot de passe lisible changent
// ensemble (voir attachShareAccess pour l'unlock concurrent)
await app.prisma.$transaction(async (tx) => {
await tx.treeAccessPasswords.update({
where: {
treeId: params.data.id,
},
data,
})
} else {
await app.prisma.treeSharePassword.deleteMany({ where: { treeId: params.data.id } })
}
// 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 tx.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)
await tx.treeSharePassword.upsert({
where: { treeId: params.data.id },
create: { treeId: params.data.id, passwordEnc },
update: { passwordEnc, setAt: new Date() },
})
} else {
await tx.treeSharePassword.deleteMany({ where: { treeId: params.data.id } })
}
})
await app.prisma.auditLog.create({
data: {
@@ -1404,55 +1411,16 @@ export const treeRoutes: FastifyPluginAsync = async (app) => {
return reply.code(403).send({ error: 'forbidden' })
}
const now = new Date()
const deletedPerson = await app.prisma.person.updateMany({
where: {
id: params.data.personId,
treeId: params.data.id,
deletedAt: null,
},
data: {
deletedAt: now,
updatedBy: user.userId,
},
})
const deleted = await app.prisma.$transaction((tx) =>
softDeletePersonCascade(tx, params.data.id, params.data.personId, user.userId),
)
if (deletedPerson.count === 0) {
if (!deleted) {
return reply.code(404).send({ error: 'person_not_found' })
}
const [deletedUnions, deletedLinks, deletedMedia] = await Promise.all([
app.prisma.union.updateMany({
where: {
treeId: params.data.id,
deletedAt: null,
OR: [{ partner1PersonId: params.data.personId }, { partner2PersonId: params.data.personId }],
},
data: {
deletedAt: now,
},
}),
app.prisma.parentChildLink.updateMany({
where: {
treeId: params.data.id,
deletedAt: null,
OR: [{ parentPersonId: params.data.personId }, { childPersonId: params.data.personId }],
},
data: {
deletedAt: now,
},
}),
app.prisma.mediaItem.updateMany({
where: {
treeId: params.data.id,
personId: params.data.personId,
deletedAt: null,
},
data: {
deletedAt: now,
},
}),
])
const storageRoot = path.resolve(env.MEDIA_STORAGE_PATH)
await Promise.all(deleted.filePaths.map((filePath) => removeStoredFile(storageRoot, filePath)))
await app.prisma.auditLog.create({
data: {
@@ -1463,9 +1431,9 @@ export const treeRoutes: FastifyPluginAsync = async (app) => {
entityType: 'person',
entityId: params.data.personId,
payloadJson: {
deletedUnions: deletedUnions.count,
deletedLinks: deletedLinks.count,
deletedMedia: deletedMedia.count,
deletedUnions: deleted.unions,
deletedLinks: deleted.links,
deletedMedia: deleted.medias,
},
},
})
@@ -1473,9 +1441,9 @@ export const treeRoutes: FastifyPluginAsync = async (app) => {
return reply.send({
ok: true,
cascaded: {
unions: deletedUnions.count,
links: deletedLinks.count,
medias: deletedMedia.count,
unions: deleted.unions,
links: deleted.links,
medias: deleted.medias,
},
})
})
@@ -1715,32 +1683,13 @@ export const treeRoutes: FastifyPluginAsync = async (app) => {
return reply.code(403).send({ error: 'forbidden' })
}
const now = new Date()
const deletedUnion = await app.prisma.union.updateMany({
where: {
id: params.data.unionId,
treeId: params.data.id,
deletedAt: null,
},
data: {
deletedAt: now,
},
})
const deletedUnion = await softDeleteUnionCascade(app.prisma, params.data.id, params.data.unionId)
if (deletedUnion.count === 0) {
if (!deletedUnion) {
return reply.code(404).send({ error: 'union_not_found' })
}
const deletedLinks = await app.prisma.parentChildLink.updateMany({
where: {
treeId: params.data.id,
viaUnionId: params.data.unionId,
deletedAt: null,
},
data: {
deletedAt: now,
},
})
const deletedLinks = { count: deletedUnion.links }
await app.prisma.auditLog.create({
data: {
+2
View File
@@ -7,6 +7,8 @@ export type UserJwtPayload = {
jti: string
userId: string
email: string
// Posé par la signature (secondes) : sert au refus des sessions antérieures à SESSIONS_NOT_BEFORE
iat?: number
}
export type TreeAccessJwtPayload = {
+1
View File
@@ -17,6 +17,7 @@ const { prismaMock } = vi.hoisted(() => ({
updateMany: vi.fn(),
},
treeMembership: {
count: vi.fn(async () => 0),
findMany: vi.fn(),
findUnique: vi.fn(),
create: vi.fn(),
@@ -17,6 +17,7 @@ const { prismaMock } = vi.hoisted(() => ({
updateMany: vi.fn(),
},
treeMembership: {
count: vi.fn(async () => 0),
findMany: vi.fn(),
findUnique: vi.fn(),
create: vi.fn(),
+1
View File
@@ -27,6 +27,7 @@ const { prismaMock } = vi.hoisted(() => ({
updateMany: vi.fn(),
},
treeMembership: {
count: vi.fn(async () => 0),
findMany: vi.fn(),
findUnique: vi.fn(),
create: vi.fn(),
+67 -1
View File
@@ -4,7 +4,13 @@ import { describe, expect, it, vi } from 'vitest'
import { sanitizeAnnotationContentForRead, sanitizeAnnotationContentForWrite } from '../src/lib/annotation-content.js'
import { isMimeAllowedForType, sanitizeImage, scrubMp4Location, sniffMimeType } from '../src/lib/media-files.js'
import { matchSharePassword } from '../src/lib/password-variants.js'
import { isTreeUnlockLocked, recordTreeUnlockFailure, resetTreeUnlockGuard } from '../src/lib/unlock-guard.js'
import {
isTreeUnlockLocked,
recordTreeUnlockFailure,
reserveTreeUnlockAttempt,
resetTreeUnlockGuard,
serializePasswordCheck,
} from '../src/lib/unlock-guard.js'
const PNG_1PX = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==', 'base64')
@@ -121,4 +127,64 @@ describe('mot de passe de partage', () => {
expect(isTreeUnlockLocked('tree-autre', now)).toBe(false)
expect(isTreeUnlockLocked('tree-x', now + 16 * 60 * 1000)).toBe(false)
})
it('compte l essai avant la vérification : une rafale simultanée ne dépasse pas le plafond', () => {
resetTreeUnlockGuard()
const now = Date.now()
const reservations = Array.from({ length: 60 }, () => reserveTreeUnlockAttempt('tree-rafale', now))
expect(reservations.filter(Boolean)).toHaveLength(30)
expect(isTreeUnlockLocked('tree-rafale', now)).toBe(true)
})
it('rend la place d un essai réussi', () => {
resetTreeUnlockGuard()
const now = Date.now()
for (let i = 0; i < 29; i += 1) reserveTreeUnlockAttempt('tree-ok', now)
const release = reserveTreeUnlockAttempt('tree-ok', now)
expect(isTreeUnlockLocked('tree-ok', now)).toBe(true)
release?.()
expect(isTreeUnlockLocked('tree-ok', now)).toBe(false)
})
it('refuse au-delà de 20 comparaisons en attente au lieu d allonger la file', async () => {
let unblock: () => void = () => undefined
const gate = new Promise<void>((resolve) => { unblock = resolve })
const runs = Array.from({ length: 25 }, () => serializePasswordCheck(() => gate.then(() => true)))
expect(runs.filter((run) => run === null)).toHaveLength(5)
unblock()
await Promise.all(runs.filter(Boolean))
expect(serializePasswordCheck(async () => true)).not.toBeNull()
})
})
describe('vidéos : position effacée en temps linéaire', () => {
it('traite un faux MP4 de 2 Mo piégé pour l expression régulière en moins d une seconde', () => {
const body = Buffer.from('+1+1CRS'.repeat(300_000), 'latin1')
const head = Buffer.alloc(8)
head.writeUInt32BE(body.length + 8, 0)
head.write('moov', 4, 'latin1')
const started = Date.now()
scrubMp4Location(Buffer.concat([head, body]))
expect(Date.now() - started).toBeLessThan(1000)
})
it('efface toujours une position avec altitude et système de coordonnées', () => {
const position = '+48.8584+002.2945+035.000CRSWGS_84/'
const head = Buffer.alloc(8)
head.writeUInt32BE(position.length + 8, 0)
head.write('moov', 4, 'latin1')
const out = scrubMp4Location(Buffer.concat([head, Buffer.from(position, 'latin1')]))
expect(out.subarray(8).toString('latin1')).toBe('+00.0000+000.0000+000.000CRSWGS_00/')
})
})
describe('GIF', () => {
it('réencode un GIF et retire ses blocs de commentaire', async () => {
const gif = await sharp({ create: { width: 4, height: 4, channels: 3, background: '#808080' } }).gif().toBuffer()
const comment = Buffer.concat([Buffer.from([0x21, 0xfe, 0x0b]), Buffer.from('secret-XMP!'), Buffer.from([0x00])])
const withComment = Buffer.concat([gif.subarray(0, gif.length - 1), comment, Buffer.from([0x3b])])
const out = await sanitizeImage(withComment, 'image/gif')
expect(out.subarray(0, 6).toString('latin1')).toMatch(/^GIF8[79]a$/)
expect(out.includes(Buffer.from('secret-XMP!'))).toBe(false)
})
})
+6 -1
View File
@@ -153,8 +153,13 @@ if [ -n "${BACKUP_S3_BUCKET:-}" ]; then
age "${AGE_ARGS[@]}" -o "$UPLOAD_MEDIA_FILE" "$MEDIA_BACKUP_FILE"
fi
echo "Copies chiffrées avec age avant envoi."
elif [ "${BACKUP_ALLOW_PLAINTEXT:-0}" = "1" ]; then
echo "WARNING: BACKUP_AGE_RECIPIENT absent et BACKUP_ALLOW_PLAINTEXT=1 : les sauvegardes partent en clair."
else
echo "WARNING: BACKUP_AGE_RECIPIENT absent, les sauvegardes partent en clair."
# Des arbres de familles (noms, dates, photos) chez un hébergeur tiers, en clair : refusé par défaut.
# La copie locale ci-dessus est faite ; seul l'envoi hors du serveur est sauté.
echo "ERROR: BACKUP_S3_BUCKET est défini sans BACKUP_AGE_RECIPIENT : envoi refusé (chiffrez avec age, ou BACKUP_ALLOW_PLAINTEXT=1)."
exit 1
fi
aws s3 cp "$UPLOAD_DB_FILE" "$S3_BASE_URI/db/$(basename "$UPLOAD_DB_FILE")" "${S3_ARGS[@]}"
+25 -8
View File
@@ -4,10 +4,20 @@
# Toute location qui pose son propre add_header (Cache-Control par exemple) répète donc la liste
# complète. Les politiques longues sont rangées dans des variables (map) pour ne pas les recopier.
#
# server_tokens off est posé dans chaque server (version de nginx absente de l'en-tête Server et des pages
# d'erreur) : au niveau http, il entrerait en double avec un nginx.conf principal qui le pose déjà.
# Journal d'accès sans la query string : les adresses des médias portent un jeton (?token=…), que l'API
# masque dans ses propres journaux mais que le format « combined » de nginx gardait en clair.
log_format parenthese_noquery '$remote_addr - $remote_user [$time_local] "$request_method $uri $server_protocol" '
'$status $body_bytes_sent "$http_referer" "$http_user_agent"';
# CSP de l'application (app.parenthese.io), sources autorisées :
# script-src, connect-src https://*.posthog.com, analytics (VITE_POSTHOG_HOST = us.i.posthog.com,
# extensions chargées depuis us-assets.i.posthog.com ; forme recommandée par PostHog)
# connect-src 'self' appels /api, /auth, /trees
# script-src 'self' posthog-js est dans le bundle ; il ne charge aucun script externe
# (disable_external_dependency_loading). Le joker https://*.posthog.com laissait
# passer le script de n'importe quel projet PostHog en cas d'injection.
# connect-src envoi des événements (VITE_POSTHOG_HOST = us.i.posthog.com) et configuration
# distante (us-assets.i.posthog.com) ; 'self' pour /api, /auth, /trees
# style-src 'unsafe-inline' styles posés en ligne par React et par posthog-js
# img-src data: blob: aperçu d'une photo avant envoi (URL.createObjectURL), images en data:
# font-src 'self' data: polices servies par le site (Vite peut en inliner une petite en data:)
@@ -16,7 +26,7 @@
# frame-src 'self' pour les PDF servis par /api, YouTube sans cookie, carte OpenStreetMap
# frame-ancestors la landing parenthese.io intègre l'app en iframe
map $host $parenthese_app_csp {
default "default-src 'self'; script-src 'self' https://*.posthog.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' https://*.posthog.com; media-src 'self' blob:; worker-src 'self' blob:; frame-src 'self' https://www.youtube-nocookie.com https://www.openstreetmap.org; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'self' https://parenthese.io https://www.parenthese.io";
default "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' https://us.i.posthog.com https://us-assets.i.posthog.com; media-src 'self' blob:; worker-src 'self' blob:; frame-src 'self' https://www.youtube-nocookie.com https://www.openstreetmap.org; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'self' https://parenthese.io https://www.parenthese.io";
}
# CSP de la landing (parenthese.io), sources autorisées :
@@ -28,7 +38,7 @@ map $host $parenthese_app_csp {
# frame-src démonstration de l'app en iframe
# frame-ancestors 'none' équivalent de X-Frame-Options DENY
map $host $parenthese_landing_csp {
default "default-src 'self'; script-src 'self' 'sha256-Av5a1/yoW/tQ2ZQWqH5FnaL896Bubcs9ivdFvsKZBrU=' https://*.posthog.com; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src 'self' https://*.posthog.com; worker-src 'self' blob:; frame-src https://app.parenthese.io; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'";
default "default-src 'self'; script-src 'self' 'sha256-Av5a1/yoW/tQ2ZQWqH5FnaL896Bubcs9ivdFvsKZBrU='; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src 'self' https://us.i.posthog.com https://us-assets.i.posthog.com; worker-src 'self' blob:; frame-src https://app.parenthese.io; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'";
}
# Réponses de l'API relayées (JSON, médias des familles) : intégrables seulement par l'app (PDF en iframe).
@@ -42,6 +52,7 @@ map $host $parenthese_api_csp {
server {
listen 80;
server_name app.parenthese.io;
server_tokens off;
return 301 https://app.parenthese.io$request_uri;
}
@@ -51,11 +62,14 @@ server {
# « listen 443 ssl; » + « http2 on; » une fois « nginx -v » vérifié.
listen 443 ssl http2;
server_name app.parenthese.io;
server_tokens off;
ssl_certificate /etc/letsencrypt/live/app.parenthese.io/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/app.parenthese.io/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
access_log /var/log/nginx/access.log parenthese_noquery;
# Hérités par les location sans add_header, c'est-à-dire les routes relayées vers l'API
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header Content-Security-Policy $parenthese_api_csp always;
@@ -77,7 +91,7 @@ server {
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
client_max_body_size 25m;
proxy_connect_timeout 10s;
@@ -90,7 +104,7 @@ server {
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
}
@@ -99,7 +113,7 @@ server {
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
client_max_body_size 25m;
}
@@ -137,12 +151,14 @@ server {
server {
listen 80;
server_name parenthese.io www.parenthese.io;
server_tokens off;
return 301 https://parenthese.io$request_uri;
}
server {
listen 443 ssl http2;
server_name www.parenthese.io;
server_tokens off;
ssl_certificate /etc/letsencrypt/live/parenthese.io/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/parenthese.io/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
@@ -153,6 +169,7 @@ server {
server {
listen 443 ssl http2;
server_name parenthese.io;
server_tokens off;
ssl_certificate /etc/letsencrypt/live/parenthese.io/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/parenthese.io/privkey.pem;
+9 -3
View File
@@ -15,6 +15,12 @@ map $host $parenthese_app_csp {
server {
listen 80;
server_name _;
server_tokens off;
# X-Forwarded-For est écrasé par l'adresse vue ici (proxy_set_header plus bas) : le compléter laissait un
# client écrire sa propre adresse quand Docker relaie par sa passerelle (Docker Desktop, rootless, IPv6),
# que l'API croit (TRUST_PROXY). Derrière un autre proxy HTTPS, déclarez ce proxy dans TRUST_PROXY et
# remplacez $remote_addr par $proxy_add_x_forwarded_for.
root /usr/share/nginx/html;
index index.html;
@@ -37,7 +43,7 @@ server {
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
client_max_body_size 25m;
proxy_connect_timeout 10s;
@@ -50,7 +56,7 @@ server {
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
}
@@ -59,7 +65,7 @@ server {
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
client_max_body_size 25m;
}
+12
View File
@@ -77,6 +77,18 @@ export function initAppAnalytics() {
// Aucun feature flag dans le code. Sans ça, /flags recevrait les propriétés de personne
// ($initial_current_url...), que before_send ne voit pas.
advanced_disable_flags: true,
// Verrouillé dans le code plutôt que laissé au tableau de bord PostHog : clics morts, cartes de chaleur
// et exceptions enverraient le texte des éléments (des noms de la famille) ou des messages d'erreur, que
// before_send ne nettoie pas. Aucun script externe (replay, sondages, site apps), la CSP n'en autorise pas.
capture_dead_clicks: false,
capture_heatmaps: false,
capture_exceptions: false,
capture_performance: false,
rageclick: false,
disable_surveys: true,
disable_product_tours: true,
disable_web_experiments: true,
disable_external_dependency_loading: true,
before_send: scrubAnalyticsEvent,
})
ph = posthog
+11
View File
@@ -63,6 +63,17 @@ export const analytics = {
// Aucun feature flag dans le code. Sans ça, /flags recevrait les propriétés de personne
// ($initial_current_url...), que before_send ne voit pas.
advanced_disable_flags: true,
// Même verrouillage que l'app (frontend/src/utils/analytics.js) : rien d'activable depuis le tableau
// de bord PostHog, aucun script externe
capture_dead_clicks: false,
capture_heatmaps: false,
capture_exceptions: false,
capture_performance: false,
rageclick: false,
disable_surveys: true,
disable_product_tours: true,
disable_web_experiments: true,
disable_external_dependency_loading: true,
before_send: scrubAnalyticsEvent,
})
ph = posthog