feat: Haushalts-Export (JSON, ohne Secrets) für Server- und Haushalts-Admins; Security-Header (nosniff, DENY, Referrer/Permissions-Policy)

This commit is contained in:
opencode committed 2026-10-18 23:24:16 +02:00
1 parent 7ae291030a
commit 92f95e5812
4 files changed
+117 -7

No files matched your search

+71
View File
@@ -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"`,
},
});
}
+12
View File
@@ -115,6 +115,18 @@ export function HouseholdPanel({ householdId, householdName, ownId, ownEmail }:
)}
</section>
<section className="rounded-card border border-line bg-white p-5">
<h2 className="text-[15px] font-semibold tracking-tight">Eigene Daten sichern</h2>
<p className="mt-1 text-[13px] text-muted">Lädt alle Gutscheine, Kontakte und Mitglieder dieses Haushalts als JSON-Datei herunter — ohne Passwörter oder Geheimnisse.</p>
<a
href={`/api/households/${householdId}/export`}
download
className="mt-3 inline-block rounded-field bg-brand-600 px-4 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-brand-700"
>
Haushaltsdaten exportieren
</a>
</section>
<section className="rounded-card border border-line bg-white p-5">
<h2 className="text-[15px] font-semibold tracking-tight">Eigene E-Mail-Adresse</h2>
<p className="mt-1 text-[13px] text-muted">Für die SSO-Verknüpfung nötig.</p>
+17 -7
View File
@@ -155,13 +155,23 @@ function HouseholdCard({
<section className="rounded-card border border-line bg-white p-5">
<div className="flex items-center justify-between gap-3">
<h2 className="text-[15px] font-semibold tracking-tight">{household.name}</h2>
<button
type="button"
onClick={() => onDelete(household.id, household.name, household.gutscheineAnzahl, household.mitgliederAnzahl)}
className="shrink-0 rounded-lg px-2 py-1 text-[13px] font-medium text-muted hover:text-danger"
>
Haushalt auflösen
</button>
<span className="flex shrink-0 items-center gap-1">
<a
href={`/api/households/${household.id}/export`}
download
className="rounded-lg px-2 py-1 text-[13px] font-medium text-muted hover:text-brand-700"
title="Eigene Haushaltsdaten als JSON sichern"
>
Exportieren
</a>
<button
type="button"
onClick={() => onDelete(household.id, household.name, household.gutscheineAnzahl, household.mitgliederAnzahl)}
className="rounded-lg px-2 py-1 text-[13px] font-medium text-muted hover:text-danger"
>
Haushalt auflösen
</button>
</span>
</div>
<p className="mt-1 text-[13px] text-muted">
{household.mitgliederAnzahl} Mitglied(er) · {household.gutscheineAnzahl} Gutschein(e) im gemeinsamen Bestand
+17
View File
@@ -4,5 +4,22 @@ const nextConfig: NextConfig = {
// Kein next/image im Einsatz (nur plain <img>); 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;