diff --git a/app/(app)/analytics/page.tsx b/app/(app)/analytics/page.tsx index 615a381..a47e761 100644 --- a/app/(app)/analytics/page.tsx +++ b/app/(app)/analytics/page.tsx @@ -6,6 +6,8 @@ import { HitsLineChart } from "@/components/charts/HitsLineChart"; import { TopDomainsBarChart } from "@/components/charts/TopDomainsBarChart"; import { CountryPie } from "@/components/charts/CountryPie"; import { ExportPdfButton } from "./ExportPdfButton"; +import { Button } from "@/components/ui/button"; +import { FileDown } from "lucide-react"; export const dynamic = "force-dynamic"; @@ -51,7 +53,14 @@ export default function AnalyticsPage() { } + actions={ +
+ + +
+ } />
diff --git a/app/(app)/audit/page.tsx b/app/(app)/audit/page.tsx new file mode 100644 index 0000000..9f119b4 --- /dev/null +++ b/app/(app)/audit/page.tsx @@ -0,0 +1,76 @@ +import { PageHeader } from "@/components/PageHeader"; +import { Card, CardContent } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { getDb } from "@/lib/db"; + +export const dynamic = "force-dynamic"; + +type Entry = { + id: number; + ts: number; + user_email: string | null; + action: string; + target_type: string | null; + target_id: string | null; + details: string | null; +}; + +const ACTION_VARIANT: Record = { + "domain.create": "green", + "domain.update": "blue", + "domain.delete": "destructive", + "domain.verify": "amber", + "group.create": "green", + "group.update": "blue", + "group.delete": "destructive", + "sunset.bulk": "amber", + "domain.bulk_delete": "destructive", + "settings.update": "blue", + "geo.install": "green", + "geo.remove": "destructive", + "token.create": "green", + "token.revoke": "destructive", + "update.apply": "amber", +}; + +export default function AuditPage() { + const rows = getDb().prepare("SELECT id, ts, user_email, action, target_type, target_id, details FROM audit_log ORDER BY ts DESC LIMIT 500").all() as Entry[]; + + return ( +
+ +
+ {rows.length === 0 ? ( + Noch keine Aktionen geloggt. + ) : ( + + + + + + + + + + + + + + {rows.map((r) => ( + + + + + + + + ))} + +
ZeitBenutzerAktionZielDetails
{new Date(r.ts).toLocaleString("de-DE")}{r.user_email || "—"}{r.action}{r.target_type ? `${r.target_type}#${r.target_id ?? ""}` : "—"}{(r.details || "").slice(0, 120)}
+
+
+ )} +
+
+ ); +} diff --git a/app/(app)/domains/BulkSunsetClient.tsx b/app/(app)/domains/BulkSunsetClient.tsx index bbdd446..45a2e71 100644 --- a/app/(app)/domains/BulkSunsetClient.tsx +++ b/app/(app)/domains/BulkSunsetClient.tsx @@ -2,7 +2,7 @@ import { useState } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; -import { CheckCircle2, Clock, AlertCircle, ArrowRight, Loader2, Sunset } from "lucide-react"; +import { CheckCircle2, Clock, AlertCircle, ArrowRight, Loader2, Sunset, Trash2 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { Card, CardContent } from "@/components/ui/card"; @@ -80,6 +80,27 @@ export function DomainsListClient({ domains }: { domains: DomainListRow[] }) { } } + async function bulkDelete() { + const ids = Array.from(selected); + const totalHits = domains.filter((d) => selected.has(d.id)).reduce((s, d) => s + d.total_hits, 0); + const msg = `${ids.length} Domain${ids.length === 1 ? "" : "s"} unwiderruflich löschen?\n\n${totalHits.toLocaleString("de-DE")} Hits werden mitgelöscht.`; + if (!confirm(msg)) return; + setSaving(true); + try { + const r = await fetch("/api/domains/bulk-delete", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ domain_ids: ids }), + }); + if (r.ok) { + setSelected(new Set()); + router.refresh(); + } + } finally { + setSaving(false); + } + } + return (
{selected.size > 0 && ( @@ -87,9 +108,13 @@ export function DomainsListClient({ domains }: { domains: DomainListRow[] }) { {selected.size} ausgewählt
+
diff --git a/app/(app)/domains/[id]/DnsRecordsCard.tsx b/app/(app)/domains/[id]/DnsRecordsCard.tsx new file mode 100644 index 0000000..00ed511 --- /dev/null +++ b/app/(app)/domains/[id]/DnsRecordsCard.tsx @@ -0,0 +1,115 @@ +"use client"; +import { useEffect, useState } from "react"; +import { Loader2, RefreshCcw, CheckCircle2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; + +type Records = { + domain: string; + A: string[]; + AAAA: string[]; + CNAME: string[]; + MX: { exchange: string; priority: number }[]; + NS: string[]; + TXT: string[]; + SOA: { nsname: string; hostmaster: string; serial: number } | null; + CAA: { issue?: string; issuewild?: string; iodef?: string }[]; + errors: Record; +}; + +export function DnsRecordsCard({ domainId, expectedIpv4, expectedIpv6 }: { domainId: number; expectedIpv4: string | null; expectedIpv6: string | null }) { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(false); + + async function load() { + setLoading(true); + try { + const r = await fetch(`/api/domains/${domainId}/dns-records`, { cache: "no-store" }); + if (r.ok) setData(await r.json()); + } finally { + setLoading(false); + } + } + useEffect(() => { load(); /* eslint-disable-next-line react-hooks/exhaustive-deps */ }, [domainId]); + + if (!data) { + return
; + } + + return ( +
+
+ +
+ + + + {data.CNAME.length > 0 && } + {data.MX.length > 0 && ( + + {data.MX.map((m, i) => ( + + ))} + + )} + {data.NS.length > 0 && } + {data.TXT.length > 0 && ( + + {data.TXT.map((t, i) => )} + + )} + {data.CAA.length > 0 && ( + + {data.CAA.map((c, i) => k !== "critical").map(([k, v]) => `${k}=${v}`).join(" ")} />)} + + )} + {data.SOA && ( + + + + )} + + {Object.keys(data.errors).length > 0 && ( +

+ DNS-Lookup-Fehler: {Object.entries(data.errors).map(([k, v]) => `${k}: ${v}`).join(" • ")} +

+ )} +
+ ); +} + +function Block({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+ {label} +
{children}
+
+ ); +} + +function Row({ value, match, mono }: { value: string; match?: boolean; mono?: boolean }) { + return ( +
+ {value} + {match && } +
+ ); +} + +function RecordList({ type, values, highlight = [], empty }: { type: string; values: string[]; highlight?: string[]; empty?: string }) { + if (values.length === 0) { + return empty ? ( + + {empty} + + ) : null; + } + return ( + + {values.map((v, i) => )} + + ); +} diff --git a/app/(app)/domains/[id]/DomainActions.tsx b/app/(app)/domains/[id]/DomainActions.tsx index 25370c5..3b31c86 100644 --- a/app/(app)/domains/[id]/DomainActions.tsx +++ b/app/(app)/domains/[id]/DomainActions.tsx @@ -4,7 +4,7 @@ import { useState } from "react"; import { RefreshCcw, Trash2, Loader2 } from "lucide-react"; import { Button } from "@/components/ui/button"; -export function DomainActions({ id, status }: { id: number; status: string }) { +export function DomainActions({ id, status, hitsTotal = 0, domainName = "" }: { id: number; status: string; hitsTotal?: number; domainName?: string }) { const router = useRouter(); const [busy, setBusy] = useState<"verify" | "delete" | null>(null); const [msg, setMsg] = useState(""); @@ -23,7 +23,10 @@ export function DomainActions({ id, status }: { id: number; status: string }) { } async function del() { - if (!confirm("Domain wirklich löschen? Hits bleiben gelöscht.")) return; + const warn = hitsTotal > 0 + ? `Domain "${domainName}" wirklich löschen?\n\n${hitsTotal.toLocaleString("de-DE")} Hits werden mitgelöscht.\n\nDieser Schritt ist nicht umkehrbar.` + : `Domain "${domainName}" wirklich löschen?`; + if (!confirm(warn)) return; setBusy("delete"); try { const res = await fetch(`/api/domains/${id}`, { method: "DELETE" }); diff --git a/app/(app)/domains/[id]/DomainEditForm.tsx b/app/(app)/domains/[id]/DomainEditForm.tsx new file mode 100644 index 0000000..4c12540 --- /dev/null +++ b/app/(app)/domains/[id]/DomainEditForm.tsx @@ -0,0 +1,138 @@ +"use client"; +import { useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; +import { Loader2, Save } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; + +type Group = { id: number; name: string; target_url: string }; + +export function DomainEditForm({ + domainId, + initial, +}: { + domainId: number; + initial: { + target_url: string | null; + group_id: number | null; + redirect_code: number; + preserve_path: number; + include_www: number; + }; +}) { + const router = useRouter(); + const [mode, setMode] = useState<"url" | "group">(initial.group_id ? "group" : "url"); + const [targetUrl, setTargetUrl] = useState(initial.target_url ?? ""); + const [groupId, setGroupId] = useState(initial.group_id ?? ""); + const [redirectCode, setRedirectCode] = useState<301 | 302>((initial.redirect_code as 301 | 302) || 302); + const [preservePath, setPreservePath] = useState(!!initial.preserve_path); + const [includeWww, setIncludeWww] = useState(!!initial.include_www); + const [groups, setGroups] = useState([]); + const [saving, setSaving] = useState(false); + const [msg, setMsg] = useState(""); + + useEffect(() => { + fetch("/api/groups").then((r) => r.json()).then((d) => setGroups(d.groups || [])).catch(() => {}); + }, []); + + async function save() { + setSaving(true); + setMsg(""); + const body: Record = { + redirect_code: redirectCode, + preserve_path: preservePath, + include_www: includeWww, + }; + if (mode === "url") { + body.target_url = targetUrl.trim(); + body.group_id = null; + } else { + body.group_id = groupId || null; + body.target_url = null; + } + + try { + const r = await fetch(`/api/domains/${domainId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (r.ok) { + setMsg("Gespeichert."); + router.refresh(); + } else { + const d = await r.json().catch(() => ({})); + setMsg(`Fehler: ${d.error || r.statusText}`); + } + } finally { + setSaving(false); + } + } + + return ( +
+
+ +
+ + +
+
+ + {mode === "url" ? ( +
+ + setTargetUrl(e.target.value)} placeholder="https://www.zielseite.de" /> +
+ ) : ( +
+ + +
+ )} + +
+
+ + +
+
+ + +
+
+ + {redirectCode === 301 && ( +

⚠ 301 wird vom Browser gecacht — Folge-Aufrufe werden nicht mehr gezählt.

+ )} + + + {msg &&

{msg}

} +
+ ); +} diff --git a/app/(app)/domains/[id]/page.tsx b/app/(app)/domains/[id]/page.tsx index 06f3bb0..4f22e5f 100644 --- a/app/(app)/domains/[id]/page.tsx +++ b/app/(app)/domains/[id]/page.tsx @@ -5,9 +5,12 @@ import { PageHeader } from "@/components/PageHeader"; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; -import { getDb, type DomainRow, type DomainGroupRow } from "@/lib/db"; +import { getDb, parseSunset, getSetting, type DomainRow, type DomainGroupRow } from "@/lib/db"; import { HitsLineChart } from "@/components/charts/HitsLineChart"; import { DomainActions } from "./DomainActions"; +import { SunsetEditor } from "./SunsetEditor"; +import { DomainEditForm } from "./DomainEditForm"; +import { DnsRecordsCard } from "./DnsRecordsCard"; export const dynamic = "force-dynamic"; @@ -21,10 +24,13 @@ export default async function DomainDetailPage({ params }: { params: Promise<{ i ? (db.prepare("SELECT * FROM domain_groups WHERE id = ?").get(domain.group_id) as DomainGroupRow | undefined) : null; + const since24h = Date.now() - 24 * 60 * 60 * 1000; const since30d = Date.now() - 30 * 24 * 60 * 60 * 1000; - const hits24h = (db.prepare("SELECT COUNT(*) AS n FROM hits WHERE domain_id = ? AND ts > ?").get(domain.id, Date.now() - 24 * 60 * 60 * 1000) as { n: number }).n; + const hits24h = (db.prepare("SELECT COUNT(*) AS n FROM hits WHERE domain_id = ? AND ts > ?").get(domain.id, since24h) as { n: number }).n; const hits30d = (db.prepare("SELECT COUNT(*) AS n FROM hits WHERE domain_id = ? AND ts > ?").get(domain.id, since30d) as { n: number }).n; const hitsTotal = (db.prepare("SELECT COUNT(*) AS n FROM hits WHERE domain_id = ?").get(domain.id) as { n: number }).n; + const visitors30d = (db.prepare("SELECT COUNT(DISTINCT ip_hash) AS n FROM hits WHERE domain_id = ? AND ts > ?").get(domain.id, since30d) as { n: number }).n; + const visitorsTotal = (db.prepare("SELECT COUNT(DISTINCT ip_hash) AS n FROM hits WHERE domain_id = ?").get(domain.id) as { n: number }).n; const dailyRows = db.prepare(` SELECT strftime('%Y-%m-%d', ts/1000, 'unixepoch') AS day, COUNT(*) AS hits @@ -33,6 +39,8 @@ export default async function DomainDetailPage({ params }: { params: Promise<{ i `).all(domain.id, since30d) as { day: string; hits: number }[]; const target = domain.target_url ?? group?.target_url ?? null; + const serverIpv4 = getSetting("server_ip"); + const serverIpv6 = getSetting("server_ipv6"); return (
@@ -48,24 +56,25 @@ export default async function DomainDetailPage({ params }: { params: Promise<{ i
- Konfiguration - - - - {target ? ( - - {target} - - ) : "—"} - - {group && {group.name}} - - {domain.redirect_code} - {domain.redirect_code === 301 && } - - {domain.preserve_path ? "ja" : "nein"} - {domain.include_www ? "ja" : "nein"} - {domain.verified_at ? new Date(domain.verified_at).toLocaleString("de-DE") : "—"} + + Konfiguration + + Status: • Verifiziert: {domain.verified_at ? new Date(domain.verified_at).toLocaleDateString("de-DE") : "—"} + {target && <> • Aktuell: {target}} + {group && <> • Gruppe: {group.name}} + + + + @@ -73,15 +82,35 @@ export default async function DomainDetailPage({ params }: { params: Promise<{ i Hits {hits24h.toLocaleString("de-DE")} - {hits30d.toLocaleString("de-DE")} - {hitsTotal.toLocaleString("de-DE")} + {hits30d.toLocaleString("de-DE")} ({visitors30d.toLocaleString("de-DE")} Besucher) + {hitsTotal.toLocaleString("de-DE")} ({visitorsTotal.toLocaleString("de-DE")} Besucher) Aktionen - + + + + + + + DNS-Records + Alle aktuell für diese Domain veröffentlichten DNS-Einträge. + + + + + + + + + Abschaltungs-Hinweis + Optional Hinweisseite vor Redirect. + + + @@ -108,8 +137,8 @@ function Row({ k, children }: { k: string; children: React.ReactNode }) { ); } -function StatusBadge({ status }: { status: string }) { - if (status === "active") return aktiv; - if (status === "pending") return wartet; - return {status}; +function StatusInline({ status }: { status: string }) { + if (status === "active") return aktiv; + if (status === "pending") return wartet; + return {status}; } diff --git a/app/(app)/domains/page.tsx b/app/(app)/domains/page.tsx index 6affd16..228aa8b 100644 --- a/app/(app)/domains/page.tsx +++ b/app/(app)/domains/page.tsx @@ -1,4 +1,5 @@ import Link from "next/link"; +import { FileDown } from "lucide-react"; import { PageHeader } from "@/components/PageHeader"; import { Button } from "@/components/ui/button"; import { getDb } from "@/lib/db"; @@ -29,9 +30,14 @@ export default function DomainsPage() { title="Domains" description="Alle verwalteten Redirect-Domains" actions={ - +
+ + +
} /> diff --git a/app/(app)/groups/page.tsx b/app/(app)/groups/page.tsx index ebf2a67..321caf0 100644 --- a/app/(app)/groups/page.tsx +++ b/app/(app)/groups/page.tsx @@ -1,6 +1,6 @@ "use client"; import { useEffect, useState } from "react"; -import { Loader2, Plus, Trash2 } from "lucide-react"; +import { Loader2, Plus, Trash2, Pencil } from "lucide-react"; import { PageHeader } from "@/components/PageHeader"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; @@ -20,6 +20,7 @@ export default function GroupsPage() { const [code, setCode] = useState<301 | 302>(302); const [creating, setCreating] = useState(false); const [error, setError] = useState(""); + const [editing, setEditing] = useState(null); async function load() { setLoading(true); @@ -63,6 +64,29 @@ export default function GroupsPage() { load(); } + async function handleEditSave(e: React.FormEvent) { + e.preventDefault(); + if (!editing) return; + setCreating(true); + setError(""); + try { + const r = await fetch(`/api/groups/${editing.id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: editing.name, target_url: editing.target_url, redirect_code: editing.redirect_code }), + }); + if (!r.ok) { + const d = await r.json(); + setError(d.error || "Fehler"); + return; + } + setEditing(null); + load(); + } finally { + setCreating(false); + } + } + return (
{g.name} - +
+ + +
@@ -138,6 +167,40 @@ export default function GroupsPage() {
)}
+ + !v && setEditing(null)}> + + + Gruppe bearbeiten + + {editing && ( +
+
+ + setEditing({ ...editing, name: e.target.value })} /> +
+
+ + setEditing({ ...editing, target_url: e.target.value })} /> +
+
+ + +
+ {error &&

{error}

} +
+ + +
+
+ )} +
+
); } diff --git a/app/api/domains/[id]/dns-records/route.ts b/app/api/domains/[id]/dns-records/route.ts new file mode 100644 index 0000000..585ba2e --- /dev/null +++ b/app/api/domains/[id]/dns-records/route.ts @@ -0,0 +1,16 @@ +import { NextResponse } from "next/server"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/lib/auth"; +import { getDb, type DomainRow } from "@/lib/db"; +import { getAllDnsRecords } from "@/lib/dns-records"; + +export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) { + const session = await getServerSession(authOptions); + if (!session) return NextResponse.json({ error: "unauthorized" }, { status: 401 }); + const { id } = await params; + const row = getDb().prepare("SELECT * FROM domains WHERE id = ?").get(Number(id)) as DomainRow | undefined; + if (!row) return NextResponse.json({ error: "not_found" }, { status: 404 }); + + const records = await getAllDnsRecords(row.domain); + return NextResponse.json(records, { headers: { "Cache-Control": "private, max-age=60" } }); +} diff --git a/app/api/domains/[id]/route.ts b/app/api/domains/[id]/route.ts index 615a715..93cb577 100644 --- a/app/api/domains/[id]/route.ts +++ b/app/api/domains/[id]/route.ts @@ -2,7 +2,7 @@ import { NextResponse } from "next/server"; import { z } from "zod"; import { getServerSession } from "next-auth"; import { authOptions } from "@/lib/auth"; -import { getDb, type DomainRow } from "@/lib/db"; +import { getDb, logAudit, type DomainRow } from "@/lib/db"; import { reloadCaddy } from "@/lib/caddy"; import { invalidateRedirectCache } from "@/lib/redirect-resolver"; @@ -71,6 +71,7 @@ export async function PATCH(req: Request, { params }: { params: Promise<{ id: st } const row = db.prepare("SELECT * FROM domains WHERE id = ?").get(Number(id)) as DomainRow; + logAudit({ user_id: Number(session.user.id), user_email: session.user.email, action: "domain.update", target_type: "domain", target_id: row.id, details: parsed.data }); return NextResponse.json({ domain: row }); } @@ -86,6 +87,7 @@ export async function DELETE(_req: Request, { params }: { params: Promise<{ id: db.prepare("DELETE FROM domains WHERE id = ?").run(Number(id)); invalidateRedirectCache(); if (row.status === "active") reloadCaddy().catch(() => {}); + logAudit({ user_id: Number(session.user.id), user_email: session.user.email, action: "domain.delete", target_type: "domain", target_id: row.id, details: { domain: row.domain } }); return NextResponse.json({ ok: true }); } diff --git a/app/api/domains/bulk-delete/route.ts b/app/api/domains/bulk-delete/route.ts new file mode 100644 index 0000000..92e85ae --- /dev/null +++ b/app/api/domains/bulk-delete/route.ts @@ -0,0 +1,32 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/lib/auth"; +import { getDb, logAudit } from "@/lib/db"; +import { reloadCaddy } from "@/lib/caddy"; +import { invalidateRedirectCache } from "@/lib/redirect-resolver"; + +const schema = z.object({ + domain_ids: z.array(z.number().int()).min(1).max(500), +}); + +export async function POST(req: Request) { + const session = await getServerSession(authOptions); + if (!session) return NextResponse.json({ error: "unauthorized" }, { status: 401 }); + + const body = await req.json().catch(() => null); + const parsed = schema.safeParse(body); + if (!parsed.success) return NextResponse.json({ error: "invalid" }, { status: 400 }); + + const { domain_ids } = parsed.data; + const db = getDb(); + const placeholders = domain_ids.map(() => "?").join(","); + const hadActive = (db.prepare(`SELECT COUNT(*) AS n FROM domains WHERE status='active' AND id IN (${placeholders})`).get(...domain_ids) as { n: number }).n; + const result = db.prepare(`DELETE FROM domains WHERE id IN (${placeholders})`).run(...domain_ids); + + invalidateRedirectCache(); + if (hadActive > 0) reloadCaddy().catch(() => {}); + logAudit({ user_id: Number(session.user.id), user_email: session.user.email, action: "domain.bulk_delete", target_type: "domain", target_id: domain_ids.join(","), details: { count: Number(result.changes) } }); + + return NextResponse.json({ ok: true, deleted: Number(result.changes) }); +} diff --git a/app/api/domains/export.csv/route.ts b/app/api/domains/export.csv/route.ts new file mode 100644 index 0000000..afc1219 --- /dev/null +++ b/app/api/domains/export.csv/route.ts @@ -0,0 +1,29 @@ +import { NextResponse } from "next/server"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/lib/auth"; +import { getDb } from "@/lib/db"; +import { toCsv } from "@/lib/csv"; + +export async function GET() { + const session = await getServerSession(authOptions); + if (!session) return NextResponse.json({ error: "unauthorized" }, { status: 401 }); + + const rows = getDb().prepare(` + SELECT d.id, d.domain, d.status, d.target_url, + (SELECT name FROM domain_groups g WHERE g.id = d.group_id) AS group_name, + d.redirect_code, d.preserve_path, d.include_www, + datetime(d.created_at/1000,'unixepoch') AS created_at, + datetime(d.verified_at/1000,'unixepoch') AS verified_at, + (SELECT COUNT(*) FROM hits h WHERE h.domain_id = d.id) AS total_hits, + (SELECT datetime(MAX(ts)/1000,'unixepoch') FROM hits h WHERE h.domain_id = d.id) AS last_hit + FROM domains d ORDER BY d.created_at DESC + `).all() as Record[]; + + const csv = toCsv(rows); + return new NextResponse(csv, { + headers: { + "Content-Type": "text/csv; charset=utf-8", + "Content-Disposition": `attachment; filename="domains-${new Date().toISOString().slice(0, 10)}.csv"`, + }, + }); +} diff --git a/app/api/domains/route.ts b/app/api/domains/route.ts index e2bdbc7..2fda146 100644 --- a/app/api/domains/route.ts +++ b/app/api/domains/route.ts @@ -2,7 +2,7 @@ import { NextResponse } from "next/server"; import { z } from "zod"; import { getServerSession } from "next-auth"; import { authOptions } from "@/lib/auth"; -import { getDb, type DomainRow } from "@/lib/db"; +import { getDb, logAudit, type DomainRow } from "@/lib/db"; import { isValidDomain } from "@/lib/dns"; export async function GET() { @@ -64,5 +64,6 @@ export async function POST(req: Request) { ); const row = db.prepare("SELECT * FROM domains WHERE id = ?").get(result.lastInsertRowid) as DomainRow; + logAudit({ user_id: Number(session.user.id), user_email: session.user.email, action: "domain.create", target_type: "domain", target_id: row.id, details: { domain: row.domain, target_url, group_id, redirect_code } }); return NextResponse.json({ domain: row }, { status: 201 }); } diff --git a/app/api/domains/sunset-bulk/route.ts b/app/api/domains/sunset-bulk/route.ts index 07a5440..d90b139 100644 --- a/app/api/domains/sunset-bulk/route.ts +++ b/app/api/domains/sunset-bulk/route.ts @@ -2,7 +2,7 @@ import { NextResponse } from "next/server"; import { z } from "zod"; import { getServerSession } from "next-auth"; import { authOptions } from "@/lib/auth"; -import { getDb } from "@/lib/db"; +import { getDb, logAudit } from "@/lib/db"; import { invalidateRedirectCache } from "@/lib/redirect-resolver"; const sunsetSchema = z.object({ @@ -34,5 +34,6 @@ export async function POST(req: Request) { db.prepare(`UPDATE domains SET sunset_config = ? WHERE id IN (${placeholders})`).run(value, ...domain_ids); invalidateRedirectCache(); + logAudit({ user_id: Number(session.user.id), user_email: session.user.email, action: "sunset.bulk", target_type: "domain", target_id: domain_ids.join(","), details: { enabled: !!config?.enabled, count: domain_ids.length } }); return NextResponse.json({ ok: true, updated: domain_ids.length }); } diff --git a/app/api/hits/export.csv/route.ts b/app/api/hits/export.csv/route.ts new file mode 100644 index 0000000..5636119 --- /dev/null +++ b/app/api/hits/export.csv/route.ts @@ -0,0 +1,29 @@ +import { NextResponse } from "next/server"; +import { getServerSession } from "next-auth"; +import { authOptions } from "@/lib/auth"; +import { getDb } from "@/lib/db"; +import { toCsv } from "@/lib/csv"; + +export async function GET(req: Request) { + const session = await getServerSession(authOptions); + if (!session) return NextResponse.json({ error: "unauthorized" }, { status: 401 }); + + const url = new URL(req.url); + const days = Math.min(365, Math.max(1, Number(url.searchParams.get("days") || 30))); + const since = Date.now() - days * 24 * 60 * 60 * 1000; + const domainId = url.searchParams.get("domain_id"); + + const rows = domainId + ? getDb().prepare(`SELECT datetime(h.ts/1000,'unixepoch') AS ts, d.domain, h.country, h.path, h.user_agent, h.referer + FROM hits h JOIN domains d ON d.id = h.domain_id WHERE h.domain_id = ? AND h.ts > ? ORDER BY h.ts DESC LIMIT 100000`).all(Number(domainId), since) + : getDb().prepare(`SELECT datetime(h.ts/1000,'unixepoch') AS ts, d.domain, h.country, h.path, h.user_agent, h.referer + FROM hits h JOIN domains d ON d.id = h.domain_id WHERE h.ts > ? ORDER BY h.ts DESC LIMIT 100000`).all(since); + + const csv = toCsv(rows as Record[]); + return new NextResponse(csv, { + headers: { + "Content-Type": "text/csv; charset=utf-8", + "Content-Disposition": `attachment; filename="hits-${new Date().toISOString().slice(0, 10)}.csv"`, + }, + }); +} diff --git a/components/Sidebar.tsx b/components/Sidebar.tsx index 585d23f..13789a8 100644 --- a/components/Sidebar.tsx +++ b/components/Sidebar.tsx @@ -1,7 +1,7 @@ "use client"; import Link from "next/link"; import { usePathname } from "next/navigation"; -import { LayoutDashboard, Globe, Layers, BarChart3, Settings, LogOut, KeyRound } from "lucide-react"; +import { LayoutDashboard, Globe, Layers, BarChart3, Settings, LogOut, KeyRound, History } from "lucide-react"; import { Logo } from "./Logo"; import { cn } from "@/lib/utils"; @@ -10,6 +10,7 @@ const NAV = [ { href: "/domains", label: "Domains", icon: Globe }, { href: "/groups", label: "Gruppen", icon: Layers }, { href: "/analytics", label: "Analytics", icon: BarChart3 }, + { href: "/audit", label: "Audit-Log", icon: History }, { href: "/settings", label: "Einstellungen", icon: Settings }, { href: "/settings/api-tokens", label: "API-Tokens", icon: KeyRound }, ]; diff --git a/lib/csv.ts b/lib/csv.ts new file mode 100644 index 0000000..4d7a75c --- /dev/null +++ b/lib/csv.ts @@ -0,0 +1,16 @@ +function esc(v: unknown): string { + if (v === null || v === undefined) return ""; + const s = String(v); + if (s.includes('"') || s.includes(",") || s.includes("\n") || s.includes(";")) { + return `"${s.replace(/"/g, '""')}"`; + } + return s; +} + +export function toCsv(rows: Record[], columns?: string[]): string { + if (rows.length === 0) return ""; + const cols = columns ?? Object.keys(rows[0]); + const head = cols.map(esc).join(","); + const body = rows.map((r) => cols.map((c) => esc(r[c])).join(",")).join("\n"); + return `${head}\n${body}\n`; +} diff --git a/lib/db.ts b/lib/db.ts index 4ef7f1b..8bc26b2 100644 --- a/lib/db.ts +++ b/lib/db.ts @@ -92,9 +92,46 @@ function ensureSchema(db: Database.Database) { ); `); + db.exec(` + CREATE TABLE IF NOT EXISTS audit_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ts INTEGER NOT NULL, + user_id INTEGER, + user_email TEXT, + action TEXT NOT NULL, + target_type TEXT, + target_id TEXT, + details TEXT + ); + CREATE INDEX IF NOT EXISTS idx_audit_ts ON audit_log(ts); + `); + runMigrations(db); } +export function logAudit(entry: { + user_id?: number | null; + user_email?: string | null; + action: string; + target_type?: string; + target_id?: string | number; + details?: unknown; +}) { + try { + getDb().prepare(`INSERT INTO audit_log (ts, user_id, user_email, action, target_type, target_id, details) VALUES (?, ?, ?, ?, ?, ?, ?)`).run( + Date.now(), + entry.user_id ?? null, + entry.user_email ?? null, + entry.action, + entry.target_type ?? null, + entry.target_id !== undefined ? String(entry.target_id) : null, + entry.details === undefined ? null : JSON.stringify(entry.details), + ); + } catch { + // never block the main flow on audit failure + } +} + function getSettingDirect(db: Database.Database, key: string): string | null { const row = db.prepare("SELECT value FROM settings WHERE key = ?").get(key) as { value: string } | undefined; return row?.value ?? null; diff --git a/lib/dns-records.ts b/lib/dns-records.ts new file mode 100644 index 0000000..712d8dd --- /dev/null +++ b/lib/dns-records.ts @@ -0,0 +1,59 @@ +import dns from "dns/promises"; + +export type DnsRecords = { + domain: string; + A: string[]; + AAAA: string[]; + CNAME: string[]; + MX: { exchange: string; priority: number }[]; + NS: string[]; + TXT: string[]; + SOA: { nsname: string; hostmaster: string; serial: number; refresh: number; retry: number; expire: number; minttl: number } | null; + CAA: { issue?: string; issuewild?: string; iodef?: string; critical?: number }[]; + errors: Record; +}; + +async function safe(p: Promise): Promise<{ ok: true; value: T } | { ok: false; error: string }> { + try { + return { ok: true, value: await p }; + } catch (e) { + const code = (e as { code?: string }).code; + return { ok: false, error: code === "ENODATA" ? "no records" : code || (e instanceof Error ? e.message : String(e)) }; + } +} + +export async function getAllDnsRecords(domain: string): Promise { + const [a, aaaa, cname, mx, ns, txt, soa, caa] = await Promise.all([ + safe(dns.resolve4(domain)), + safe(dns.resolve6(domain)), + safe(dns.resolveCname(domain)), + safe(dns.resolveMx(domain)), + safe(dns.resolveNs(domain)), + safe(dns.resolveTxt(domain)), + safe(dns.resolveSoa(domain)), + safe(dns.resolveCaa(domain)), + ]); + + const errors: Record = {}; + if (!a.ok && a.error !== "no records") errors.A = a.error; + if (!aaaa.ok && aaaa.error !== "no records") errors.AAAA = aaaa.error; + if (!cname.ok && cname.error !== "no records") errors.CNAME = cname.error; + if (!mx.ok && mx.error !== "no records") errors.MX = mx.error; + if (!ns.ok && ns.error !== "no records") errors.NS = ns.error; + if (!txt.ok && txt.error !== "no records") errors.TXT = txt.error; + if (!soa.ok && soa.error !== "no records") errors.SOA = soa.error; + if (!caa.ok && caa.error !== "no records") errors.CAA = caa.error; + + return { + domain, + A: a.ok ? a.value : [], + AAAA: aaaa.ok ? aaaa.value : [], + CNAME: cname.ok ? cname.value : [], + MX: mx.ok ? mx.value : [], + NS: ns.ok ? ns.value : [], + TXT: txt.ok ? txt.value.map((parts) => parts.join("")) : [], + SOA: soa.ok ? soa.value : null, + CAA: caa.ok ? (caa.value as DnsRecords["CAA"]) : [], + errors, + }; +} diff --git a/package.json b/package.json index d6f8c1c..cd3b49c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "corex-nexredirect", - "version": "0.1.15", + "version": "0.1.16", "license": "MIT", "overrides": { "postcss": "^8.5.13",