diff --git a/app/(app)/dashboard/page.tsx b/app/(app)/dashboard/page.tsx
index dc97ce8..f6b1ea8 100644
--- a/app/(app)/dashboard/page.tsx
+++ b/app/(app)/dashboard/page.tsx
@@ -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() {
} label="Domains" value={s.totalDomains} sub={`${s.activeDomains} aktiv`} />
} label="Wartend" value={s.pendingDomains} sub="DNS-Verify nötig" />
- } label="Hits (24h)" value={s.hits24h} sub={`${s.hits30d} in 30 Tagen`} />
+ } label="Hits (24h)" value={s.hits24h} sub={`${s.visitors24h} Besucher`} />
} label="Gruppen" value={s.groups} />
diff --git a/app/(app)/domains/BulkSunsetClient.tsx b/app/(app)/domains/BulkSunsetClient.tsx
new file mode 100644
index 0000000..bbdd446
--- /dev/null
+++ b/app/(app)/domains/BulkSunsetClient.tsx
@@ -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>(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 (
+
+ {selected.size > 0 && (
+
+
{selected.size} ausgewählt
+
+ setSelected(new Set())}>Auswahl aufheben
+ setBulkOpen(true)}>
+
+ Sunset-Hinweis konfigurieren
+
+
+
+ )}
+
+ {domains.length === 0 ? (
+
+
+ Noch keine Domain angelegt.
+
+ Erste Domain hinzufügen
+
+
+
+ ) : (
+
+
+
+
+
+ )}
+
+
+
+
+ Sunset-Hinweis für {selected.size} Domain{selected.size === 1 ? "" : "s"}
+ Setzt eine Hinweisseite vor dem Redirect. Nutzer klickt sich aktiv durch.
+
+
+
+ setEnabled(e.target.checked)} />
+ Aktivieren (uncheck = deaktivieren / entfernen)
+
+ {enabled && (
+ <>
+
+ Titel
+ setTitle(e.target.value)} />
+
+
+ Nachricht
+
+
+ >
+ )}
+
+
+ setBulkOpen(false)}>Abbrechen
+
+ {saving ? : null}
+ Auf {selected.size} anwenden
+
+
+
+
+
+ );
+}
+
+function StatusBadge({ status }: { status: "pending" | "active" | "error" }) {
+ if (status === "active") return aktiv ;
+ if (status === "pending") return wartet ;
+ return fehler ;
+}
diff --git a/app/(app)/domains/[id]/SunsetEditor.tsx b/app/(app)/domains/[id]/SunsetEditor.tsx
new file mode 100644
index 0000000..4f6d09f
--- /dev/null
+++ b/app/(app)/domains/[id]/SunsetEditor.tsx
@@ -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 (
+
+
+ setEnabled(e.target.checked)} />
+ Hinweisseite vor Redirect anzeigen
+
+
+ {enabled && (
+
+
+ Titel
+ setTitle(e.target.value)} />
+
+
+ Nachricht
+
+
+
+ )}
+
+
+ {saving ? : }
+ Speichern
+
+ {msg &&
{msg}
}
+
+ );
+}
diff --git a/app/(app)/domains/page.tsx b/app/(app)/domains/page.tsx
index fda3a40..6affd16 100644
--- a/app/(app)/domains/page.tsx
+++ b/app/(app)/domains/page.tsx
@@ -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() {
}
/>
-
-
- {domains.length === 0 ? (
-
-
- Noch keine Domain angelegt.
-
- Erste Domain hinzufügen
-
-
-
- ) : (
-
-
-
-
-
- Domain
- Status
- Ziel
- Hits
- Letzter Hit
-
-
-
-
- {domains.map((d) => (
-
- {d.domain}
-
-
-
-
- {d.target_url || d.group_target || "—"}
- {d.group_name && ({d.group_name}) }
-
- {d.total_hits.toLocaleString("de-DE")}
- {timeAgo(d.last_hit)}
-
-
-
- Details
-
-
-
-
- ))}
-
-
-
-
- )}
-
+
);
}
-
-function StatusBadge({ status }: { status: "pending" | "active" | "error" }) {
- if (status === "active") {
- return aktiv ;
- }
- if (status === "pending") {
- return wartet ;
- }
- return fehler ;
-}
diff --git a/app/api/domains/[id]/route.ts b/app/api/domains/[id]/route.ts
index ffe463f..615a715 100644
--- a/app/api/domains/[id]/route.ts
+++ b/app/api/domains/[id]/route.ts
@@ -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);
diff --git a/app/api/domains/sunset-bulk/route.ts b/app/api/domains/sunset-bulk/route.ts
new file mode 100644
index 0000000..07a5440
--- /dev/null
+++ b/app/api/domains/sunset-bulk/route.ts
@@ -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 });
+}
diff --git a/lib/db.ts b/lib/db.ts
index 600ecb2..cd72ab4 100644
--- a/lib/db.ts
+++ b/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;
diff --git a/lib/hits.ts b/lib/hits.ts
index 81f8d24..42ff447 100644
--- a/lib/hits.ts
+++ b/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;
diff --git a/lib/redirect-resolver.ts b/lib/redirect-resolver.ts
index ce41157..8975cfe 100644
--- a/lib/redirect-resolver.ts
+++ b/lib/redirect-resolver.ts
@@ -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 | null = null;
@@ -23,9 +25,11 @@ function loadCache(): Map {
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);
diff --git a/lib/sunset-html.ts b/lib/sunset-html.ts
new file mode 100644
index 0000000..82e6192
--- /dev/null
+++ b/lib/sunset-html.ts
@@ -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 `
+
+
+
+
+
+${esc(title)}
+
+
+
+
+
${esc(title)}
+
${esc(message)}
+ ${date ? `
${esc(date)}
` : ""}
+
${esc(button)}
+
${esc(opts.domain)} → ${esc(opts.target)}
+
+
+`;
+}
diff --git a/server.ts b/server.ts
index 2416ec4..41da0cc 100644
--- a/server.ts
+++ b/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",