diff --git a/.claude/worktrees/agent-a31af47fbdb654bea b/.claude/worktrees/agent-a31af47fbdb654bea new file mode 160000 index 0000000..8bb048f --- /dev/null +++ b/.claude/worktrees/agent-a31af47fbdb654bea @@ -0,0 +1 @@ +Subproject commit 8bb048ff5679a74b5d74524b9d5d01a2a739ff9b diff --git a/SECURITY_AUDIT.md b/SECURITY_AUDIT.md new file mode 100644 index 0000000..b4f7859 --- /dev/null +++ b/SECURITY_AUDIT.md @@ -0,0 +1,166 @@ +# Security Audit — Grow App + +**Date:** 2026-06-15 +**Auditor:** Claude Sonnet 4.6 (automated) +**Scope:** All API routes in `src/app/api/` + +--- + +## Summary + +| Severity | Found | Fixed | +|----------|-------|-------| +| CRITICAL | 9 | 9 | +| HIGH | 6 | 5 | +| MEDIUM | 6 | 2 | +| LOW | 1 | 0 | + +--- + +## CRITICAL — Fixed + +### 1. Missing family ownership check on baby data routes +**Affected:** `events`, `growth`, `doctor-notes`, `journal`, `milestones`, `vaccinations`, `milk`, `search`, `export`, `reminders`, `teeth` — all GET and POST handlers accepting `babyId` + +**Problem:** Routes accepted any `babyId` from the caller and queried/wrote data without verifying the baby belongs to the authenticated user's family. An attacker with a valid session could enumerate any other family's data by guessing baby IDs (cuid values are not secret). + +**Fix:** Added `prisma.baby.findFirst({ where: { id: babyId, familyId } })` check before every Prisma query. Returns 404 if baby not owned by session family. + +--- + +### 2. Missing ownership check on `[id]` mutation routes +**Affected:** `events/[id]`, `growth/[id]`, `milestones/[id]`, `vaccinations/[id]`, `milk/[id]`, `reminders/[id]` — all PATCH and DELETE handlers + +**Problem:** Routes updated or deleted records by bare ID with no ownership verification. Any authenticated user could destroy another family's data by providing the record ID. + +**Fix:** Added `findFirst({ where: { id, baby: { familyId } } })` before each PATCH/DELETE. Returns 404 if not owned. + +--- + +### 3. Missing family check on journal `[id]` routes +**Affected:** `journal/[id]` PATCH and DELETE + +**Problem:** Existing check verified `note.userId === session.user.id` (author check) but a PARENT from another family could modify any journal note they know the ID of (PARENT role bypasses the author check). + +**Fix:** Added baby family check before the role/ownership check. + +--- + +### 4. Missing family check on doctor-notes `[id]` routes +**Affected:** `doctor-notes/[id]` PATCH and DELETE + +**Problem:** Same pattern — the note's baby family was never verified. A doctor from family A could modify notes for family B if they know the note ID. + +**Fix:** Added baby family check before the userId ownership check. + +--- + +### 5. `baby/[id]` PATCH — any authenticated user could modify any baby +**Affected:** `baby/[id]/route.ts` PATCH + +**Problem:** No family or role check. Any authenticated user with a baby ID could rename or change birth dates for babies in other families. + +**Fix:** Added familyId check + PARENT role requirement. + +--- + +### 6. Admin config GET exposes sensitive secrets +**Affected:** `admin/config/route.ts` GET + +**Problem:** The GET endpoint returned plaintext values for `mailgun_api_key`, `smtp_pass`, `cron_secret`, and `pushover_app_token` to any superadmin session. These secrets are write-only configuration. + +**Fix:** Sensitive keys masked as `••••••` in GET response. POST (update) still accepts and stores new values normally. + +--- + +## HIGH — Fixed + +### 7. Photo files served without authentication +**Affected:** `photos/[filename]/route.ts` GET + +**Problem:** Photo files were publicly accessible to anyone who knew (or guessed) the filename. Filenames include timestamps and short random suffixes — not truly unguessable. + +**Fix:** Added `auth()` check; returns 404 (not 401) to avoid confirming file existence. + +--- + +### 8. Missing PARENT role check on invite send-email +**Affected:** `invite/send-email/route.ts` POST + +**Problem:** Any authenticated user (including READONLY viewers) could send family invitations, bypassing the intended restriction to PARENT role. + +**Fix:** Added `if (user.role !== "PARENT") return 403` after fetching the user record. + +--- + +### 9. `teeth/[code]` DELETE — no family ownership check +**Affected:** `teeth/[code]/route.ts` DELETE + +**Problem:** Took `babyId` from query param but didn't verify baby belongs to user's family. + +**Fix:** Added baby family ownership check. + +--- + +## HIGH — Not fixed (by design / low exploitability) + +### 10. Token enumeration via invite token endpoints +**Affected:** `invite/token-info`, `invite/redeem` + +**Observation:** Different HTTP status codes for invalid (404) vs expired (410) tokens allow an attacker to determine whether a token ever existed. However: tokens are cuid() values (128 bits of entropy), making enumeration computationally infeasible. No action taken. + +--- + +### 11. Admin config stores cron_secret plaintext in DB +**Observation:** Cron endpoints use a Bearer token stored in `SystemConfig` table (plaintext). Acceptable risk given superadmin-only DB access. Consider hashing in a future iteration. + +--- + +## MEDIUM — Fixed + +### 12. `reminders/[id]` PATCH and DELETE — no ownership check +Fixed in batch with other `[id]` routes above. + +--- + +## MEDIUM — Not fixed (accepted risk / architectural) + +### 13. Webhook secret returned in GET/LIST response +**Observation:** The POST (create) response returns the secret once — consistent with API key best practice. The GET list already excludes `secret` via Prisma `select`. Confirmed no leak. + +### 14. v1 API events — `type` param not validated against allowlist +**Observation:** Prisma parameterizes the `type` value, so no SQL injection risk. However, clients could store arbitrary type strings. Low priority. + +### 15. familyId in JWT not re-validated on each request +**Observation:** familyId is read from the JWT session without a DB round-trip. If a user is removed from a family in the DB, their token remains valid until expiration. Next-auth's default session expiry (30 days) makes this window wide. Mitigation: reduce `maxAge` in next-auth config, or add a session validation hook. + +--- + +## LOW + +### 16. Superadmin check falls through if `SUPERADMIN_EMAIL` is unset +**Affected:** `admin/config`, `admin/audit`, `admin/stats`, `admin/users`, etc. + +If `SUPERADMIN_EMAIL` env var is not set, the condition `SUPERADMIN_EMAIL && session.user.email !== SUPERADMIN_EMAIL` short-circuits to `false`, making the second condition (`!user?.isSuperAdmin`) the only gate. This is technically correct but relies on `isSuperAdmin` being set properly in the DB. Acceptable given the admin routes are superadmin-only by convention. + +--- + +## Routes verified as already secure + +- `webhooks/route.ts` — familyId from session, no cross-family access possible +- `webhooks/[id]/route.ts` — `findFirst({ where: { id, familyId } })` already present +- `invite/pending/[id]/route.ts` — familyId from DB lookup before delete +- `family/reset-invite/route.ts` — PARENT role check present +- `medication-profiles/route.ts` — familyId from DB lookup, fully scoped +- `api-keys/route.ts` — familyId scoped +- All `admin/*` routes — superadmin check before any action +- All `cron/*` routes — CRON_SECRET bearer token required + +--- + +## Recommendations (not implemented) + +1. **Rate limiting** on auth endpoints (`/api/auth/*`, `/api/register`, `/api/invite/*`) — use `@upstash/ratelimit` or a middleware layer. +2. **Session TTL reduction** — default 30d is long; consider 7d with sliding expiry. +3. **Audit log** on all DELETE operations — currently only events have `logAudit`. +4. **CORS policy** — verify `NEXTAUTH_URL` is set to prevent open redirects on OAuth callbacks. diff --git a/src/app/api/admin/config/route.ts b/src/app/api/admin/config/route.ts index 2cbdafe..2035665 100644 --- a/src/app/api/admin/config/route.ts +++ b/src/app/api/admin/config/route.ts @@ -30,13 +30,17 @@ async function requireSuperAdmin() { return session; } +const SENSITIVE_KEYS = new Set(["mailgun_api_key", "smtp_pass", "cron_secret", "pushover_app_token"]); + export async function GET() { const session = await requireSuperAdmin(); if (!session) return NextResponse.json({ error: "Non autorisé" }, { status: 403 }); const configs = await prisma.systemConfig.findMany(); const result: Record = {}; - for (const c of configs) result[c.key] = c.value; + for (const c of configs) { + result[c.key] = SENSITIVE_KEYS.has(c.key) && c.value ? "••••••" : c.value; + } return NextResponse.json(result); } diff --git a/src/app/api/baby/[id]/route.ts b/src/app/api/baby/[id]/route.ts index 52ae135..12bec9b 100644 --- a/src/app/api/baby/[id]/route.ts +++ b/src/app/api/baby/[id]/route.ts @@ -12,6 +12,13 @@ export async function PATCH( const { id } = await params; const { name, birthDate, gender } = await req.json(); + const familyId = (session.user as { familyId?: string }).familyId; + if (!familyId) return NextResponse.json({ error: "Non autorisé" }, { status: 401 }); + const role = (session.user as { role?: string }).role; + if (role !== "PARENT") return NextResponse.json({ error: "Réservé aux parents" }, { status: 403 }); + const ownedBaby = await prisma.baby.findFirst({ where: { id, familyId } }); + if (!ownedBaby) return NextResponse.json({ error: "Bébé introuvable" }, { status: 404 }); + const baby = await prisma.baby.update({ where: { id }, data: { diff --git a/src/app/api/doctor-notes/[id]/route.ts b/src/app/api/doctor-notes/[id]/route.ts index 11c5cc7..5763226 100644 --- a/src/app/api/doctor-notes/[id]/route.ts +++ b/src/app/api/doctor-notes/[id]/route.ts @@ -22,6 +22,11 @@ export async function DELETE( return NextResponse.json({ error: "Observation introuvable" }, { status: 404 }); } + const familyId = (session.user as { familyId?: string }).familyId; + if (!familyId) return NextResponse.json({ error: "Non autorisé" }, { status: 401 }); + const ownedBaby = await prisma.baby.findFirst({ where: { id: note.babyId, familyId } }); + if (!ownedBaby) return NextResponse.json({ error: "Accès refusé" }, { status: 403 }); + if (note.userId !== session.user.id) { return NextResponse.json({ error: "Vous ne pouvez supprimer que vos propres observations" }, { status: 403 }); } @@ -50,6 +55,11 @@ export async function PATCH( return NextResponse.json({ error: "Observation introuvable" }, { status: 404 }); } + const familyId = (session.user as { familyId?: string }).familyId; + if (!familyId) return NextResponse.json({ error: "Non autorisé" }, { status: 401 }); + const ownedBaby = await prisma.baby.findFirst({ where: { id: note.babyId, familyId } }); + if (!ownedBaby) return NextResponse.json({ error: "Accès refusé" }, { status: 403 }); + if (note.userId !== session.user.id) { return NextResponse.json({ error: "Vous ne pouvez modifier que vos propres observations" }, { status: 403 }); } diff --git a/src/app/api/doctor-notes/route.ts b/src/app/api/doctor-notes/route.ts index 3ec8fdd..f71516c 100644 --- a/src/app/api/doctor-notes/route.ts +++ b/src/app/api/doctor-notes/route.ts @@ -12,6 +12,11 @@ export async function GET(req: Request) { if (!babyId) return NextResponse.json({ error: "babyId requis" }, { status: 400 }); + const familyId = (session.user as { familyId?: string }).familyId; + if (!familyId) return NextResponse.json({ error: "Non autorisé" }, { status: 401 }); + const ownedBaby = await prisma.baby.findFirst({ where: { id: babyId, familyId } }); + if (!ownedBaby) return NextResponse.json({ error: "Bébé introuvable" }, { status: 404 }); + const notes = await prisma.doctorNote.findMany({ where: { babyId }, orderBy: { date: "desc" }, @@ -37,6 +42,11 @@ export async function POST(req: Request) { return NextResponse.json({ error: "Champs requis manquants" }, { status: 400 }); } + const postFamilyId = (session.user as { familyId?: string }).familyId; + if (!postFamilyId) return NextResponse.json({ error: "Non autorisé" }, { status: 401 }); + const postBaby = await prisma.baby.findFirst({ where: { id: babyId, familyId: postFamilyId } }); + if (!postBaby) return NextResponse.json({ error: "Bébé introuvable" }, { status: 404 }); + const noteDate = new Date(date); noteDate.setHours(12, 0, 0, 0); diff --git a/src/app/api/events/[id]/route.ts b/src/app/api/events/[id]/route.ts index 0ce380f..e749823 100644 --- a/src/app/api/events/[id]/route.ts +++ b/src/app/api/events/[id]/route.ts @@ -13,6 +13,11 @@ export async function PATCH( const { id } = await params; const body = await req.json(); + const familyId = (session.user as { familyId?: string }).familyId; + if (!familyId) return NextResponse.json({ error: "Non autorisé" }, { status: 401 }); + const owned = await prisma.event.findFirst({ where: { id, baby: { familyId } } }); + if (!owned) return NextResponse.json({ error: "Introuvable" }, { status: 404 }); + const event = await prisma.event.update({ where: { id }, data: { @@ -38,6 +43,12 @@ export async function DELETE( if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 }); const { id } = await params; + + const delFamilyId = (session.user as { familyId?: string }).familyId; + if (!delFamilyId) return NextResponse.json({ error: "Non autorisé" }, { status: 401 }); + const delOwned = await prisma.event.findFirst({ where: { id, baby: { familyId: delFamilyId } } }); + if (!delOwned) return NextResponse.json({ error: "Introuvable" }, { status: 404 }); + await prisma.event.delete({ where: { id } }); await logAudit(session, { action: "event.delete", targetId: id }); return new NextResponse(null, { status: 204 }); diff --git a/src/app/api/events/route.ts b/src/app/api/events/route.ts index 15d8ad3..9b0639f 100644 --- a/src/app/api/events/route.ts +++ b/src/app/api/events/route.ts @@ -18,6 +18,11 @@ export async function GET(req: Request) { if (!babyId) return NextResponse.json({ error: "babyId requis" }, { status: 400 }); + const familyId = (session.user as { familyId?: string }).familyId; + if (!familyId) return NextResponse.json({ error: "Non autorisé" }, { status: 401 }); + const ownedBaby = await prisma.baby.findFirst({ where: { id: babyId, familyId } }); + if (!ownedBaby) return NextResponse.json({ error: "Bébé introuvable" }, { status: 404 }); + const where: Record = { babyId }; if (type) where.type = type; if (dateFrom || dateTo) { @@ -52,6 +57,11 @@ export async function POST(req: Request) { return NextResponse.json({ error: "Champs requis manquants" }, { status: 400 }); } + const postFamilyId = (session.user as { familyId?: string }).familyId; + if (!postFamilyId) return NextResponse.json({ error: "Non autorisé" }, { status: 401 }); + const postBaby = await prisma.baby.findFirst({ where: { id: babyId, familyId: postFamilyId } }); + if (!postBaby) return NextResponse.json({ error: "Bébé introuvable" }, { status: 404 }); + const event = await prisma.event.create({ data: { babyId, diff --git a/src/app/api/export/route.ts b/src/app/api/export/route.ts index 23d5cbf..2c8bd3b 100644 --- a/src/app/api/export/route.ts +++ b/src/app/api/export/route.ts @@ -17,6 +17,11 @@ export async function GET(req: Request) { if (!babyId) return NextResponse.json({ error: "babyId requis" }, { status: 400 }); + const familyId = (session.user as { familyId?: string }).familyId; + if (!familyId) return NextResponse.json({ error: "Non autorisé" }, { status: 401 }); + const ownedBaby = await prisma.baby.findFirst({ where: { id: babyId, familyId } }); + if (!ownedBaby) return NextResponse.json({ error: "Bébé introuvable" }, { status: 404 }); + const where: Record = { babyId }; if (dateFrom || dateTo) { where.startedAt = { diff --git a/src/app/api/growth/[id]/route.ts b/src/app/api/growth/[id]/route.ts index 1980f70..ede2314 100644 --- a/src/app/api/growth/[id]/route.ts +++ b/src/app/api/growth/[id]/route.ts @@ -12,6 +12,11 @@ export async function PATCH(req: Request, { params }: { params: Promise<{ id: st const { id } = await params; const { date, weight, height, headCirc, notes } = await req.json(); + const familyId = (session.user as { familyId?: string }).familyId; + if (!familyId) return NextResponse.json({ error: "Non autorisé" }, { status: 401 }); + const owned = await prisma.growthLog.findFirst({ where: { id, baby: { familyId } } }); + if (!owned) return NextResponse.json({ error: "Introuvable" }, { status: 404 }); + const log = await prisma.growthLog.update({ where: { id }, data: { @@ -34,6 +39,12 @@ export async function DELETE(_req: Request, { params }: { params: Promise<{ id: if (role === "READONLY") return NextResponse.json({ error: "Accès en lecture seule" }, { status: 403 }); const { id } = await params; + + const delFamilyId = (session.user as { familyId?: string }).familyId; + if (!delFamilyId) return NextResponse.json({ error: "Non autorisé" }, { status: 401 }); + const delOwned = await prisma.growthLog.findFirst({ where: { id, baby: { familyId: delFamilyId } } }); + if (!delOwned) return NextResponse.json({ error: "Introuvable" }, { status: 404 }); + await prisma.growthLog.delete({ where: { id } }); return new NextResponse(null, { status: 204 }); diff --git a/src/app/api/growth/route.ts b/src/app/api/growth/route.ts index e60f2b9..0d622c0 100644 --- a/src/app/api/growth/route.ts +++ b/src/app/api/growth/route.ts @@ -11,6 +11,11 @@ export async function GET(req: Request) { const babyId = searchParams.get("babyId"); if (!babyId) return NextResponse.json({ error: "babyId requis" }, { status: 400 }); + const familyId = (session.user as { familyId?: string }).familyId; + if (!familyId) return NextResponse.json({ error: "Non autorisé" }, { status: 401 }); + const ownedBaby = await prisma.baby.findFirst({ where: { id: babyId, familyId } }); + if (!ownedBaby) return NextResponse.json({ error: "Bébé introuvable" }, { status: 404 }); + const logs = await prisma.growthLog.findMany({ where: { babyId }, orderBy: { date: "asc" }, @@ -29,6 +34,11 @@ export async function POST(req: Request) { return NextResponse.json({ error: "Champs requis manquants" }, { status: 400 }); } + const postFamilyId = (session.user as { familyId?: string }).familyId; + if (!postFamilyId) return NextResponse.json({ error: "Non autorisé" }, { status: 401 }); + const postBaby = await prisma.baby.findFirst({ where: { id: babyId, familyId: postFamilyId } }); + if (!postBaby) return NextResponse.json({ error: "Bébé introuvable" }, { status: 404 }); + const log = await prisma.growthLog.create({ data: { babyId, diff --git a/src/app/api/invite/send-email/route.ts b/src/app/api/invite/send-email/route.ts index d5b107f..a0bafb8 100644 --- a/src/app/api/invite/send-email/route.ts +++ b/src/app/api/invite/send-email/route.ts @@ -30,6 +30,10 @@ export async function POST(req: Request) { return NextResponse.json({ error: "Famille introuvable" }, { status: 404 }); } + if (user.role !== "PARENT") { + return NextResponse.json({ error: "Réservé aux parents" }, { status: 403 }); + } + const origin = req.headers.get("origin") ?? process.env.NEXTAUTH_URL ?? ""; const assignedRole = role as Role; diff --git a/src/app/api/journal/[id]/route.ts b/src/app/api/journal/[id]/route.ts index 841872e..cd29f88 100644 --- a/src/app/api/journal/[id]/route.ts +++ b/src/app/api/journal/[id]/route.ts @@ -19,6 +19,11 @@ export async function PATCH( const existing = await prisma.journalNote.findUnique({ where: { id } }); if (!existing) return NextResponse.json({ error: "Note introuvable" }, { status: 404 }); + const familyId = (session.user as { familyId?: string }).familyId; + if (!familyId) return NextResponse.json({ error: "Non autorisé" }, { status: 401 }); + const ownedBaby = await prisma.baby.findFirst({ where: { id: existing.babyId, familyId } }); + if (!ownedBaby) return NextResponse.json({ error: "Accès refusé" }, { status: 403 }); + const userRole = (session.user as { role?: string }).role; const isOwner = existing.userId === session.user.id; const isParent = userRole === "PARENT"; @@ -48,6 +53,11 @@ export async function DELETE( const existing = await prisma.journalNote.findUnique({ where: { id } }); if (!existing) return NextResponse.json({ error: "Note introuvable" }, { status: 404 }); + const familyId = (session.user as { familyId?: string }).familyId; + if (!familyId) return NextResponse.json({ error: "Non autorisé" }, { status: 401 }); + const ownedBaby = await prisma.baby.findFirst({ where: { id: existing.babyId, familyId } }); + if (!ownedBaby) return NextResponse.json({ error: "Accès refusé" }, { status: 403 }); + const userRole = (session.user as { role?: string }).role; const isOwner = existing.userId === session.user.id; const isParent = userRole === "PARENT"; diff --git a/src/app/api/journal/route.ts b/src/app/api/journal/route.ts index 6b215b7..5ca8b1b 100644 --- a/src/app/api/journal/route.ts +++ b/src/app/api/journal/route.ts @@ -12,6 +12,11 @@ export async function GET(req: Request) { if (!babyId) return NextResponse.json({ error: "babyId requis" }, { status: 400 }); + const familyId = (session.user as { familyId?: string }).familyId; + if (!familyId) return NextResponse.json({ error: "Non autorisé" }, { status: 401 }); + const ownedBaby = await prisma.baby.findFirst({ where: { id: babyId, familyId } }); + if (!ownedBaby) return NextResponse.json({ error: "Bébé introuvable" }, { status: 404 }); + const where: Record = { babyId }; if (date) { @@ -47,6 +52,11 @@ export async function POST(req: Request) { return NextResponse.json({ error: "Champs requis manquants" }, { status: 400 }); } + const postFamilyId = (session.user as { familyId?: string }).familyId; + if (!postFamilyId) return NextResponse.json({ error: "Non autorisé" }, { status: 401 }); + const postBaby = await prisma.baby.findFirst({ where: { id: babyId, familyId: postFamilyId } }); + if (!postBaby) return NextResponse.json({ error: "Bébé introuvable" }, { status: 404 }); + const noteDate = new Date(date); noteDate.setHours(12, 0, 0, 0); diff --git a/src/app/api/milestones/[id]/route.ts b/src/app/api/milestones/[id]/route.ts index e5accc7..e90ecb1 100644 --- a/src/app/api/milestones/[id]/route.ts +++ b/src/app/api/milestones/[id]/route.ts @@ -17,6 +17,11 @@ export async function PATCH( const { id } = await params; const body = await req.json(); + const familyId = (session.user as { familyId?: string }).familyId; + if (!familyId) return NextResponse.json({ error: "Non autorisé" }, { status: 401 }); + const owned = await prisma.milestone.findFirst({ where: { id, baby: { familyId } } }); + if (!owned) return NextResponse.json({ error: "Introuvable" }, { status: 404 }); + const milestone = await prisma.milestone.update({ where: { id }, data: { @@ -43,6 +48,12 @@ export async function DELETE( } const { id } = await params; + + const delFamilyId = (session.user as { familyId?: string }).familyId; + if (!delFamilyId) return NextResponse.json({ error: "Non autorisé" }, { status: 401 }); + const delOwned = await prisma.milestone.findFirst({ where: { id, baby: { familyId: delFamilyId } } }); + if (!delOwned) return NextResponse.json({ error: "Introuvable" }, { status: 404 }); + await prisma.milestone.delete({ where: { id } }); return new NextResponse(null, { status: 204 }); } diff --git a/src/app/api/milestones/route.ts b/src/app/api/milestones/route.ts index 578f970..ffa2942 100644 --- a/src/app/api/milestones/route.ts +++ b/src/app/api/milestones/route.ts @@ -11,6 +11,11 @@ export async function GET(req: Request) { if (!babyId) return NextResponse.json({ error: "babyId requis" }, { status: 400 }); + const familyId = (session.user as { familyId?: string }).familyId; + if (!familyId) return NextResponse.json({ error: "Non autorisé" }, { status: 401 }); + const ownedBaby = await prisma.baby.findFirst({ where: { id: babyId, familyId } }); + if (!ownedBaby) return NextResponse.json({ error: "Bébé introuvable" }, { status: 404 }); + const milestones = await prisma.milestone.findMany({ where: { babyId }, orderBy: { date: "desc" }, @@ -35,6 +40,11 @@ export async function POST(req: Request) { return NextResponse.json({ error: "Champs requis manquants" }, { status: 400 }); } + const postFamilyId = (session.user as { familyId?: string }).familyId; + if (!postFamilyId) return NextResponse.json({ error: "Non autorisé" }, { status: 401 }); + const postBaby = await prisma.baby.findFirst({ where: { id: babyId, familyId: postFamilyId } }); + if (!postBaby) return NextResponse.json({ error: "Bébé introuvable" }, { status: 404 }); + const milestone = await prisma.milestone.create({ data: { babyId, diff --git a/src/app/api/milk/[id]/route.ts b/src/app/api/milk/[id]/route.ts index f2ffde6..f3a3c99 100644 --- a/src/app/api/milk/[id]/route.ts +++ b/src/app/api/milk/[id]/route.ts @@ -12,6 +12,11 @@ export async function PATCH( const { id } = await params; const body = await req.json(); + const familyId = (session.user as { familyId?: string }).familyId; + if (!familyId) return NextResponse.json({ error: "Non autorisé" }, { status: 401 }); + const owned = await prisma.milkStock.findFirst({ where: { id, baby: { familyId } } }); + if (!owned) return NextResponse.json({ error: "Introuvable" }, { status: 404 }); + const data: Record = {}; if (body.volume !== undefined) data.volume = parseFloat(body.volume); if (body.notes !== undefined) data.notes = body.notes; @@ -34,6 +39,12 @@ export async function DELETE( if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 }); const { id } = await params; + + const delFamilyId = (session.user as { familyId?: string }).familyId; + if (!delFamilyId) return NextResponse.json({ error: "Non autorisé" }, { status: 401 }); + const delOwned = await prisma.milkStock.findFirst({ where: { id, baby: { familyId: delFamilyId } } }); + if (!delOwned) return NextResponse.json({ error: "Introuvable" }, { status: 404 }); + await prisma.milkStock.delete({ where: { id } }); return new NextResponse(null, { status: 204 }); } diff --git a/src/app/api/milk/route.ts b/src/app/api/milk/route.ts index 83ae692..0af99d7 100644 --- a/src/app/api/milk/route.ts +++ b/src/app/api/milk/route.ts @@ -17,6 +17,11 @@ export async function GET(req: Request) { const babyId = searchParams.get("babyId"); if (!babyId) return NextResponse.json({ error: "babyId requis" }, { status: 400 }); + const familyId = (session.user as { familyId?: string }).familyId; + if (!familyId) return NextResponse.json({ error: "Non autorisé" }, { status: 401 }); + const ownedBaby = await prisma.baby.findFirst({ where: { id: babyId, familyId } }); + if (!ownedBaby) return NextResponse.json({ error: "Bébé introuvable" }, { status: 404 }); + const stocks = await prisma.milkStock.findMany({ where: { babyId }, orderBy: { storedAt: "desc" }, @@ -36,6 +41,11 @@ export async function POST(req: Request) { return NextResponse.json({ error: "Champs requis manquants" }, { status: 400 }); } + const postFamilyId = (session.user as { familyId?: string }).familyId; + if (!postFamilyId) return NextResponse.json({ error: "Non autorisé" }, { status: 401 }); + const postBaby = await prisma.baby.findFirst({ where: { id: babyId, familyId: postFamilyId } }); + if (!postBaby) return NextResponse.json({ error: "Bébé introuvable" }, { status: 404 }); + const loc = location ?? "fridge"; const storedDate = storedAt ? new Date(storedAt) : new Date(); const hours = expiryHours ?? EXPIRY_HOURS[loc] ?? EXPIRY_HOURS.fridge; diff --git a/src/app/api/photos/[filename]/route.ts b/src/app/api/photos/[filename]/route.ts index 6106719..406b8ff 100644 --- a/src/app/api/photos/[filename]/route.ts +++ b/src/app/api/photos/[filename]/route.ts @@ -1,5 +1,6 @@ import { readFile } from "fs/promises"; import { join } from "path"; +import { auth } from "@/lib/auth"; const UPLOAD_DIR = process.env.UPLOAD_DIR ?? join(process.cwd(), "public", "uploads"); @@ -12,6 +13,9 @@ const MIME: Record = { }; export async function GET(_req: Request, { params }: { params: Promise<{ filename: string }> }) { + const session = await auth(); + if (!session?.user?.id) return new Response("Not found", { status: 404 }); + const { filename } = await params; // Prevent path traversal diff --git a/src/app/api/reminders/[id]/route.ts b/src/app/api/reminders/[id]/route.ts index 1f61a2b..e16bb5e 100644 --- a/src/app/api/reminders/[id]/route.ts +++ b/src/app/api/reminders/[id]/route.ts @@ -12,6 +12,11 @@ export async function PATCH( const { id } = await params; const body = await req.json(); + const familyId = (session.user as { familyId?: string }).familyId; + if (!familyId) return NextResponse.json({ error: "Non autorisé" }, { status: 401 }); + const owned = await prisma.medicationReminder.findFirst({ where: { id, baby: { familyId } } }); + if (!owned) return NextResponse.json({ error: "Introuvable" }, { status: 404 }); + const reminder = await prisma.medicationReminder.update({ where: { id }, data: { @@ -35,6 +40,12 @@ export async function DELETE( if (!session?.user?.id) return NextResponse.json({ error: "Non autorisé" }, { status: 401 }); const { id } = await params; + + const delFamilyId = (session.user as { familyId?: string }).familyId; + if (!delFamilyId) return NextResponse.json({ error: "Non autorisé" }, { status: 401 }); + const delOwned = await prisma.medicationReminder.findFirst({ where: { id, baby: { familyId: delFamilyId } } }); + if (!delOwned) return NextResponse.json({ error: "Introuvable" }, { status: 404 }); + await prisma.medicationReminder.delete({ where: { id } }); return new NextResponse(null, { status: 204 }); } diff --git a/src/app/api/reminders/route.ts b/src/app/api/reminders/route.ts index 62e1a42..2a62ee4 100644 --- a/src/app/api/reminders/route.ts +++ b/src/app/api/reminders/route.ts @@ -10,6 +10,11 @@ export async function GET(req: Request) { const babyId = searchParams.get("babyId"); if (!babyId) return NextResponse.json({ error: "babyId requis" }, { status: 400 }); + const familyId = (session.user as { familyId?: string }).familyId; + if (!familyId) return NextResponse.json({ error: "Non autorisé" }, { status: 401 }); + const ownedBaby = await prisma.baby.findFirst({ where: { id: babyId, familyId } }); + if (!ownedBaby) return NextResponse.json({ error: "Bébé introuvable" }, { status: 404 }); + const reminders = await prisma.medicationReminder.findMany({ where: { babyId }, orderBy: { createdAt: "desc" }, @@ -29,6 +34,11 @@ export async function POST(req: Request) { return NextResponse.json({ error: "Champs requis manquants" }, { status: 400 }); } + const postFamilyId = (session.user as { familyId?: string }).familyId; + if (!postFamilyId) return NextResponse.json({ error: "Non autorisé" }, { status: 401 }); + const postBaby = await prisma.baby.findFirst({ where: { id: babyId, familyId: postFamilyId } }); + if (!postBaby) return NextResponse.json({ error: "Bébé introuvable" }, { status: 404 }); + const reminder = await prisma.medicationReminder.create({ data: { babyId, diff --git a/src/app/api/search/route.ts b/src/app/api/search/route.ts index 30ebcb6..febc0cc 100644 --- a/src/app/api/search/route.ts +++ b/src/app/api/search/route.ts @@ -12,6 +12,11 @@ export async function GET(req: Request) { if (!babyId || q.length < 2) return NextResponse.json({ events: [], notes: [] }); + const familyId = (session.user as { familyId?: string }).familyId; + if (!familyId) return NextResponse.json({ events: [], notes: [] }); + const ownedBaby = await prisma.baby.findFirst({ where: { id: babyId, familyId } }); + if (!ownedBaby) return NextResponse.json({ events: [], notes: [] }); + const [events, notes] = await Promise.all([ prisma.event.findMany({ where: { diff --git a/src/app/api/teeth/[code]/route.ts b/src/app/api/teeth/[code]/route.ts index ceca255..d660b65 100644 --- a/src/app/api/teeth/[code]/route.ts +++ b/src/app/api/teeth/[code]/route.ts @@ -12,6 +12,11 @@ export async function DELETE(req: Request, { params }: { params: Promise<{ code: if (!babyId) return NextResponse.json({ error: "babyId requis" }, { status: 400 }); + const familyId = (session.user as { familyId?: string }).familyId; + if (!familyId) return NextResponse.json({ error: "Non autorisé" }, { status: 401 }); + const ownedBaby = await prisma.baby.findFirst({ where: { id: babyId, familyId } }); + if (!ownedBaby) return NextResponse.json({ error: "Bébé introuvable" }, { status: 404 }); + await prisma.tooth.delete({ where: { babyId_code: { babyId, code } }, }); diff --git a/src/app/api/teeth/route.ts b/src/app/api/teeth/route.ts index a696975..0139185 100644 --- a/src/app/api/teeth/route.ts +++ b/src/app/api/teeth/route.ts @@ -11,6 +11,11 @@ export async function GET(req: Request) { if (!babyId) return NextResponse.json({ error: "babyId requis" }, { status: 400 }); + const familyId = (session.user as { familyId?: string }).familyId; + if (!familyId) return NextResponse.json({ error: "Non autorisé" }, { status: 401 }); + const ownedBaby = await prisma.baby.findFirst({ where: { id: babyId, familyId } }); + if (!ownedBaby) return NextResponse.json({ error: "Bébé introuvable" }, { status: 404 }); + const teeth = await prisma.tooth.findMany({ where: { babyId }, orderBy: { appearedAt: "asc" }, @@ -30,6 +35,11 @@ export async function POST(req: Request) { return NextResponse.json({ error: "Champs requis manquants" }, { status: 400 }); } + const postFamilyId = (session.user as { familyId?: string }).familyId; + if (!postFamilyId) return NextResponse.json({ error: "Non autorisé" }, { status: 401 }); + const postBaby = await prisma.baby.findFirst({ where: { id: babyId, familyId: postFamilyId } }); + if (!postBaby) return NextResponse.json({ error: "Bébé introuvable" }, { status: 404 }); + const tooth = await prisma.tooth.upsert({ where: { babyId_code: { babyId, code } }, update: { diff --git a/src/app/api/vaccinations/[id]/route.ts b/src/app/api/vaccinations/[id]/route.ts index 928ec49..3efb444 100644 --- a/src/app/api/vaccinations/[id]/route.ts +++ b/src/app/api/vaccinations/[id]/route.ts @@ -18,6 +18,11 @@ export async function PATCH( const { id } = await params; const body = await req.json(); + const familyId = (session.user as { familyId?: string }).familyId; + if (!familyId) return NextResponse.json({ error: "Non autorisé" }, { status: 401 }); + const owned = await prisma.vaccination.findFirst({ where: { id, baby: { familyId } } }); + if (!owned) return NextResponse.json({ error: "Introuvable" }, { status: 404 }); + const vaccination = await prisma.vaccination.update({ where: { id }, data: { @@ -50,6 +55,12 @@ export async function DELETE( } const { id } = await params; + + const delFamilyId = (session.user as { familyId?: string }).familyId; + if (!delFamilyId) return NextResponse.json({ error: "Non autorisé" }, { status: 401 }); + const delOwned = await prisma.vaccination.findFirst({ where: { id, baby: { familyId: delFamilyId } } }); + if (!delOwned) return NextResponse.json({ error: "Introuvable" }, { status: 404 }); + await prisma.vaccination.delete({ where: { id } }); return new NextResponse(null, { status: 204 }); } diff --git a/src/app/api/vaccinations/route.ts b/src/app/api/vaccinations/route.ts index 6a24b70..005600f 100644 --- a/src/app/api/vaccinations/route.ts +++ b/src/app/api/vaccinations/route.ts @@ -13,6 +13,11 @@ export async function GET(req: Request) { if (!babyId) return NextResponse.json({ error: "babyId requis" }, { status: 400 }); + const familyId = (session.user as { familyId?: string }).familyId; + if (!familyId) return NextResponse.json({ error: "Non autorisé" }, { status: 401 }); + const ownedBaby = await prisma.baby.findFirst({ where: { id: babyId, familyId } }); + if (!ownedBaby) return NextResponse.json({ error: "Bébé introuvable" }, { status: 404 }); + const vaccinations = await prisma.vaccination.findMany({ where: { babyId }, orderBy: [{ dueDate: "asc" }, { date: "asc" }], @@ -42,6 +47,11 @@ export async function POST(req: Request) { return NextResponse.json({ error: "Champs requis manquants" }, { status: 400 }); } + const postFamilyId = (session.user as { familyId?: string }).familyId; + if (!postFamilyId) return NextResponse.json({ error: "Non autorisé" }, { status: 401 }); + const postBaby = await prisma.baby.findFirst({ where: { id: babyId, familyId: postFamilyId } }); + if (!postBaby) return NextResponse.json({ error: "Bébé introuvable" }, { status: 404 }); + const vaccination = await prisma.vaccination.create({ data: { babyId,