v0.1.12 — bot filter, unique visitors, sunset notice page (per-domain + bulk)

This commit is contained in:
Hendrik 2026-05-01 19:22:04 +02:00
parent fd118b40bf
commit aeba290d16
11 changed files with 512 additions and 103 deletions

View file

@ -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>

View 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>;
}

View 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>
);
}

View file

@ -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>;
}

View file

@ -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);

View 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 });
}

View file

@ -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;

View file

@ -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;

View file

@ -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
View file

@ -0,0 +1,85 @@
import type { SunsetConfig } from "./db";
function esc(s: string): string {
return s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[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>`;
}

View file

@ -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",