feat: Haushalts-Export (JSON, ohne Secrets) für Server- und Haushalts-Admins; Security-Header (nosniff, DENY, Referrer/Permissions-Policy)
This commit is contained in:
1 parent
7ae291030a
commit
92f95e5812
4 files changed
+117
-7
No files matched your search
@@ -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"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
Reference in new issue
Block a user