v0.1.12 — bot filter, unique visitors, sunset notice page (per-domain + bulk)
This commit is contained in:
parent
fd118b40bf
commit
aeba290d16
11 changed files with 512 additions and 103 deletions
|
|
@ -21,6 +21,8 @@ function getStats() {
|
|||
const groups = (db.prepare("SELECT COUNT(*) AS n FROM domain_groups").get() as { n: number }).n;
|
||||
const hits24h = (db.prepare("SELECT COUNT(*) AS n FROM hits WHERE ts > ?").get(since24h) as { n: number }).n;
|
||||
const hits30d = (db.prepare("SELECT COUNT(*) AS n FROM hits WHERE ts > ?").get(since30d) as { n: number }).n;
|
||||
const visitors24h = (db.prepare("SELECT COUNT(DISTINCT ip_hash) AS n FROM hits WHERE ts > ?").get(since24h) as { n: number }).n;
|
||||
const visitors30d = (db.prepare("SELECT COUNT(DISTINCT ip_hash) AS n FROM hits WHERE ts > ?").get(since30d) as { n: number }).n;
|
||||
|
||||
const dailyRows = db.prepare(`
|
||||
SELECT strftime('%Y-%m-%d', ts/1000, 'unixepoch') AS day, COUNT(*) AS hits
|
||||
|
|
@ -36,7 +38,7 @@ function getStats() {
|
|||
ORDER BY hits DESC LIMIT 10
|
||||
`).all(since30d) as { domain: string; hits: number }[];
|
||||
|
||||
return { totalDomains, activeDomains, pendingDomains, groups, hits24h, hits30d, dailyRows, topRows };
|
||||
return { totalDomains, activeDomains, pendingDomains, groups, hits24h, hits30d, visitors24h, visitors30d, dailyRows, topRows };
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
|
|
@ -58,7 +60,7 @@ export default function DashboardPage() {
|
|||
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
|
||||
<StatCard icon={<Globe className="h-4 w-4" />} label="Domains" value={s.totalDomains} sub={`${s.activeDomains} aktiv`} />
|
||||
<StatCard icon={<AlertCircle className="h-4 w-4" />} label="Wartend" value={s.pendingDomains} sub="DNS-Verify nötig" />
|
||||
<StatCard icon={<MousePointerClick className="h-4 w-4" />} label="Hits (24h)" value={s.hits24h} sub={`${s.hits30d} in 30 Tagen`} />
|
||||
<StatCard icon={<MousePointerClick className="h-4 w-4" />} label="Hits (24h)" value={s.hits24h} sub={`${s.visitors24h} Besucher`} />
|
||||
<StatCard icon={<Layers className="h-4 w-4" />} label="Gruppen" value={s.groups} />
|
||||
</div>
|
||||
|
||||
|
|
|
|||
215
app/(app)/domains/BulkSunsetClient.tsx
Normal file
215
app/(app)/domains/BulkSunsetClient.tsx
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
"use client";
|
||||
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 { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
export type DomainListRow = {
|
||||
id: number;
|
||||
domain: string;
|
||||
status: "pending" | "active" | "error";
|
||||
target_url: string | null;
|
||||
group_id: number | null;
|
||||
group_name: string | null;
|
||||
group_target: string | null;
|
||||
total_hits: number;
|
||||
last_hit: number | null;
|
||||
sunset_config: string | null;
|
||||
};
|
||||
|
||||
function timeAgo(ts: number | null): string {
|
||||
if (!ts) return "—";
|
||||
const diff = Date.now() - ts;
|
||||
const m = Math.floor(diff / 60000);
|
||||
if (m < 1) return "gerade eben";
|
||||
if (m < 60) return `vor ${m} min`;
|
||||
const h = Math.floor(m / 60);
|
||||
if (h < 24) return `vor ${h} h`;
|
||||
const d = Math.floor(h / 24);
|
||||
return `vor ${d} d`;
|
||||
}
|
||||
|
||||
export function DomainsListClient({ domains }: { domains: DomainListRow[] }) {
|
||||
const router = useRouter();
|
||||
const [selected, setSelected] = useState<Set<number>>(new Set());
|
||||
const [bulkOpen, setBulkOpen] = useState(false);
|
||||
const [enabled, setEnabled] = useState(true);
|
||||
const [title, setTitle] = useState("Diese Domain wird abgeschaltet");
|
||||
const [message, setMessage] = useState("");
|
||||
const [buttonLabel, setButtonLabel] = useState("Weiter");
|
||||
const [sunsetDate, setSunsetDate] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
function toggle(id: number) {
|
||||
setSelected((cur) => {
|
||||
const next = new Set(cur);
|
||||
if (next.has(id)) next.delete(id); else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
function toggleAll() {
|
||||
if (selected.size === domains.length) setSelected(new Set());
|
||||
else setSelected(new Set(domains.map((d) => d.id)));
|
||||
}
|
||||
|
||||
async function applyBulk() {
|
||||
setSaving(true);
|
||||
try {
|
||||
const cfg = enabled
|
||||
? { enabled: true, title, message, button_label: buttonLabel, sunset_date: sunsetDate || undefined }
|
||||
: null;
|
||||
const r = await fetch("/api/domains/sunset-bulk", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ domain_ids: Array.from(selected), config: cfg }),
|
||||
});
|
||||
if (r.ok) {
|
||||
setBulkOpen(false);
|
||||
setSelected(new Set());
|
||||
router.refresh();
|
||||
}
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-8 space-y-3">
|
||||
{selected.size > 0 && (
|
||||
<div className="flex items-center justify-between rounded-md border border-cyan-500/40 bg-cyan-500/10 px-4 py-2 text-sm">
|
||||
<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" onClick={() => setBulkOpen(true)}>
|
||||
<Sunset className="mr-1 h-3 w-3" />
|
||||
Sunset-Hinweis konfigurieren
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{domains.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center gap-3 py-16 text-center">
|
||||
<p className="text-sm text-muted-foreground">Noch keine Domain angelegt.</p>
|
||||
<Button asChild>
|
||||
<Link href="/domains/new">Erste Domain hinzufügen</Link>
|
||||
</Button>
|
||||
</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-3 py-3 text-left">
|
||||
<input type="checkbox" checked={selected.size === domains.length && domains.length > 0} onChange={toggleAll} />
|
||||
</th>
|
||||
<th className="px-3 py-3 text-left font-medium">Domain</th>
|
||||
<th className="px-3 py-3 text-left font-medium">Status</th>
|
||||
<th className="px-3 py-3 text-left font-medium">Ziel</th>
|
||||
<th className="px-3 py-3 text-right font-medium">Hits</th>
|
||||
<th className="px-3 py-3 text-left font-medium">Letzter Hit</th>
|
||||
<th className="px-3 py-3 text-left font-medium">Sunset</th>
|
||||
<th className="px-3 py-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-800/70">
|
||||
{domains.map((d) => {
|
||||
const sunsetEnabled = (() => {
|
||||
try { return d.sunset_config ? JSON.parse(d.sunset_config).enabled === true : false; } catch { return false; }
|
||||
})();
|
||||
return (
|
||||
<tr key={d.id} className="hover:bg-zinc-900/40">
|
||||
<td className="px-3 py-3">
|
||||
<input type="checkbox" checked={selected.has(d.id)} onChange={() => toggle(d.id)} />
|
||||
</td>
|
||||
<td className="px-3 py-3 font-medium text-zinc-100">{d.domain}</td>
|
||||
<td className="px-3 py-3">
|
||||
<StatusBadge status={d.status} />
|
||||
</td>
|
||||
<td className="px-3 py-3 text-muted-foreground">
|
||||
<span className="truncate">{d.target_url || d.group_target || "—"}</span>
|
||||
{d.group_name && <span className="ml-2 text-xs text-cyan-400">({d.group_name})</span>}
|
||||
</td>
|
||||
<td className="px-3 py-3 text-right tabular-nums">{d.total_hits.toLocaleString("de-DE")}</td>
|
||||
<td className="px-3 py-3 text-muted-foreground">{timeAgo(d.last_hit)}</td>
|
||||
<td className="px-3 py-3">
|
||||
{sunsetEnabled ? <Badge variant="amber">aktiv</Badge> : <span className="text-xs text-zinc-600">—</span>}
|
||||
</td>
|
||||
<td className="px-3 py-3 text-right">
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link href={`/domains/${d.id}`}>
|
||||
Details <ArrowRight className="ml-1 h-3 w-3" />
|
||||
</Link>
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Dialog open={bulkOpen} onOpenChange={setBulkOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Sunset-Hinweis für {selected.size} Domain{selected.size === 1 ? "" : "s"}</DialogTitle>
|
||||
<DialogDescription>Setzt eine Hinweisseite vor dem Redirect. Nutzer klickt sich aktiv durch.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" checked={enabled} onChange={(e) => setEnabled(e.target.checked)} />
|
||||
Aktivieren (uncheck = deaktivieren / entfernen)
|
||||
</label>
|
||||
{enabled && (
|
||||
<>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Titel</Label>
|
||||
<Input value={title} onChange={(e) => setTitle(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Nachricht</Label>
|
||||
<Textarea value={message} onChange={(e) => setMessage(e.target.value)} rows={4} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Button-Text</Label>
|
||||
<Input value={buttonLabel} onChange={(e) => setButtonLabel(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Abschaltdatum</Label>
|
||||
<Input value={sunsetDate} onChange={(e) => setSunsetDate(e.target.value)} placeholder="31.12.2026" />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setBulkOpen(false)}>Abbrechen</Button>
|
||||
<Button onClick={applyBulk} disabled={saving}>
|
||||
{saving ? <Loader2 className="mr-2 h-3 w-3 animate-spin" /> : null}
|
||||
Auf {selected.size} anwenden
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: "pending" | "active" | "error" }) {
|
||||
if (status === "active") return <Badge variant="green"><CheckCircle2 className="mr-1 h-3 w-3" />aktiv</Badge>;
|
||||
if (status === "pending") return <Badge variant="amber"><Clock className="mr-1 h-3 w-3" />wartet</Badge>;
|
||||
return <Badge variant="destructive"><AlertCircle className="mr-1 h-3 w-3" />fehler</Badge>;
|
||||
}
|
||||
88
app/(app)/domains/[id]/SunsetEditor.tsx
Normal file
88
app/(app)/domains/[id]/SunsetEditor.tsx
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
"use client";
|
||||
import { 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";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
type Cfg = {
|
||||
enabled: boolean;
|
||||
title?: string;
|
||||
message?: string;
|
||||
button_label?: string;
|
||||
sunset_date?: string;
|
||||
};
|
||||
|
||||
export function SunsetEditor({ domainId, initial }: { domainId: number; initial: Cfg | null }) {
|
||||
const router = useRouter();
|
||||
const [enabled, setEnabled] = useState(initial?.enabled ?? false);
|
||||
const [title, setTitle] = useState(initial?.title ?? "Diese Domain wird abgeschaltet");
|
||||
const [message, setMessage] = useState(initial?.message ?? "");
|
||||
const [buttonLabel, setButtonLabel] = useState(initial?.button_label ?? "Weiter");
|
||||
const [sunsetDate, setSunsetDate] = useState(initial?.sunset_date ?? "");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [msg, setMsg] = useState("");
|
||||
|
||||
async function save() {
|
||||
setSaving(true);
|
||||
setMsg("");
|
||||
try {
|
||||
const cfg = enabled
|
||||
? { enabled, title, message, button_label: buttonLabel, sunset_date: sunsetDate || undefined }
|
||||
: null;
|
||||
const r = await fetch(`/api/domains/${domainId}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ sunset_config: cfg }),
|
||||
});
|
||||
if (r.ok) {
|
||||
setMsg("Gespeichert.");
|
||||
router.refresh();
|
||||
} else {
|
||||
setMsg("Fehler beim Speichern.");
|
||||
}
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" checked={enabled} onChange={(e) => setEnabled(e.target.checked)} />
|
||||
Hinweisseite vor Redirect anzeigen
|
||||
</label>
|
||||
|
||||
{enabled && (
|
||||
<div className="space-y-3 border-l-2 border-amber-500/40 pl-3">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="s-title" className="text-xs">Titel</Label>
|
||||
<Input id="s-title" value={title} onChange={(e) => setTitle(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="s-msg" className="text-xs">Nachricht</Label>
|
||||
<Textarea id="s-msg" value={message} onChange={(e) => setMessage(e.target.value)} rows={4} placeholder="z.B. Diese Domain wird zum 31.12.2026 abgeschaltet. Bitte verwende ab sofort https://test.de" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="s-btn" className="text-xs">Button-Text</Label>
|
||||
<Input id="s-btn" value={buttonLabel} onChange={(e) => setButtonLabel(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="s-date" className="text-xs">Abschaltdatum (optional)</Label>
|
||||
<Input id="s-date" value={sunsetDate} onChange={(e) => setSunsetDate(e.target.value)} placeholder="31.12.2026" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,29 +1,15 @@
|
|||
import Link from "next/link";
|
||||
import { CheckCircle2, Clock, AlertCircle, ArrowRight } from "lucide-react";
|
||||
import { PageHeader } from "@/components/PageHeader";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { getDb } from "@/lib/db";
|
||||
import { DomainsListClient, type DomainListRow } from "./BulkSunsetClient";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type DomainListRow = {
|
||||
id: number;
|
||||
domain: string;
|
||||
status: "pending" | "active" | "error";
|
||||
target_url: string | null;
|
||||
group_id: number | null;
|
||||
group_name: string | null;
|
||||
group_target: string | null;
|
||||
total_hits: number;
|
||||
last_hit: number | null;
|
||||
};
|
||||
|
||||
function getDomains(): DomainListRow[] {
|
||||
return getDb()
|
||||
.prepare(`
|
||||
SELECT d.id, d.domain, d.status, d.target_url, d.group_id,
|
||||
SELECT d.id, d.domain, d.status, d.target_url, d.group_id, d.sunset_config,
|
||||
g.name AS group_name, g.target_url AS group_target,
|
||||
(SELECT COUNT(*) FROM hits h WHERE h.domain_id = d.id) AS total_hits,
|
||||
(SELECT MAX(ts) FROM hits h WHERE h.domain_id = d.id) AS last_hit
|
||||
|
|
@ -34,18 +20,6 @@ function getDomains(): DomainListRow[] {
|
|||
.all() as DomainListRow[];
|
||||
}
|
||||
|
||||
function timeAgo(ts: number | null): string {
|
||||
if (!ts) return "—";
|
||||
const diff = Date.now() - ts;
|
||||
const m = Math.floor(diff / 60000);
|
||||
if (m < 1) return "gerade eben";
|
||||
if (m < 60) return `vor ${m} min`;
|
||||
const h = Math.floor(m / 60);
|
||||
if (h < 24) return `vor ${h} h`;
|
||||
const d = Math.floor(h / 24);
|
||||
return `vor ${d} d`;
|
||||
}
|
||||
|
||||
export default function DomainsPage() {
|
||||
const domains = getDomains();
|
||||
|
||||
|
|
@ -60,69 +34,7 @@ export default function DomainsPage() {
|
|||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="p-8">
|
||||
{domains.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center gap-3 py-16 text-center">
|
||||
<p className="text-sm text-muted-foreground">Noch keine Domain angelegt.</p>
|
||||
<Button asChild>
|
||||
<Link href="/domains/new">Erste Domain hinzufügen</Link>
|
||||
</Button>
|
||||
</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">Domain</th>
|
||||
<th className="px-6 py-3 text-left font-medium">Status</th>
|
||||
<th className="px-6 py-3 text-left font-medium">Ziel</th>
|
||||
<th className="px-6 py-3 text-right font-medium">Hits</th>
|
||||
<th className="px-6 py-3 text-left font-medium">Letzter Hit</th>
|
||||
<th className="px-6 py-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-800/70">
|
||||
{domains.map((d) => (
|
||||
<tr key={d.id} className="hover:bg-zinc-900/40">
|
||||
<td className="px-6 py-3 font-medium text-zinc-100">{d.domain}</td>
|
||||
<td className="px-6 py-3">
|
||||
<StatusBadge status={d.status} />
|
||||
</td>
|
||||
<td className="px-6 py-3 text-muted-foreground">
|
||||
<span className="truncate">{d.target_url || d.group_target || "—"}</span>
|
||||
{d.group_name && <span className="ml-2 text-xs text-cyan-400">({d.group_name})</span>}
|
||||
</td>
|
||||
<td className="px-6 py-3 text-right tabular-nums">{d.total_hits.toLocaleString("de-DE")}</td>
|
||||
<td className="px-6 py-3 text-muted-foreground">{timeAgo(d.last_hit)}</td>
|
||||
<td className="px-6 py-3 text-right">
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link href={`/domains/${d.id}`}>
|
||||
Details <ArrowRight className="ml-1 h-3 w-3" />
|
||||
</Link>
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
<DomainsListClient domains={domains} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: "pending" | "active" | "error" }) {
|
||||
if (status === "active") {
|
||||
return <Badge variant="green"><CheckCircle2 className="mr-1 h-3 w-3" />aktiv</Badge>;
|
||||
}
|
||||
if (status === "pending") {
|
||||
return <Badge variant="amber"><Clock className="mr-1 h-3 w-3" />wartet</Badge>;
|
||||
}
|
||||
return <Badge variant="destructive"><AlertCircle className="mr-1 h-3 w-3" />fehler</Badge>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,12 +6,21 @@ import { getDb, type DomainRow } from "@/lib/db";
|
|||
import { reloadCaddy } from "@/lib/caddy";
|
||||
import { invalidateRedirectCache } from "@/lib/redirect-resolver";
|
||||
|
||||
const sunsetSchema = z.object({
|
||||
enabled: z.boolean(),
|
||||
title: z.string().max(200).optional(),
|
||||
message: z.string().max(2000).optional(),
|
||||
button_label: z.string().max(50).optional(),
|
||||
sunset_date: z.string().max(50).optional(),
|
||||
});
|
||||
|
||||
const updateSchema = z.object({
|
||||
target_url: z.string().url().nullable().optional(),
|
||||
group_id: z.number().int().nullable().optional(),
|
||||
redirect_code: z.union([z.literal(301), z.literal(302), z.literal(307), z.literal(308)]).optional(),
|
||||
preserve_path: z.boolean().optional(),
|
||||
include_www: z.boolean().optional(),
|
||||
sunset_config: sunsetSchema.nullable().optional(),
|
||||
});
|
||||
|
||||
export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
|
|
@ -43,6 +52,9 @@ export async function PATCH(req: Request, { params }: { params: Promise<{ id: st
|
|||
if (key === "preserve_path" || key === "include_www") {
|
||||
fields.push(`${key} = ?`);
|
||||
values.push(val ? 1 : 0);
|
||||
} else if (key === "sunset_config") {
|
||||
fields.push(`sunset_config = ?`);
|
||||
values.push(val === null ? null : JSON.stringify(val));
|
||||
} else {
|
||||
fields.push(`${key} = ?`);
|
||||
values.push(val);
|
||||
|
|
|
|||
38
app/api/domains/sunset-bulk/route.ts
Normal file
38
app/api/domains/sunset-bulk/route.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
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 { invalidateRedirectCache } from "@/lib/redirect-resolver";
|
||||
|
||||
const sunsetSchema = z.object({
|
||||
enabled: z.boolean(),
|
||||
title: z.string().max(200).optional(),
|
||||
message: z.string().max(2000).optional(),
|
||||
button_label: z.string().max(50).optional(),
|
||||
sunset_date: z.string().max(50).optional(),
|
||||
});
|
||||
|
||||
const bodySchema = z.object({
|
||||
domain_ids: z.array(z.number().int()).min(1).max(500),
|
||||
config: sunsetSchema.nullable(),
|
||||
});
|
||||
|
||||
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 = bodySchema.safeParse(body);
|
||||
if (!parsed.success) return NextResponse.json({ error: "invalid", details: parsed.error.flatten() }, { status: 400 });
|
||||
|
||||
const { domain_ids, config } = parsed.data;
|
||||
const value = config === null ? null : JSON.stringify(config);
|
||||
|
||||
const db = getDb();
|
||||
const placeholders = domain_ids.map(() => "?").join(",");
|
||||
db.prepare(`UPDATE domains SET sunset_config = ? WHERE id IN (${placeholders})`).run(value, ...domain_ids);
|
||||
|
||||
invalidateRedirectCache();
|
||||
return NextResponse.json({ ok: true, updated: domain_ids.length });
|
||||
}
|
||||
19
lib/db.ts
19
lib/db.ts
|
|
@ -143,6 +143,14 @@ export function hashIp(ip: string): string {
|
|||
return crypto.createHash("sha256").update(ip + getDailySalt()).digest("hex");
|
||||
}
|
||||
|
||||
export type SunsetConfig = {
|
||||
enabled: boolean;
|
||||
title?: string;
|
||||
message?: string;
|
||||
button_label?: string;
|
||||
sunset_date?: string;
|
||||
};
|
||||
|
||||
export type DomainRow = {
|
||||
id: number;
|
||||
domain: string;
|
||||
|
|
@ -155,8 +163,19 @@ export type DomainRow = {
|
|||
created_by: number | null;
|
||||
created_at: number;
|
||||
verified_at: number | null;
|
||||
sunset_config: string | null;
|
||||
};
|
||||
|
||||
export function parseSunset(row: { sunset_config?: string | null }): SunsetConfig | null {
|
||||
if (!row.sunset_config) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(row.sunset_config) as SunsetConfig;
|
||||
return parsed.enabled ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export type DomainGroupRow = {
|
||||
id: number;
|
||||
name: string;
|
||||
|
|
|
|||
12
lib/hits.ts
12
lib/hits.ts
|
|
@ -1,6 +1,18 @@
|
|||
import { getDb, hashIp } from "./db";
|
||||
import { lookupCountry } from "./geo";
|
||||
|
||||
// Patterns we don't want polluting analytics
|
||||
const BOT_UA = /bot|crawl|spider|slurp|curl|wget|httpclient|python-requests|axios|node-fetch|monitor|uptime|pingdom|datadog|prometheus|scanner|fetch|preview|whatsapp|telegrambot|facebookexternalhit|linkedinbot|twitterbot|discordbot|skypeuripreview|mastodon|matrix-bot|preconnect|dnsperf|sentry|newrelic|gtmetrix|lighthouse|headlesschrome|phantomjs|puppeteer|playwright|chrome-lighthouse/i;
|
||||
const SKIP_PATHS = /^\/(favicon\.ico|robots\.txt|sitemap\.xml|apple-touch-icon[\w-]*\.png|browserconfig\.xml|\.well-known\/|ads\.txt)/i;
|
||||
|
||||
export function shouldRecord(method: string, path: string | null, userAgent: string | null): boolean {
|
||||
const m = (method || "GET").toUpperCase();
|
||||
if (m === "HEAD" || m === "OPTIONS") return false;
|
||||
if (path && SKIP_PATHS.test(path)) return false;
|
||||
if (userAgent && BOT_UA.test(userAgent)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
type PendingHit = {
|
||||
domain_id: number;
|
||||
ts: number;
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
import { getDb, getSetting, type DomainRow, type DomainGroupRow } from "./db";
|
||||
import { getDb, getSetting, parseSunset, type DomainRow, type DomainGroupRow, type SunsetConfig } from "./db";
|
||||
|
||||
export type ResolvedRedirect = {
|
||||
domain_id: number;
|
||||
domain: string;
|
||||
target_url: string;
|
||||
redirect_code: number;
|
||||
preserve_path: boolean;
|
||||
sunset: SunsetConfig | null;
|
||||
};
|
||||
|
||||
let cache: Map<string, ResolvedRedirect> | null = null;
|
||||
|
|
@ -23,9 +25,11 @@ function loadCache(): Map<string, ResolvedRedirect> {
|
|||
if (!target) continue;
|
||||
const r: ResolvedRedirect = {
|
||||
domain_id: d.id,
|
||||
domain: d.domain,
|
||||
target_url: target,
|
||||
redirect_code: d.redirect_code,
|
||||
preserve_path: !!d.preserve_path,
|
||||
sunset: parseSunset(d),
|
||||
};
|
||||
m.set(d.domain.toLowerCase(), r);
|
||||
if (d.include_www) m.set(`www.${d.domain.toLowerCase()}`, r);
|
||||
|
|
|
|||
85
lib/sunset-html.ts
Normal file
85
lib/sunset-html.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import type { SunsetConfig } from "./db";
|
||||
|
||||
function esc(s: string): string {
|
||||
return s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]!));
|
||||
}
|
||||
|
||||
export function renderSunsetPage(opts: {
|
||||
domain: string;
|
||||
target: string;
|
||||
preservePath: boolean;
|
||||
reqPath: string;
|
||||
cfg: SunsetConfig;
|
||||
}): string {
|
||||
const title = opts.cfg.title || "Diese Domain wird abgeschaltet";
|
||||
const message = opts.cfg.message || `Die Domain ${opts.domain} wird abgeschaltet. Bitte aktualisiere deine Lesezeichen.`;
|
||||
const button = opts.cfg.button_label || "Weiter";
|
||||
const date = opts.cfg.sunset_date ? `Geplante Abschaltung: ${opts.cfg.sunset_date}` : "";
|
||||
|
||||
const continueUrl = (() => {
|
||||
const sep = opts.target.includes("?") ? "&" : "?";
|
||||
const base = opts.preservePath ? opts.target + (opts.reqPath || "") : opts.target;
|
||||
// Avoid mangling paths that already have query — only append nothing extra; just go to target as-is
|
||||
return base;
|
||||
})();
|
||||
|
||||
return `<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<meta name="robots" content="noindex,nofollow">
|
||||
<title>${esc(title)}</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
background: #ffffff;
|
||||
color: #111;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.card {
|
||||
max-width: 540px;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
h1 { font-size: 22px; font-weight: 600; margin: 0 0 16px; color: #000; }
|
||||
p { font-size: 15px; color: #333; margin: 0 0 12px; white-space: pre-wrap; }
|
||||
.date { font-size: 13px; color: #666; margin: 16px 0 28px; }
|
||||
a.continue {
|
||||
display: inline-block;
|
||||
padding: 10px 24px;
|
||||
background: #111;
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
a.continue:hover { background: #333; }
|
||||
.domain {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
color: #888;
|
||||
margin-top: 32px;
|
||||
word-break: break-all;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h1>${esc(title)}</h1>
|
||||
<p>${esc(message)}</p>
|
||||
${date ? `<p class="date">${esc(date)}</p>` : ""}
|
||||
<a class="continue" href="${esc(continueUrl)}?nr_continue=1">${esc(button)}</a>
|
||||
<p class="domain">${esc(opts.domain)} → ${esc(opts.target)}</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
40
server.ts
40
server.ts
|
|
@ -8,7 +8,8 @@ import http from "http";
|
|||
import { parse } from "url";
|
||||
import next from "next";
|
||||
import { resolveHost, isAdminHost } from "./lib/redirect-resolver";
|
||||
import { recordHit } from "./lib/hits";
|
||||
import { recordHit, shouldRecord } from "./lib/hits";
|
||||
import { renderSunsetPage } from "./lib/sunset-html";
|
||||
|
||||
const dev = process.env.NODE_ENV !== "production";
|
||||
const port = parseInt(process.env.PORT || "3000", 10);
|
||||
|
|
@ -30,20 +31,41 @@ app.prepare().then(() => {
|
|||
((req.headers["x-forwarded-for"] || "") as string).split(",")[0].trim() ||
|
||||
req.socket.remoteAddress ||
|
||||
"unknown";
|
||||
recordHit({
|
||||
domain_id: resolved.domain_id,
|
||||
ip,
|
||||
user_agent: (req.headers["user-agent"] as string) || null,
|
||||
referer: (req.headers["referer"] as string) || null,
|
||||
path: req.url || null,
|
||||
}).catch(() => {});
|
||||
const ua = (req.headers["user-agent"] as string) || null;
|
||||
if (shouldRecord(req.method || "GET", req.url || "/", ua)) {
|
||||
recordHit({
|
||||
domain_id: resolved.domain_id,
|
||||
ip,
|
||||
user_agent: ua,
|
||||
referer: (req.headers["referer"] as string) || null,
|
||||
path: req.url || null,
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
// Sunset notice: serve interstitial unless user clicked "Weiter" (?nr_continue=1)
|
||||
const reqPath = req.url || "/";
|
||||
const isContinue = parsedUrl.query?.nr_continue === "1";
|
||||
if (resolved.sunset && !isContinue) {
|
||||
const html = renderSunsetPage({
|
||||
domain: resolved.domain,
|
||||
target: resolved.target_url,
|
||||
preservePath: resolved.preserve_path,
|
||||
reqPath,
|
||||
cfg: resolved.sunset,
|
||||
});
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "text/html; charset=utf-8",
|
||||
"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0",
|
||||
});
|
||||
res.end(html);
|
||||
return;
|
||||
}
|
||||
|
||||
const target = resolved.preserve_path
|
||||
? resolved.target_url + (parsedUrl.path || "")
|
||||
: resolved.target_url;
|
||||
res.writeHead(resolved.redirect_code || 302, {
|
||||
Location: target,
|
||||
// Forbid caching so every hit reaches us for analytics.
|
||||
"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0",
|
||||
Pragma: "no-cache",
|
||||
Expires: "0",
|
||||
|
|
|
|||
Loading…
Reference in a new issue