From 92f95e581205448cc0cb9d25c155746ba9358271 Mon Sep 17 00:00:00 2001 From: opencode Date: Sun, 18 Oct 2026 23:24:16 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Haushalts-Export=20(JSON,=20ohne=20Secr?= =?UTF-8?q?ets)=20f=C3=BCr=20Server-=20und=20Haushalts-Admins;=20Security-?= =?UTF-8?q?Header=20(nosniff,=20DENY,=20Referrer/Permissions-Policy)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/households/[id]/export/route.ts | 71 +++++++++++++++++++++++++ app/settings/household-panel.tsx | 12 +++++ app/settings/server-households.tsx | 24 ++++++--- next.config.ts | 17 ++++++ 4 files changed, 117 insertions(+), 7 deletions(-) create mode 100644 app/api/households/[id]/export/route.ts diff --git a/app/api/households/[id]/export/route.ts b/app/api/households/[id]/export/route.ts new file mode 100644 index 0000000..62c241c --- /dev/null +++ b/app/api/households/[id]/export/route.ts @@ -0,0 +1,71 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getDb } from "@/lib/db"; +import { getViewer } from "@/lib/viewer"; + +/** + * Haushalts-Export als JSON (eigene Daten sichern/mitnehmen). + * Server-Admin: jeder Haushalt. Haushalts-Admin: nur der eigene. + * Enthält keine Passwort-Hashes, Secrets oder fremde Haushalte. + */ +export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + const viewer = await getViewer(); + if (!viewer) return NextResponse.json({ error: "unauthorized" }, { status: 401 }); + const { id } = await params; + const numId = Number(id); + if (!Number.isInteger(numId)) return NextResponse.json({ error: "not found" }, { status: 404 }); + + const isServerAdmin = viewer.role === "admin"; + const isOwnHouseholdAdmin = + !isServerAdmin && viewer.householdId === numId && viewer.householdRole === "admin"; + if (!isServerAdmin && !isOwnHouseholdAdmin) { + return NextResponse.json({ error: "forbidden" }, { status: 403 }); + } + + const db = getDb(); + const household = await db.household.findUnique({ + where: { id: numId }, + include: { + members: { include: { user: { select: { id: true, name: true, email: true, createdAt: true } } }, orderBy: { createdAt: "asc" } }, + vouchers: { include: { rules: true, contacts: { include: { contact: true } } }, orderBy: { createdAt: "asc" } }, + }, + }); + if (!household) return NextResponse.json({ error: "not found" }, { status: 404 }); + + const stamp = new Date().toISOString().slice(0, 10); + const body = { + export: "gutscheinnest-haushalt", + version: 1, + exportedAt: new Date().toISOString(), + household: { id: household.id, name: household.name }, + mitglieder: household.members.map((m) => ({ name: m.user.name, email: m.user.email, rolle: m.role, seit: m.createdAt })), + gutscheine: household.vouchers.map((v) => ({ + name: v.name, + anbieter: v.anbieter, + wertTyp: v.wertTyp, + wertCents: v.wertCents, + restCents: v.restCents, + prozentWert: v.prozentWert, + mindestbestellwertCents: v.mindestbestellwertCents, + ablaufdatum: v.ablaufdatum, + codeText: v.codeText, + kategorie: v.kategorie, + notiz: v.notiz, + shopUrl: v.shopUrl, + status: v.status, + angelegtVon: v.createdBy, + erinnerungstage: v.rules.map((r) => r.tageVorher), + kontakte: v.contacts.map((c) => ({ + name: c.contact.name, + email: c.contact.email, + telegramChatId: c.contact.telegramChatId, + })), + })), + }; + const safeName = household.name.toLowerCase().replace(/[^a-z0-9äöüß]+/gi, "-").replace(/^-+|-+$/g, "") || "haushalt"; + return new NextResponse(JSON.stringify(body, null, 2), { + headers: { + "content-type": "application/json; charset=utf-8", + "content-disposition": `attachment; filename="gutscheine-${safeName}-${stamp}.json"`, + }, + }); +} diff --git a/app/settings/household-panel.tsx b/app/settings/household-panel.tsx index 08431f0..39f8658 100644 --- a/app/settings/household-panel.tsx +++ b/app/settings/household-panel.tsx @@ -115,6 +115,18 @@ export function HouseholdPanel({ householdId, householdName, ownId, ownEmail }: )} +
+

Eigene Daten sichern

+

Lädt alle Gutscheine, Kontakte und Mitglieder dieses Haushalts als JSON-Datei herunter — ohne Passwörter oder Geheimnisse.

+ + Haushaltsdaten exportieren + +
+

Eigene E-Mail-Adresse

Für die SSO-Verknüpfung nötig.

diff --git a/app/settings/server-households.tsx b/app/settings/server-households.tsx index 90fb2d3..60f8660 100644 --- a/app/settings/server-households.tsx +++ b/app/settings/server-households.tsx @@ -155,13 +155,23 @@ function HouseholdCard({

{household.name}

- + + + Exportieren + + +

{household.mitgliederAnzahl} Mitglied(er) · {household.gutscheineAnzahl} Gutschein(e) im gemeinsamen Bestand diff --git a/next.config.ts b/next.config.ts index a3c5d10..2e037b6 100644 --- a/next.config.ts +++ b/next.config.ts @@ -4,5 +4,22 @@ const nextConfig: NextConfig = { // Kein next/image im Einsatz (nur plain ); Optimizer deaktivieren, // damit der /_next/image-Endpunkt sharp gar nicht erst lädt. images: { unoptimized: true }, + async headers() { + return [ + { + source: "/:path*", + headers: [ + // MIME-Sniffing verbieten (Bypässe bei Datei-Downloads erschweren) + { key: "X-Content-Type-Options", value: "nosniff" }, + // Kein Einbetten in fremde Seiten (Clickjacking-Schutz) + { key: "X-Frame-Options", value: "DENY" }, + // Referrer nur same-origin bzw. downgrade-sicher senden + { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" }, + // Browser-APIs einschränken, die die App nicht braucht + { key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()" }, + ], + }, + ]; + }, }; export default nextConfig;