v0.1.16 — DNS records overview, domain edit form, bulk delete, group edit, CSV export, audit log
This commit is contained in:
parent
63df0fe8d6
commit
12f16e078b
21 changed files with 731 additions and 44 deletions
|
|
@ -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() {
|
|||
<PageHeader
|
||||
title="Analytics"
|
||||
description="Hit-Statistiken letzte 30 Tage"
|
||||
actions={<ExportPdfButton />}
|
||||
actions={
|
||||
<div className="flex gap-2">
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<a href="/api/hits/export.csv?days=30" download><FileDown className="mr-1 h-3 w-3" />Hits CSV</a>
|
||||
</Button>
|
||||
<ExportPdfButton />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="space-y-4 p-8">
|
||||
|
|
|
|||
76
app/(app)/audit/page.tsx
Normal file
76
app/(app)/audit/page.tsx
Normal file
|
|
@ -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<string, "green" | "amber" | "blue" | "destructive" | "zinc"> = {
|
||||
"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 (
|
||||
<div>
|
||||
<PageHeader title="Audit-Log" description="Alle administrativen Mutationen — bis zu 500 Einträge" />
|
||||
<div className="p-8">
|
||||
{rows.length === 0 ? (
|
||||
<Card><CardContent className="py-16 text-center text-sm text-muted-foreground">Noch keine Aktionen geloggt.</CardContent></Card>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b border-zinc-800/70 text-xs uppercase tracking-wider text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left font-medium">Zeit</th>
|
||||
<th className="px-6 py-3 text-left font-medium">Benutzer</th>
|
||||
<th className="px-6 py-3 text-left font-medium">Aktion</th>
|
||||
<th className="px-6 py-3 text-left font-medium">Ziel</th>
|
||||
<th className="px-6 py-3 text-left font-medium">Details</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-800/70">
|
||||
{rows.map((r) => (
|
||||
<tr key={r.id} className="hover:bg-zinc-900/40">
|
||||
<td className="px-6 py-2.5 font-mono text-xs">{new Date(r.ts).toLocaleString("de-DE")}</td>
|
||||
<td className="px-6 py-2.5 text-xs">{r.user_email || "—"}</td>
|
||||
<td className="px-6 py-2.5"><Badge variant={ACTION_VARIANT[r.action] || "zinc"}>{r.action}</Badge></td>
|
||||
<td className="px-6 py-2.5 text-xs text-muted-foreground">{r.target_type ? `${r.target_type}#${r.target_id ?? ""}` : "—"}</td>
|
||||
<td className="px-6 py-2.5 font-mono text-[10px] text-muted-foreground">{(r.details || "").slice(0, 120)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<div className="p-8 space-y-3">
|
||||
{selected.size > 0 && (
|
||||
|
|
@ -87,9 +108,13 @@ export function DomainsListClient({ domains }: { domains: DomainListRow[] }) {
|
|||
<span>{selected.size} ausgewählt</span>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="outline" onClick={() => setSelected(new Set())}>Auswahl aufheben</Button>
|
||||
<Button size="sm" variant="destructive" onClick={bulkDelete} disabled={saving}>
|
||||
<Trash2 className="mr-1 h-3 w-3" />
|
||||
Löschen
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => setBulkOpen(true)}>
|
||||
<Sunset className="mr-1 h-3 w-3" />
|
||||
Sunset-Hinweis konfigurieren
|
||||
Sunset-Hinweis
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
115
app/(app)/domains/[id]/DnsRecordsCard.tsx
Normal file
115
app/(app)/domains/[id]/DnsRecordsCard.tsx
Normal file
|
|
@ -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<string, string>;
|
||||
};
|
||||
|
||||
export function DnsRecordsCard({ domainId, expectedIpv4, expectedIpv6 }: { domainId: number; expectedIpv4: string | null; expectedIpv6: string | null }) {
|
||||
const [data, setData] = useState<Records | null>(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 <div className="flex justify-center py-6"><Loader2 className="h-5 w-5 animate-spin text-muted-foreground" /></div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={load} variant="outline" size="sm" disabled={loading}>
|
||||
{loading ? <Loader2 className="mr-2 h-3 w-3 animate-spin" /> : <RefreshCcw className="mr-2 h-3 w-3" />}
|
||||
Aktualisieren
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<RecordList type="A" values={data.A} highlight={expectedIpv4 ? [expectedIpv4] : []} empty="Kein A-Record" />
|
||||
<RecordList type="AAAA" values={data.AAAA} highlight={expectedIpv6 ? [expectedIpv6] : []} empty="Kein AAAA-Record" />
|
||||
{data.CNAME.length > 0 && <RecordList type="CNAME" values={data.CNAME} />}
|
||||
{data.MX.length > 0 && (
|
||||
<Block label="MX">
|
||||
{data.MX.map((m, i) => (
|
||||
<Row key={i} value={`${m.priority} ${m.exchange}`} />
|
||||
))}
|
||||
</Block>
|
||||
)}
|
||||
{data.NS.length > 0 && <RecordList type="NS" values={data.NS} />}
|
||||
{data.TXT.length > 0 && (
|
||||
<Block label="TXT">
|
||||
{data.TXT.map((t, i) => <Row key={i} value={t} mono />)}
|
||||
</Block>
|
||||
)}
|
||||
{data.CAA.length > 0 && (
|
||||
<Block label="CAA">
|
||||
{data.CAA.map((c, i) => <Row key={i} value={Object.entries(c).filter(([k]) => k !== "critical").map(([k, v]) => `${k}=${v}`).join(" ")} />)}
|
||||
</Block>
|
||||
)}
|
||||
{data.SOA && (
|
||||
<Block label="SOA">
|
||||
<Row value={`${data.SOA.nsname} ${data.SOA.hostmaster} serial ${data.SOA.serial}`} />
|
||||
</Block>
|
||||
)}
|
||||
|
||||
{Object.keys(data.errors).length > 0 && (
|
||||
<p className="text-[11px] text-amber-400">
|
||||
DNS-Lookup-Fehler: {Object.entries(data.errors).map(([k, v]) => `${k}: ${v}`).join(" • ")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Block({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-start gap-3">
|
||||
<Badge variant="zinc" className="mt-0.5 w-12 justify-center">{label}</Badge>
|
||||
<div className="flex-1 space-y-0.5">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ value, match, mono }: { value: string; match?: boolean; mono?: boolean }) {
|
||||
return (
|
||||
<div className={`flex items-center gap-2 text-xs ${mono === false ? "" : "font-mono"}`}>
|
||||
<span className="break-all text-zinc-200">{value}</span>
|
||||
{match && <CheckCircle2 className="h-3 w-3 shrink-0 text-green-400" />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RecordList({ type, values, highlight = [], empty }: { type: string; values: string[]; highlight?: string[]; empty?: string }) {
|
||||
if (values.length === 0) {
|
||||
return empty ? (
|
||||
<Block label={type}>
|
||||
<span className="text-xs italic text-muted-foreground">{empty}</span>
|
||||
</Block>
|
||||
) : null;
|
||||
}
|
||||
return (
|
||||
<Block label={type}>
|
||||
{values.map((v, i) => <Row key={i} value={v} match={highlight.includes(v)} />)}
|
||||
</Block>
|
||||
);
|
||||
}
|
||||
|
|
@ -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" });
|
||||
|
|
|
|||
138
app/(app)/domains/[id]/DomainEditForm.tsx
Normal file
138
app/(app)/domains/[id]/DomainEditForm.tsx
Normal file
|
|
@ -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<number | "">(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<Group[]>([]);
|
||||
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<string, unknown> = {
|
||||
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 (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Ziel</Label>
|
||||
<div className="flex gap-2">
|
||||
<Button type="button" variant={mode === "url" ? "default" : "outline"} size="sm" onClick={() => setMode("url")}>Einzel-URL</Button>
|
||||
<Button type="button" variant={mode === "group" ? "default" : "outline"} size="sm" onClick={() => setMode("group")} disabled={groups.length === 0}>Gruppe</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{mode === "url" ? (
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="target" className="text-xs">Ziel-URL</Label>
|
||||
<Input id="target" type="url" value={targetUrl} onChange={(e) => setTargetUrl(e.target.value)} placeholder="https://www.zielseite.de" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="group" className="text-xs">Gruppe</Label>
|
||||
<select
|
||||
id="group"
|
||||
value={groupId}
|
||||
onChange={(e) => setGroupId(e.target.value ? Number(e.target.value) : "")}
|
||||
className="flex h-9 w-full rounded-md border border-input bg-zinc-950 px-3 py-1 text-sm text-zinc-100"
|
||||
>
|
||||
<option value="" className="bg-zinc-900 text-zinc-100">— wählen —</option>
|
||||
{groups.map((g) => <option key={g.id} value={g.id} className="bg-zinc-900 text-zinc-100">{g.name} → {g.target_url}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Status-Code</Label>
|
||||
<select
|
||||
value={redirectCode}
|
||||
onChange={(e) => setRedirectCode(Number(e.target.value) as 301 | 302)}
|
||||
className="flex h-9 w-full rounded-md border border-input bg-zinc-950 px-3 py-1 text-sm text-zinc-100"
|
||||
>
|
||||
<option value={302} className="bg-zinc-900 text-zinc-100">302 (empfohlen)</option>
|
||||
<option value={301} className="bg-zinc-900 text-zinc-100">301 Permanent</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex flex-col justify-end gap-2 pb-1">
|
||||
<label className="flex items-center gap-2 text-xs">
|
||||
<input type="checkbox" checked={preservePath} onChange={(e) => setPreservePath(e.target.checked)} />
|
||||
Pfad übernehmen
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-xs">
|
||||
<input type="checkbox" checked={includeWww} onChange={(e) => setIncludeWww(e.target.checked)} />
|
||||
www.-Subdomain einbeziehen
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{redirectCode === 301 && (
|
||||
<p className="text-[11px] text-amber-400">⚠ 301 wird vom Browser gecacht — Folge-Aufrufe werden nicht mehr gezählt.</p>
|
||||
)}
|
||||
|
||||
<Button onClick={save} disabled={saving} size="sm" className="w-full">
|
||||
{saving ? <Loader2 className="mr-2 h-3 w-3 animate-spin" /> : <Save className="mr-2 h-3 w-3" />}
|
||||
Speichern
|
||||
</Button>
|
||||
{msg && <p className="text-xs text-muted-foreground">{msg}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<div>
|
||||
|
|
@ -48,24 +56,25 @@ export default async function DomainDetailPage({ params }: { params: Promise<{ i
|
|||
|
||||
<div className="grid grid-cols-1 gap-4 p-8 lg:grid-cols-3">
|
||||
<Card>
|
||||
<CardHeader><CardTitle className="text-sm">Konfiguration</CardTitle></CardHeader>
|
||||
<CardContent className="space-y-2 text-sm">
|
||||
<Row k="Status"><StatusBadge status={domain.status} /></Row>
|
||||
<Row k="Ziel">
|
||||
{target ? (
|
||||
<a href={target} target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 text-cyan-400 hover:underline">
|
||||
{target} <ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
) : "—"}
|
||||
</Row>
|
||||
{group && <Row k="Gruppe"><Badge variant="blue">{group.name}</Badge></Row>}
|
||||
<Row k="Code">
|
||||
{domain.redirect_code}
|
||||
{domain.redirect_code === 301 && <span className="ml-1 text-[10px] text-amber-400" title="301 wird vom Browser gecacht">⚠</span>}
|
||||
</Row>
|
||||
<Row k="Pfad übernehmen">{domain.preserve_path ? "ja" : "nein"}</Row>
|
||||
<Row k="www-Subdomain">{domain.include_www ? "ja" : "nein"}</Row>
|
||||
<Row k="Verifiziert">{domain.verified_at ? new Date(domain.verified_at).toLocaleString("de-DE") : "—"}</Row>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm">Konfiguration</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
Status: <StatusInline status={domain.status} /> • Verifiziert: {domain.verified_at ? new Date(domain.verified_at).toLocaleDateString("de-DE") : "—"}
|
||||
{target && <> • Aktuell: <a href={target} target="_blank" rel="noreferrer" className="text-cyan-400 hover:underline">{target}<ExternalLink className="ml-0.5 inline h-3 w-3" /></a></>}
|
||||
{group && <> • Gruppe: <span className="text-cyan-400">{group.name}</span></>}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DomainEditForm
|
||||
domainId={domain.id}
|
||||
initial={{
|
||||
target_url: domain.target_url,
|
||||
group_id: domain.group_id,
|
||||
redirect_code: domain.redirect_code,
|
||||
preserve_path: domain.preserve_path,
|
||||
include_www: domain.include_www,
|
||||
}}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
|
@ -73,15 +82,35 @@ export default async function DomainDetailPage({ params }: { params: Promise<{ i
|
|||
<CardHeader><CardTitle className="text-sm">Hits</CardTitle></CardHeader>
|
||||
<CardContent className="space-y-2 text-sm">
|
||||
<Row k="Letzte 24h">{hits24h.toLocaleString("de-DE")}</Row>
|
||||
<Row k="Letzte 30 Tage">{hits30d.toLocaleString("de-DE")}</Row>
|
||||
<Row k="Gesamt">{hitsTotal.toLocaleString("de-DE")}</Row>
|
||||
<Row k="30 Tage">{hits30d.toLocaleString("de-DE")} <span className="text-xs text-muted-foreground">({visitors30d.toLocaleString("de-DE")} Besucher)</span></Row>
|
||||
<Row k="Gesamt">{hitsTotal.toLocaleString("de-DE")} <span className="text-xs text-muted-foreground">({visitorsTotal.toLocaleString("de-DE")} Besucher)</span></Row>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader><CardTitle className="text-sm">Aktionen</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<DomainActions id={domain.id} status={domain.status} />
|
||||
<DomainActions id={domain.id} status={domain.status} hitsTotal={hitsTotal} domainName={domain.domain} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="lg:col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm">DNS-Records</CardTitle>
|
||||
<CardDescription className="text-xs">Alle aktuell für diese Domain veröffentlichten DNS-Einträge.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DnsRecordsCard domainId={domain.id} expectedIpv4={serverIpv4} expectedIpv6={serverIpv6} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm">Abschaltungs-Hinweis</CardTitle>
|
||||
<CardDescription className="text-xs">Optional Hinweisseite vor Redirect.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<SunsetEditor domainId={domain.id} initial={parseSunset(domain)} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
|
@ -108,8 +137,8 @@ function Row({ k, children }: { k: string; children: React.ReactNode }) {
|
|||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
if (status === "active") return <Badge variant="green">aktiv</Badge>;
|
||||
if (status === "pending") return <Badge variant="amber">wartet</Badge>;
|
||||
return <Badge variant="destructive">{status}</Badge>;
|
||||
function StatusInline({ status }: { status: string }) {
|
||||
if (status === "active") return <span className="text-green-400">aktiv</span>;
|
||||
if (status === "pending") return <span className="text-amber-400">wartet</span>;
|
||||
return <span className="text-destructive">{status}</span>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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={
|
||||
<Button asChild>
|
||||
<Link href="/domains/new">+ Domain hinzufügen</Link>
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<a href="/api/domains/export.csv" download><FileDown className="mr-1 h-3 w-3" />CSV</a>
|
||||
</Button>
|
||||
<Button asChild>
|
||||
<Link href="/domains/new">+ Domain hinzufügen</Link>
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<DomainsListClient domains={domains} />
|
||||
|
|
|
|||
|
|
@ -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<Group | null>(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 (
|
||||
<div>
|
||||
<PageHeader
|
||||
|
|
@ -121,9 +145,14 @@ export default function GroupsPage() {
|
|||
<CardHeader>
|
||||
<div className="flex items-start justify-between">
|
||||
<CardTitle className="text-base">{g.name}</CardTitle>
|
||||
<button onClick={() => handleDelete(g.id)} className="rounded p-1 text-zinc-500 hover:text-destructive" title="Löschen">
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<div className="flex gap-1">
|
||||
<button onClick={() => setEditing({ ...g })} className="rounded p-1 text-zinc-500 hover:text-zinc-200" title="Bearbeiten">
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button onClick={() => handleDelete(g.id)} className="rounded p-1 text-zinc-500 hover:text-destructive" title="Löschen">
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 text-sm">
|
||||
|
|
@ -138,6 +167,40 @@ export default function GroupsPage() {
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Dialog open={!!editing} onOpenChange={(v) => !v && setEditing(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Gruppe bearbeiten</DialogTitle>
|
||||
</DialogHeader>
|
||||
{editing && (
|
||||
<form onSubmit={handleEditSave} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">Name</Label>
|
||||
<Input required value={editing.name} onChange={(e) => setEditing({ ...editing, name: e.target.value })} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">Ziel-URL</Label>
|
||||
<Input required type="url" value={editing.target_url} onChange={(e) => setEditing({ ...editing, target_url: e.target.value })} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">Status-Code</Label>
|
||||
<select value={editing.redirect_code} onChange={(e) => setEditing({ ...editing, redirect_code: Number(e.target.value) })} className="flex h-9 w-full rounded-md border border-input bg-zinc-950 px-3 py-1 text-sm text-zinc-100">
|
||||
<option value={302} className="bg-zinc-900 text-zinc-100">302 Temporär</option>
|
||||
<option value={301} className="bg-zinc-900 text-zinc-100">301 Permanent</option>
|
||||
</select>
|
||||
</div>
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="outline" onClick={() => setEditing(null)}>Abbrechen</Button>
|
||||
<Button type="submit" disabled={creating}>
|
||||
{creating ? <Loader2 className="mr-2 h-3 w-3 animate-spin" /> : null}Speichern
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
16
app/api/domains/[id]/dns-records/route.ts
Normal file
16
app/api/domains/[id]/dns-records/route.ts
Normal file
|
|
@ -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" } });
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
32
app/api/domains/bulk-delete/route.ts
Normal file
32
app/api/domains/bulk-delete/route.ts
Normal file
|
|
@ -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) });
|
||||
}
|
||||
29
app/api/domains/export.csv/route.ts
Normal file
29
app/api/domains/export.csv/route.ts
Normal file
|
|
@ -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<string, unknown>[];
|
||||
|
||||
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"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
29
app/api/hits/export.csv/route.ts
Normal file
29
app/api/hits/export.csv/route.ts
Normal file
|
|
@ -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<string, unknown>[]);
|
||||
return new NextResponse(csv, {
|
||||
headers: {
|
||||
"Content-Type": "text/csv; charset=utf-8",
|
||||
"Content-Disposition": `attachment; filename="hits-${new Date().toISOString().slice(0, 10)}.csv"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -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 },
|
||||
];
|
||||
|
|
|
|||
16
lib/csv.ts
Normal file
16
lib/csv.ts
Normal file
|
|
@ -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<string, unknown>[], 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`;
|
||||
}
|
||||
37
lib/db.ts
37
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;
|
||||
|
|
|
|||
59
lib/dns-records.ts
Normal file
59
lib/dns-records.ts
Normal file
|
|
@ -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<string, string>;
|
||||
};
|
||||
|
||||
async function safe<T>(p: Promise<T>): 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<DnsRecords> {
|
||||
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<string, string> = {};
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "corex-nexredirect",
|
||||
"version": "0.1.15",
|
||||
"version": "0.1.16",
|
||||
"license": "MIT",
|
||||
"overrides": {
|
||||
"postcss": "^8.5.13",
|
||||
|
|
|
|||
Loading…
Reference in a new issue