Initial NexRedirect: redirect server with admin UI, analytics, API tokens, self-update

This commit is contained in:
Hendrik 2026-05-01 17:51:12 +02:00
commit d7272c5e58
81 changed files with 8934 additions and 0 deletions

16
.gitignore vendored Normal file
View file

@ -0,0 +1,16 @@
node_modules
.next
.next/
out/
build/
*.tsbuildinfo
.env
.env.local
.env*.local
data/
*.db
*.db-journal
*.mmdb
.DS_Store
.vscode/
.idea/

87
README.md Normal file
View file

@ -0,0 +1,87 @@
# CoreX NexRedirect
Self-hosted Domain-Redirect-Server mit Web-Admin-UI und Per-Domain-Analytics. Viele Domains zeigen via DNS auf einen einzigen Server, der jede Domain auf das jeweilige Ziel weiterleitet und protokolliert, welche Domains tatsächlich noch genutzt werden — ideal um tote Domains zu identifizieren.
## Features
- **One-Line Install** auf Debian/Ubuntu (Caddy + Node + systemd)
- **Web-Admin-UI** mit Setup-Wizard, Domain-Verwaltung, Analytics
- **Auto-HTTPS** via Caddy (Let's Encrypt automatisch)
- **DNS-Validierung** beim Hinzufügen einer Domain (zeigt fehlende Records)
- **Domain-Gruppen** für gleiches Ziel über mehrere Domains
- **Per-Domain-Analytics** (Hits, Geo, Top-Domains, "Tote Domains")
- **Public REST-API** mit Token-Auth und Scopes
- **Self-Update** via GitHub-Releases (UI-Banner + Auto-Update opt-in)
- **DSGVO-freundlich**: IP-Hash mit täglich rotierendem Salt, kein Klartext
## Installation
```bash
curl -sSL https://raw.githubusercontent.com/CoreXManagement/CoreX-NexRedirect/main/scripts/install.sh | sudo bash
```
Optional vorab MaxMind-Lizenz für Geo-Lookup:
```bash
export MAXMIND_LICENSE_KEY=xxx
curl ... | sudo -E bash
```
Anschließend Setup unter `http://<server-ip>/setup` aufrufen und Admin-Account erstellen.
Details: [docs/INSTALL.md](docs/INSTALL.md)
## Domain hinzufügen
1. **Admin-UI** → "Domains" → "+ Domain hinzufügen"
2. Domain + Ziel-URL (oder Gruppe) eingeben
3. **DNS-Records** beim DNS-Provider eintragen (A/AAAA auf Server-IP)
4. **Validieren** — Server prüft DNS, aktiviert Domain, Caddy reload
Alternativ via API:
```bash
curl -X POST -H "Authorization: Bearer nrx_..." -H "Content-Type: application/json" \
-d '{"domain":"alt-firma.de","target_url":"https://www.firma.de"}' \
https://admin.firma.de/api/v1/domains
```
## API
Tokens werden im Web-UI unter **Einstellungen → API-Tokens** erstellt. Tokens haben Scopes (`read:domains`, `write:domains`, `read:analytics`, `read:hits`).
```bash
curl -H "Authorization: Bearer nrx_..." https://admin.firma.de/api/v1/domains
```
Vollständige Doku: [docs/API.md](docs/API.md)
## Updates
Standardmäßig prüft der Server stündlich auf neue Releases und zeigt einen Banner in der UI. **Keine Auto-Updates** außer aktiviert.
- Manuell: Settings → "Update X.Y.Z installieren"
- Auto: Settings → Auto-Update-Toggle aktivieren
Details: [docs/UPDATE.md](docs/UPDATE.md)
## Stack
- Next.js 15 + TypeScript + TailwindCSS + Radix UI + Recharts
- better-sqlite3 (eine Datei in `/var/lib/corex-nexredirect/nexredirect.db`)
- Caddy (Auto-HTTPS, Reverse-Proxy)
- MaxMind GeoLite2-Country (lokal)
- NextAuth Credentials + bcryptjs
## Lokale Entwicklung
```bash
git clone https://github.com/CoreXManagement/CoreX-NexRedirect
cd CoreX-NexRedirect
npm install
npm run dev
```
Setup unter `http://localhost:3000/setup`.
## Lizenz
Internal — © CoreX Management

View file

@ -0,0 +1,90 @@
import { PageHeader } from "@/components/PageHeader";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { getDb } from "@/lib/db";
import { HitsLineChart } from "@/components/charts/HitsLineChart";
import { TopDomainsBarChart } from "@/components/charts/TopDomainsBarChart";
import { CountryPie } from "@/components/charts/CountryPie";
export const dynamic = "force-dynamic";
function getStats() {
const db = getDb();
const since = Date.now() - 30 * 24 * 60 * 60 * 1000;
const daily = db.prepare(`
SELECT strftime('%Y-%m-%d', ts/1000, 'unixepoch') AS day, COUNT(*) AS hits
FROM hits WHERE ts > ?
GROUP BY day ORDER BY day
`).all(since) as { day: string; hits: number }[];
const top = db.prepare(`
SELECT d.domain, COUNT(h.id) AS hits
FROM hits h JOIN domains d ON d.id = h.domain_id
WHERE h.ts > ?
GROUP BY d.domain ORDER BY hits DESC LIMIT 10
`).all(since) as { domain: string; hits: number }[];
const byCountry = db.prepare(`
SELECT COALESCE(country,'??') AS country, COUNT(*) AS hits
FROM hits WHERE ts > ?
GROUP BY country ORDER BY hits DESC LIMIT 8
`).all(since) as { country: string; hits: number }[];
const dead = db.prepare(`
SELECT d.id, d.domain, d.target_url, d.created_at
FROM domains d
WHERE d.status = 'active'
AND NOT EXISTS (SELECT 1 FROM hits h WHERE h.domain_id = d.id AND h.ts > ?)
ORDER BY d.created_at
`).all(Date.now() - 90 * 24 * 60 * 60 * 1000) as { id: number; domain: string; target_url: string | null; created_at: number }[];
return { daily, top, byCountry, dead };
}
export default function AnalyticsPage() {
const s = getStats();
return (
<div>
<PageHeader title="Analytics" description="Hit-Statistiken letzte 30 Tage" />
<div className="space-y-4 p-8">
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<Card>
<CardHeader><CardTitle>Hits pro Tag</CardTitle></CardHeader>
<CardContent><HitsLineChart data={s.daily} /></CardContent>
</Card>
<Card>
<CardHeader><CardTitle>Top 10 Domains</CardTitle></CardHeader>
<CardContent><TopDomainsBarChart data={s.top} /></CardContent>
</Card>
<Card>
<CardHeader><CardTitle>Top Länder</CardTitle></CardHeader>
<CardContent><CountryPie data={s.byCountry} /></CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Tote Domains</CardTitle>
<CardDescription>Aktive Domains ohne Hits in den letzten 90 Tagen kandidaten zum Kündigen</CardDescription>
</CardHeader>
<CardContent>
{s.dead.length === 0 ? (
<p className="py-6 text-center text-sm text-muted-foreground">Keine alle aktiven Domains werden genutzt.</p>
) : (
<ul className="divide-y divide-zinc-800/70">
{s.dead.map((d) => (
<li key={d.id} className="flex items-center justify-between py-2 text-sm">
<span className="font-medium">{d.domain}</span>
<Badge variant="amber">0 Hits / 90d</Badge>
</li>
))}
</ul>
)}
</CardContent>
</Card>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,103 @@
import Link from "next/link";
import { Globe, MousePointerClick, Layers, AlertCircle } from "lucide-react";
import { PageHeader } from "@/components/PageHeader";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { getDb } from "@/lib/db";
import { HitsLineChart } from "@/components/charts/HitsLineChart";
import { TopDomainsBarChart } from "@/components/charts/TopDomainsBarChart";
export const dynamic = "force-dynamic";
function getStats() {
const db = getDb();
const since30d = Date.now() - 30 * 24 * 60 * 60 * 1000;
const since24h = Date.now() - 24 * 60 * 60 * 1000;
const totalDomains = (db.prepare("SELECT COUNT(*) AS n FROM domains").get() as { n: number }).n;
const activeDomains = (db.prepare("SELECT COUNT(*) AS n FROM domains WHERE status='active'").get() as { n: number }).n;
const pendingDomains = (db.prepare("SELECT COUNT(*) AS n FROM domains WHERE status='pending'").get() as { n: number }).n;
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 dailyRows = db.prepare(`
SELECT strftime('%Y-%m-%d', ts/1000, 'unixepoch') AS day, COUNT(*) AS hits
FROM hits WHERE ts > ?
GROUP BY day ORDER BY day
`).all(since30d) as { day: string; hits: number }[];
const topRows = db.prepare(`
SELECT d.domain, COUNT(h.id) AS hits
FROM hits h JOIN domains d ON d.id = h.domain_id
WHERE h.ts > ?
GROUP BY d.domain
ORDER BY hits DESC LIMIT 10
`).all(since30d) as { domain: string; hits: number }[];
return { totalDomains, activeDomains, pendingDomains, groups, hits24h, hits30d, dailyRows, topRows };
}
export default function DashboardPage() {
const s = getStats();
return (
<div>
<PageHeader
title="Dashboard"
description="Überblick über deine Domains und Redirects"
actions={
<Button asChild size="sm">
<Link href="/domains/new">+ Domain hinzufügen</Link>
</Button>
}
/>
<div className="space-y-6 p-8">
<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={<Layers className="h-4 w-4" />} label="Gruppen" value={s.groups} />
</div>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Hits letzte 30 Tage</CardTitle>
<CardDescription>Aggregiert über alle Domains</CardDescription>
</CardHeader>
<CardContent>
<HitsLineChart data={s.dailyRows} />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Top Domains</CardTitle>
<CardDescription>Hits letzte 30 Tage</CardDescription>
</CardHeader>
<CardContent>
<TopDomainsBarChart data={s.topRows} />
</CardContent>
</Card>
</div>
</div>
</div>
);
}
function StatCard({ icon, label, value, sub }: { icon: React.ReactNode; label: string; value: number; sub?: string }) {
return (
<Card>
<CardContent className="p-5">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
{icon}
<span>{label}</span>
</div>
<p className="mt-2 text-3xl font-semibold tabular-nums">{value.toLocaleString("de-DE")}</p>
{sub && <p className="mt-1 text-xs text-muted-foreground">{sub}</p>}
</CardContent>
</Card>
);
}

View file

@ -0,0 +1,50 @@
"use client";
import { useRouter } from "next/navigation";
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 }) {
const router = useRouter();
const [busy, setBusy] = useState<"verify" | "delete" | null>(null);
const [msg, setMsg] = useState("");
async function verify() {
setBusy("verify");
setMsg("");
try {
const res = await fetch(`/api/domains/${id}/verify`, { method: "POST" });
const d = await res.json();
setMsg(d.ok ? "DNS OK — Domain aktiviert" : `DNS unvollständig: ${d.result?.missing?.join(", ") ?? ""}`);
router.refresh();
} finally {
setBusy(null);
}
}
async function del() {
if (!confirm("Domain wirklich löschen? Hits bleiben gelöscht.")) return;
setBusy("delete");
try {
const res = await fetch(`/api/domains/${id}`, { method: "DELETE" });
if (res.ok) router.push("/domains");
} finally {
setBusy(null);
}
}
return (
<div className="space-y-2">
<Button onClick={verify} variant="outline" size="sm" className="w-full" disabled={busy !== null}>
{busy === "verify" ? <Loader2 className="mr-2 h-3 w-3 animate-spin" /> : <RefreshCcw className="mr-2 h-3 w-3" />}
DNS erneut prüfen
</Button>
<Button onClick={del} variant="destructive" size="sm" className="w-full" disabled={busy !== null}>
{busy === "delete" ? <Loader2 className="mr-2 h-3 w-3 animate-spin" /> : <Trash2 className="mr-2 h-3 w-3" />}
Domain löschen
</Button>
{msg && <p className="text-xs text-muted-foreground">{msg}</p>}
{status === "pending" && <p className="text-xs text-amber-300/80">Domain ist noch nicht aktiv. DNS muss korrekt eingetragen sein, bevor Aufrufe weitergeleitet werden.</p>}
</div>
);
}

View file

@ -0,0 +1,112 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { ArrowLeft, ExternalLink } from "lucide-react";
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 { HitsLineChart } from "@/components/charts/HitsLineChart";
import { DomainActions } from "./DomainActions";
export const dynamic = "force-dynamic";
export default async function DomainDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const db = getDb();
const domain = db.prepare("SELECT * FROM domains WHERE id = ?").get(Number(id)) as DomainRow | undefined;
if (!domain) notFound();
const group = domain.group_id
? (db.prepare("SELECT * FROM domain_groups WHERE id = ?").get(domain.group_id) as DomainGroupRow | undefined)
: null;
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 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 dailyRows = db.prepare(`
SELECT strftime('%Y-%m-%d', ts/1000, 'unixepoch') AS day, COUNT(*) AS hits
FROM hits WHERE domain_id = ? AND ts > ?
GROUP BY day ORDER BY day
`).all(domain.id, since30d) as { day: string; hits: number }[];
const target = domain.target_url ?? group?.target_url ?? null;
return (
<div>
<PageHeader
title={domain.domain}
description={`Status: ${domain.status} • Code ${domain.redirect_code}`}
actions={
<Button asChild variant="ghost" size="sm">
<Link href="/domains"><ArrowLeft className="mr-1 h-3 w-3" />Zurück</Link>
</Button>
}
/>
<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}</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>
</CardContent>
</Card>
<Card>
<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>
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle className="text-sm">Aktionen</CardTitle></CardHeader>
<CardContent>
<DomainActions id={domain.id} status={domain.status} />
</CardContent>
</Card>
<Card className="lg:col-span-3">
<CardHeader>
<CardTitle>Hits letzte 30 Tage</CardTitle>
<CardDescription>Tagesgenaue Aufrufe</CardDescription>
</CardHeader>
<CardContent>
<HitsLineChart data={dailyRows} />
</CardContent>
</Card>
</div>
</div>
);
}
function Row({ k, children }: { k: string; children: React.ReactNode }) {
return (
<div className="flex items-center justify-between gap-4 border-b border-zinc-800/40 py-1.5 last:border-0">
<span className="text-xs text-muted-foreground">{k}</span>
<span className="text-right">{children}</span>
</div>
);
}
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>;
}

View file

@ -0,0 +1,306 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { Loader2, Copy, CheckCircle2, AlertTriangle, ArrowRight } from "lucide-react";
import { PageHeader } from "@/components/PageHeader";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
type Group = { id: number; name: string; target_url: string };
type VerifyResult = {
ok: boolean;
result: {
expected: { ipv4?: string; ipv6?: string };
resolved: { a: string[]; aaaa: string[]; wwwA: string[]; wwwAaaa: string[] };
missing: string[];
};
};
export default function NewDomainPage() {
const router = useRouter();
const [step, setStep] = useState<1 | 2 | 3>(1);
const [domainId, setDomainId] = useState<number | null>(null);
// Step 1 form
const [domain, setDomain] = useState("");
const [targetMode, setTargetMode] = useState<"url" | "group">("url");
const [targetUrl, setTargetUrl] = useState("");
const [groupId, setGroupId] = useState<number | "">("");
const [redirectCode, setRedirectCode] = useState<301 | 302>(301);
const [preservePath, setPreservePath] = useState(true);
const [includeWww, setIncludeWww] = useState(true);
const [groups, setGroups] = useState<Group[]>([]);
const [creating, setCreating] = useState(false);
const [error, setError] = useState("");
// Step 2/3
const [serverIps, setServerIps] = useState<{ ipv4?: string; ipv6?: string }>({});
const [verifying, setVerifying] = useState(false);
const [verifyResult, setVerifyResult] = useState<VerifyResult | null>(null);
useEffect(() => {
fetch("/api/groups").then((r) => r.json()).then((d) => setGroups(d.groups || [])).catch(() => {});
fetch("/api/settings/server-ip").then((r) => r.json()).then(setServerIps).catch(() => {});
}, []);
async function handleCreate(e: React.FormEvent) {
e.preventDefault();
setCreating(true);
setError("");
try {
const body: Record<string, unknown> = {
domain: domain.trim().toLowerCase(),
redirect_code: redirectCode,
preserve_path: preservePath,
include_www: includeWww,
};
if (targetMode === "url") body.target_url = targetUrl.trim();
else body.group_id = groupId;
const res = await fetch("/api/domains", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const d = await res.json();
if (!res.ok) {
setError(d.error || "Fehler beim Anlegen");
return;
}
setDomainId(d.domain.id);
setStep(2);
} finally {
setCreating(false);
}
}
async function handleVerify() {
if (!domainId) return;
setVerifying(true);
setVerifyResult(null);
try {
const res = await fetch(`/api/domains/${domainId}/verify`, { method: "POST" });
const d = await res.json();
setVerifyResult(d);
} finally {
setVerifying(false);
}
}
return (
<div>
<PageHeader title="Domain hinzufügen" description={`Schritt ${step} von 3`} />
<div className="mx-auto max-w-2xl space-y-4 p-8">
<StepIndicator step={step} />
{step === 1 && (
<Card>
<CardHeader>
<CardTitle>Domain & Ziel</CardTitle>
<CardDescription>Lege fest, welche Domain wohin weiterleiten soll.</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleCreate} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="domain">Domain</Label>
<Input id="domain" required placeholder="beispiel.de" value={domain} onChange={(e) => setDomain(e.target.value)} />
</div>
<div className="space-y-2">
<Label>Ziel</Label>
<div className="flex gap-2">
<Button type="button" variant={targetMode === "url" ? "default" : "outline"} size="sm" onClick={() => setTargetMode("url")}>Einzel-URL</Button>
<Button type="button" variant={targetMode === "group" ? "default" : "outline"} size="sm" onClick={() => setTargetMode("group")} disabled={groups.length === 0}>Gruppe</Button>
</div>
</div>
{targetMode === "url" ? (
<div className="space-y-2">
<Label htmlFor="target">Ziel-URL</Label>
<Input id="target" required type="url" placeholder="https://www.zielseite.de" value={targetUrl} onChange={(e) => setTargetUrl(e.target.value)} />
</div>
) : (
<div className="space-y-2">
<Label htmlFor="group">Gruppe</Label>
<select
id="group"
required
value={groupId}
onChange={(e) => setGroupId(e.target.value ? Number(e.target.value) : "")}
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
<option value=""> wählen </option>
{groups.map((g) => (
<option key={g.id} value={g.id}>{g.name} {g.target_url}</option>
))}
</select>
</div>
)}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="code">Status-Code</Label>
<select
id="code"
value={redirectCode}
onChange={(e) => setRedirectCode(Number(e.target.value) as 301 | 302)}
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm"
>
<option value={301}>301 Permanent</option>
<option value={302}>302 Temporär</option>
</select>
</div>
<div className="flex items-end gap-4">
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={preservePath} onChange={(e) => setPreservePath(e.target.checked)} />
Pfad übernehmen
</label>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={includeWww} onChange={(e) => setIncludeWww(e.target.checked)} />
www.
</label>
</div>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<div className="flex justify-end pt-2">
<Button type="submit" disabled={creating}>
{creating ? <><Loader2 className="mr-2 h-4 w-4 animate-spin" />Lege an...</> : <>Weiter <ArrowRight className="ml-2 h-4 w-4" /></>}
</Button>
</div>
</form>
</CardContent>
</Card>
)}
{step === 2 && (
<Card>
<CardHeader>
<CardTitle>DNS-Records eintragen</CardTitle>
<CardDescription>Trage diese Records bei deinem DNS-Provider ein:</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<DnsRecordsTable domain={domain.trim().toLowerCase()} ipv4={serverIps.ipv4} ipv6={serverIps.ipv6} includeWww={includeWww} />
<div className="rounded-md border border-amber-500/30 bg-amber-500/10 p-3 text-xs text-amber-200">
Hinweis: DNS-Änderungen können einige Minuten bis zur Sichtbarkeit dauern.
</div>
<div className="flex justify-end">
<Button onClick={() => setStep(3)}>Weiter <ArrowRight className="ml-2 h-4 w-4" /></Button>
</div>
</CardContent>
</Card>
)}
{step === 3 && (
<Card>
<CardHeader>
<CardTitle>Validierung</CardTitle>
<CardDescription>Prüft, ob die DNS-Records korrekt gesetzt sind.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<Button onClick={handleVerify} disabled={verifying} className="w-full">
{verifying ? <><Loader2 className="mr-2 h-4 w-4 animate-spin" />Prüfe...</> : "Jetzt prüfen"}
</Button>
{verifyResult && (
<div className="space-y-3">
{verifyResult.ok ? (
<div className="rounded-md border border-green-500/30 bg-green-500/10 p-4">
<div className="flex items-center gap-2 text-green-300">
<CheckCircle2 className="h-5 w-5" />
<span className="font-semibold">DNS korrekt Domain ist aktiv!</span>
</div>
<p className="mt-1 text-xs text-green-200/80">Caddy wurde neu geladen. Aufrufe werden jetzt weitergeleitet.</p>
<Button className="mt-3" size="sm" onClick={() => router.push(`/domains/${domainId}`)}>Zur Domain</Button>
</div>
) : (
<div className="rounded-md border border-amber-500/30 bg-amber-500/10 p-4">
<div className="flex items-center gap-2 text-amber-300">
<AlertTriangle className="h-5 w-5" />
<span className="font-semibold">DNS-Records noch nicht korrekt</span>
</div>
<p className="mt-2 text-xs text-amber-200/80">Fehlend:</p>
<ul className="ml-4 mt-1 list-disc text-xs text-amber-100/90">
{verifyResult.result.missing.map((m, i) => <li key={i}>{m}</li>)}
</ul>
<div className="mt-3 grid grid-cols-2 gap-3 text-xs">
<div>
<p className="text-muted-foreground">Aufgelöst (A):</p>
<p className="font-mono">{verifyResult.result.resolved.a.join(", ") || "—"}</p>
</div>
<div>
<p className="text-muted-foreground">Erwartet:</p>
<p className="font-mono">{verifyResult.result.expected.ipv4 || "(server-IP)"}</p>
</div>
</div>
</div>
)}
</div>
)}
</CardContent>
</Card>
)}
</div>
</div>
);
}
function StepIndicator({ step }: { step: 1 | 2 | 3 }) {
return (
<div className="flex items-center gap-2 text-xs">
{[1, 2, 3].map((n) => (
<Badge key={n} variant={n === step ? "default" : n < step ? "green" : "zinc"}>
{n < step ? <CheckCircle2 className="mr-1 h-3 w-3" /> : null}
Schritt {n}
</Badge>
))}
</div>
);
}
function DnsRecordsTable({ domain, ipv4, ipv6, includeWww }: { domain: string; ipv4?: string; ipv6?: string; includeWww: boolean }) {
const records = [
{ type: "A", name: domain, value: ipv4 || "<server-IP>" },
...(ipv6 ? [{ type: "AAAA", name: domain, value: ipv6 }] : []),
...(includeWww ? [{ type: "A", name: `www.${domain}`, value: ipv4 || "<server-IP>" }] : []),
];
return (
<div className="overflow-hidden rounded-md border">
<table className="w-full text-sm">
<thead className="border-b bg-zinc-900/40 text-xs uppercase tracking-wider text-muted-foreground">
<tr>
<th className="px-4 py-2 text-left font-medium">Typ</th>
<th className="px-4 py-2 text-left font-medium">Name</th>
<th className="px-4 py-2 text-left font-medium">Wert</th>
<th className="px-4 py-2"></th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-800/70">
{records.map((r, i) => (
<tr key={i}>
<td className="px-4 py-2"><Badge variant="zinc">{r.type}</Badge></td>
<td className="px-4 py-2 font-mono text-xs">{r.name}</td>
<td className="px-4 py-2 font-mono text-xs">{r.value}</td>
<td className="px-4 py-2 text-right">
<button
onClick={() => navigator.clipboard?.writeText(r.value)}
className="rounded p-1 text-zinc-500 hover:text-zinc-200"
title="Wert kopieren"
>
<Copy className="h-3 w-3" />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}

128
app/(app)/domains/page.tsx Normal file
View file

@ -0,0 +1,128 @@
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";
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,
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
FROM domains d
LEFT JOIN domain_groups g ON g.id = d.group_id
ORDER BY d.created_at DESC
`)
.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();
return (
<div>
<PageHeader
title="Domains"
description="Alle verwalteten Redirect-Domains"
actions={
<Button asChild>
<Link href="/domains/new">+ Domain hinzufügen</Link>
</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>
</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>;
}

140
app/(app)/groups/page.tsx Normal file
View file

@ -0,0 +1,140 @@
"use client";
import { useEffect, useState } from "react";
import { Loader2, Plus, Trash2 } from "lucide-react";
import { PageHeader } from "@/components/PageHeader";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
type Group = { id: number; name: string; target_url: string; redirect_code: number; domain_count: number };
export default function GroupsPage() {
const [groups, setGroups] = useState<Group[]>([]);
const [loading, setLoading] = useState(true);
const [open, setOpen] = useState(false);
const [name, setName] = useState("");
const [targetUrl, setTargetUrl] = useState("");
const [code, setCode] = useState<301 | 302>(301);
const [creating, setCreating] = useState(false);
const [error, setError] = useState("");
async function load() {
setLoading(true);
const r = await fetch("/api/groups");
const d = await r.json();
setGroups(d.groups || []);
setLoading(false);
}
useEffect(() => { load(); }, []);
async function handleCreate(e: React.FormEvent) {
e.preventDefault();
setCreating(true);
setError("");
try {
const res = await fetch("/api/groups", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: name.trim(), target_url: targetUrl.trim(), redirect_code: code }),
});
if (!res.ok) {
const d = await res.json();
setError(d.error || "Fehler");
return;
}
setOpen(false);
setName(""); setTargetUrl(""); setCode(301);
load();
} finally {
setCreating(false);
}
}
async function handleDelete(id: number) {
if (!confirm("Gruppe wirklich löschen?")) return;
const res = await fetch(`/api/groups/${id}`, { method: "DELETE" });
if (res.status === 409) {
alert("Gruppe wird noch von Domains verwendet.");
return;
}
load();
}
return (
<div>
<PageHeader
title="Domain-Gruppen"
description="Mehrere Domains zu einem Ziel zusammenfassen"
actions={
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button><Plus className="mr-1 h-3 w-3" />Neue Gruppe</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Neue Gruppe</DialogTitle>
<DialogDescription>Mehrere Domains können dasselbe Ziel teilen.</DialogDescription>
</DialogHeader>
<form onSubmit={handleCreate} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Name</Label>
<Input id="name" required value={name} onChange={(e) => setName(e.target.value)} placeholder="Marketing-Domains" />
</div>
<div className="space-y-2">
<Label htmlFor="target">Ziel-URL</Label>
<Input id="target" required type="url" value={targetUrl} onChange={(e) => setTargetUrl(e.target.value)} placeholder="https://www.firma.de" />
</div>
<div className="space-y-2">
<Label htmlFor="code">Status-Code</Label>
<select id="code" value={code} onChange={(e) => setCode(Number(e.target.value) as 301 | 302)} className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm">
<option value={301}>301 Permanent</option>
<option value={302}>302 Temporär</option>
</select>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<div className="flex justify-end">
<Button type="submit" disabled={creating}>
{creating ? <><Loader2 className="mr-2 h-3 w-3 animate-spin" />Anlegen...</> : "Anlegen"}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
}
/>
<div className="p-8">
{loading ? (
<div className="flex justify-center py-16"><Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /></div>
) : groups.length === 0 ? (
<Card><CardContent className="py-16 text-center text-sm text-muted-foreground">Noch keine Gruppen angelegt.</CardContent></Card>
) : (
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 lg:grid-cols-3">
{groups.map((g) => (
<Card key={g.id}>
<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>
</CardHeader>
<CardContent className="space-y-2 text-sm">
<p className="truncate text-xs text-muted-foreground">{g.target_url}</p>
<div className="flex items-center gap-2">
<Badge variant="zinc">{g.redirect_code}</Badge>
<Badge variant="blue">{g.domain_count} Domain{g.domain_count === 1 ? "" : "s"}</Badge>
</div>
</CardContent>
</Card>
))}
</div>
)}
</div>
</div>
);
}

25
app/(app)/layout.tsx Normal file
View file

@ -0,0 +1,25 @@
import { getServerSession } from "next-auth";
import { redirect } from "next/navigation";
import { authOptions } from "@/lib/auth";
import { isSetupComplete } from "@/lib/db";
import { Sidebar } from "@/components/Sidebar";
import { UpdateBanner } from "@/components/UpdateBanner";
export default async function AppLayout({ children }: { children: React.ReactNode }) {
if (!isSetupComplete()) redirect("/setup");
const session = await getServerSession(authOptions);
if (!session) redirect("/login");
return (
<div className="relative flex h-screen overflow-hidden bg-background">
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_top_right,rgba(45,212,191,0.12),transparent_40%),radial-gradient(circle_at_bottom_left,rgba(245,158,11,0.07),transparent_34%)]" />
<Sidebar user={{ email: session.user.email }} />
<div className="relative flex flex-1 flex-col overflow-hidden">
<UpdateBanner />
<main className="flex-1 overflow-auto">{children}</main>
</div>
</div>
);
}

View file

@ -0,0 +1,177 @@
"use client";
import { useEffect, useState } from "react";
import { Loader2, Plus, Copy, Trash2, KeyRound } from "lucide-react";
import { PageHeader } from "@/components/PageHeader";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
const SCOPES = ["read:domains", "write:domains", "read:analytics", "read:hits"] as const;
type Token = {
id: number;
name: string;
scopes: string;
created_at: number;
last_used_at: number | null;
revoked_at: number | null;
};
export default function ApiTokensPage() {
const [tokens, setTokens] = useState<Token[]>([]);
const [loading, setLoading] = useState(true);
const [open, setOpen] = useState(false);
const [name, setName] = useState("");
const [selectedScopes, setSelectedScopes] = useState<string[]>(["read:domains", "read:analytics"]);
const [creating, setCreating] = useState(false);
const [newToken, setNewToken] = useState<string | null>(null);
async function load() {
const r = await fetch("/api/tokens");
const d = await r.json();
setTokens(d.tokens || []);
setLoading(false);
}
useEffect(() => { load(); }, []);
async function create(e: React.FormEvent) {
e.preventDefault();
setCreating(true);
try {
const r = await fetch("/api/tokens", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: name.trim(), scopes: selectedScopes }),
});
const d = await r.json();
if (r.ok) {
setNewToken(d.token);
setName("");
setSelectedScopes(["read:domains", "read:analytics"]);
load();
}
} finally {
setCreating(false);
}
}
async function revoke(id: number) {
if (!confirm("Token wirklich widerrufen?")) return;
await fetch(`/api/tokens/${id}`, { method: "DELETE" });
load();
}
return (
<div>
<PageHeader
title="API-Tokens"
description="Tokens für externe Monitoring-Tools und Integrationen"
actions={
<Dialog open={open} onOpenChange={(v) => { setOpen(v); if (!v) setNewToken(null); }}>
<DialogTrigger asChild>
<Button><Plus className="mr-1 h-3 w-3" />Neuen Token</Button>
</DialogTrigger>
<DialogContent>
{newToken ? (
<div className="space-y-4">
<DialogHeader>
<DialogTitle>Token erstellt</DialogTitle>
<DialogDescription>Wird nur einmal angezeigt sicher kopieren.</DialogDescription>
</DialogHeader>
<div className="rounded-md border bg-zinc-950 p-3">
<code className="block break-all font-mono text-xs">{newToken}</code>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => navigator.clipboard?.writeText(newToken)}>
<Copy className="mr-2 h-3 w-3" />Kopieren
</Button>
<Button onClick={() => { setOpen(false); setNewToken(null); }}>Fertig</Button>
</div>
</div>
) : (
<form onSubmit={create} className="space-y-4">
<DialogHeader>
<DialogTitle>Neuen Token erstellen</DialogTitle>
</DialogHeader>
<div className="space-y-2">
<Label htmlFor="tname">Name</Label>
<Input id="tname" required value={name} onChange={(e) => setName(e.target.value)} placeholder="Uptime-Monitor" />
</div>
<div className="space-y-2">
<Label>Scopes</Label>
<div className="space-y-1">
{SCOPES.map((s) => (
<label key={s} className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={selectedScopes.includes(s)}
onChange={(e) => setSelectedScopes((cur) => e.target.checked ? [...cur, s] : cur.filter((x) => x !== s))}
/>
<code className="font-mono text-xs">{s}</code>
</label>
))}
</div>
</div>
<div className="flex justify-end">
<Button type="submit" disabled={creating || selectedScopes.length === 0}>
{creating ? <Loader2 className="mr-2 h-3 w-3 animate-spin" /> : null}Erstellen
</Button>
</div>
</form>
)}
</DialogContent>
</Dialog>
}
/>
<div className="p-8">
{loading ? (
<div className="flex justify-center py-16"><Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /></div>
) : tokens.length === 0 ? (
<Card><CardContent className="py-16 text-center text-sm text-muted-foreground">Keine Tokens.</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">Name</th>
<th className="px-6 py-3 text-left font-medium">Scopes</th>
<th className="px-6 py-3 text-left font-medium">Erstellt</th>
<th className="px-6 py-3 text-left font-medium">Zuletzt benutzt</th>
<th className="px-6 py-3 text-left font-medium">Status</th>
<th className="px-6 py-3"></th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-800/70">
{tokens.map((t) => {
const scopes = JSON.parse(t.scopes) as string[];
return (
<tr key={t.id} className="hover:bg-zinc-900/40">
<td className="px-6 py-3 font-medium"><KeyRound className="mr-2 inline h-3 w-3 text-cyan-400" />{t.name}</td>
<td className="px-6 py-3"><div className="flex flex-wrap gap-1">{scopes.map((s) => <Badge key={s} variant="zinc">{s}</Badge>)}</div></td>
<td className="px-6 py-3 text-muted-foreground">{new Date(t.created_at).toLocaleDateString("de-DE")}</td>
<td className="px-6 py-3 text-muted-foreground">{t.last_used_at ? new Date(t.last_used_at).toLocaleString("de-DE") : "—"}</td>
<td className="px-6 py-3">{t.revoked_at ? <Badge variant="destructive">widerrufen</Badge> : <Badge variant="green">aktiv</Badge>}</td>
<td className="px-6 py-3 text-right">
{!t.revoked_at && (
<button onClick={() => revoke(t.id)} className="rounded p-1 text-zinc-500 hover:text-destructive" title="Widerrufen">
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</CardContent>
</Card>
)}
</div>
</div>
);
}

157
app/(app)/settings/page.tsx Normal file
View file

@ -0,0 +1,157 @@
"use client";
import { useEffect, useState } from "react";
import { Loader2, RefreshCcw, ArrowUpCircle } from "lucide-react";
import { PageHeader } from "@/components/PageHeader";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
type Settings = {
base_domain: string | null;
admin_email: string | null;
update_auto: string | null;
update_include_prereleases: string | null;
};
type UpdateStatus = {
current: string;
latest: string | null;
update_available: boolean;
release_url?: string;
last_check?: number;
auto_update: boolean;
};
export default function SettingsPage() {
const [settings, setSettings] = useState<Settings | null>(null);
const [status, setStatus] = useState<UpdateStatus | null>(null);
const [saving, setSaving] = useState(false);
const [checking, setChecking] = useState(false);
const [applying, setApplying] = useState(false);
const [msg, setMsg] = useState("");
async function load() {
const [s, u] = await Promise.all([
fetch("/api/settings").then((r) => r.json()),
fetch("/api/update/check").then((r) => r.json()),
]);
setSettings(s);
setStatus(u);
}
useEffect(() => { load(); }, []);
async function save(patch: Partial<Settings>) {
setSaving(true);
await fetch("/api/settings", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(patch),
});
setSettings((s) => s ? { ...s, ...patch } : s);
setSaving(false);
}
async function check() {
setChecking(true);
const r = await fetch("/api/update/check?force=1");
setStatus(await r.json());
setChecking(false);
}
async function applyNow() {
if (!confirm(`Update auf ${status?.latest} jetzt installieren?\n\nDer Server wird neu gestartet (kurze Downtime der Admin-UI). Redirects bleiben über Caddy aktiv.`)) return;
setApplying(true);
setMsg("");
try {
const r = await fetch("/api/update/apply", { method: "POST" });
const d = await r.json();
setMsg(d.ok ? `Update erfolgreich: ${d.from}${d.to}` : `Fehler: ${d.error}`);
load();
} finally {
setApplying(false);
}
}
if (!settings || !status) {
return <div className="flex justify-center p-16"><Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /></div>;
}
return (
<div>
<PageHeader title="Einstellungen" description="Server-Konfiguration und Updates" />
<div className="space-y-4 p-8">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
Updates {status.update_available && <Badge variant="green">Verfügbar</Badge>}
</CardTitle>
<CardDescription>
Aktuelle Version <span className="font-mono">{status.current}</span>
{status.latest && <> Neueste <span className="font-mono">{status.latest}</span></>}
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex flex-wrap items-center gap-2">
<Button onClick={check} variant="outline" size="sm" disabled={checking}>
{checking ? <Loader2 className="mr-2 h-3 w-3 animate-spin" /> : <RefreshCcw className="mr-2 h-3 w-3" />}
Jetzt prüfen
</Button>
{status.update_available && (
<Button onClick={applyNow} size="sm" disabled={applying}>
{applying ? <Loader2 className="mr-2 h-3 w-3 animate-spin" /> : <ArrowUpCircle className="mr-2 h-3 w-3" />}
Update {status.latest} installieren
</Button>
)}
{status.release_url && <a href={status.release_url} target="_blank" rel="noreferrer" className="text-xs text-cyan-400 hover:underline">Release-Notes </a>}
</div>
<div className="space-y-2 pt-2">
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={settings.update_auto === "true"} onChange={(e) => save({ update_auto: e.target.checked ? "true" : "false" })} disabled={saving} />
Auto-Update aktivieren <span className="text-xs text-muted-foreground">(Updates automatisch beim Check installieren)</span>
</label>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={settings.update_include_prereleases === "true"} onChange={(e) => save({ update_include_prereleases: e.target.checked ? "true" : "false" })} disabled={saving} />
Pre-Releases einbeziehen
</label>
</div>
{msg && <p className="text-xs text-muted-foreground">{msg}</p>}
{status.last_check && <p className="text-xs text-muted-foreground">Letzte Prüfung: {new Date(status.last_check).toLocaleString("de-DE")}</p>}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Server</CardTitle>
<CardDescription>Allgemeine Konfiguration</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="baseDomain">Admin-Domain</Label>
<Input
id="baseDomain"
placeholder="admin.beispiel.de"
defaultValue={settings.base_domain ?? ""}
onBlur={(e) => save({ base_domain: e.target.value.trim() })}
/>
<p className="text-[11px] text-muted-foreground">Optional. Bestimmt unter welcher Domain die Admin-UI erreichbar ist.</p>
</div>
<div className="space-y-2">
<Label htmlFor="adminEmail">Admin-E-Mail (Let&apos;s Encrypt)</Label>
<Input
id="adminEmail"
type="email"
placeholder="admin@beispiel.de"
defaultValue={settings.admin_email ?? ""}
onBlur={(e) => save({ admin_email: e.target.value.trim() })}
/>
<p className="text-[11px] text-muted-foreground">Wird von Caddy für ACME/Let&apos;s Encrypt benötigt.</p>
</div>
</CardContent>
</Card>
</div>
</div>
);
}

7
app/(auth)/layout.tsx Normal file
View file

@ -0,0 +1,7 @@
import { redirect } from "next/navigation";
import { isSetupComplete } from "@/lib/db";
export default function AuthLayout({ children }: { children: React.ReactNode }) {
if (!isSetupComplete()) redirect("/setup");
return <>{children}</>;
}

68
app/(auth)/login/page.tsx Normal file
View file

@ -0,0 +1,68 @@
"use client";
import { useState } from "react";
import { signIn } from "next-auth/react";
import { useRouter } from "next/navigation";
import { Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Logo } from "@/components/Logo";
export default function LoginPage() {
const router = useRouter();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!email.trim() || !password) return;
setLoading(true);
setError("");
try {
const result = await signIn("credentials", { email: email.trim(), password, redirect: false });
if (result?.error) {
setError("Ungültige E-Mail oder falsches Passwort.");
} else {
router.push("/dashboard");
router.refresh();
}
} catch {
setError("Verbindungsfehler. Bitte erneut versuchen.");
} finally {
setLoading(false);
}
}
return (
<div className="flex min-h-screen items-center justify-center px-4">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<div className="mx-auto mb-4">
<Logo size={48} />
</div>
<CardTitle className="text-2xl">CoreX NexRedirect</CardTitle>
<CardDescription>Melde dich mit deinem Admin-Account an</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">E-Mail</Label>
<Input id="email" type="email" required value={email} onChange={(e) => setEmail(e.target.value)} disabled={loading} autoComplete="email" />
</div>
<div className="space-y-2">
<Label htmlFor="password">Passwort</Label>
<Input id="password" type="password" required value={password} onChange={(e) => setPassword(e.target.value)} disabled={loading} autoComplete="current-password" />
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<Button type="submit" className="w-full" disabled={loading || !email.trim() || !password}>
{loading ? <><Loader2 className="mr-2 h-4 w-4 animate-spin" />Anmelden...</> : "Anmelden"}
</Button>
</form>
</CardContent>
</Card>
</div>
);
}

107
app/(setup)/setup/page.tsx Normal file
View file

@ -0,0 +1,107 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { Loader2, ShieldCheck } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Logo } from "@/components/Logo";
export default function SetupPage() {
const router = useRouter();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [password2, setPassword2] = useState("");
const [baseDomain, setBaseDomain] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [checked, setChecked] = useState(false);
useEffect(() => {
fetch("/api/setup")
.then((r) => r.json())
.then((d) => {
if (d.setup_complete) router.replace("/login");
else setChecked(true);
});
}, [router]);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (password !== password2) {
setError("Passwörter stimmen nicht überein.");
return;
}
if (password.length < 8) {
setError("Passwort muss mindestens 8 Zeichen lang sein.");
return;
}
setLoading(true);
setError("");
try {
const res = await fetch("/api/setup", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: email.trim(), password, baseDomain: baseDomain.trim() || undefined }),
});
if (!res.ok) {
const d = await res.json().catch(() => ({}));
setError(d.error || "Setup fehlgeschlagen.");
return;
}
router.replace("/login");
} catch {
setError("Verbindungsfehler.");
} finally {
setLoading(false);
}
}
if (!checked) {
return (
<div className="flex min-h-screen items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div className="flex min-h-screen items-center justify-center px-4">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<div className="mx-auto mb-4">
<Logo size={48} />
</div>
<CardTitle className="text-2xl">Erstes Setup</CardTitle>
<CardDescription>Erstelle deinen Admin-Account für CoreX NexRedirect</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Admin-E-Mail</Label>
<Input id="email" type="email" required value={email} onChange={(e) => setEmail(e.target.value)} disabled={loading} placeholder="admin@beispiel.de" autoComplete="email" />
</div>
<div className="space-y-2">
<Label htmlFor="password">Passwort</Label>
<Input id="password" type="password" required value={password} onChange={(e) => setPassword(e.target.value)} disabled={loading} autoComplete="new-password" />
</div>
<div className="space-y-2">
<Label htmlFor="password2">Passwort wiederholen</Label>
<Input id="password2" type="password" required value={password2} onChange={(e) => setPassword2(e.target.value)} disabled={loading} autoComplete="new-password" />
</div>
<div className="space-y-2">
<Label htmlFor="baseDomain">Admin-Domain <span className="text-muted-foreground">(optional)</span></Label>
<Input id="baseDomain" type="text" value={baseDomain} onChange={(e) => setBaseDomain(e.target.value)} disabled={loading} placeholder="admin.beispiel.de" />
<p className="text-[11px] text-muted-foreground">Lass leer um die Server-IP zu verwenden.</p>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<Button type="submit" className="w-full" disabled={loading}>
{loading ? <><Loader2 className="mr-2 h-4 w-4 animate-spin" />Erstelle...</> : <><ShieldCheck className="mr-2 h-4 w-4" />Account erstellen</>}
</Button>
</form>
</CardContent>
</Card>
</div>
);
}

View file

@ -0,0 +1,49 @@
import { NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { getDb } from "@/lib/db";
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 domainId = url.searchParams.get("domain_id");
const since = Date.now() - days * 24 * 60 * 60 * 1000;
const db = getDb();
const where = domainId ? "ts > ? AND domain_id = ?" : "ts > ?";
const args: unknown[] = domainId ? [since, Number(domainId)] : [since];
const daily = db.prepare(`
SELECT strftime('%Y-%m-%d', ts/1000, 'unixepoch') AS day, COUNT(*) AS hits
FROM hits WHERE ${where}
GROUP BY day ORDER BY day
`).all(...args);
const top = db.prepare(`
SELECT d.id, d.domain, COUNT(h.id) AS hits
FROM hits h JOIN domains d ON d.id = h.domain_id
WHERE h.ts > ?
GROUP BY d.id, d.domain
ORDER BY hits DESC LIMIT 20
`).all(since);
const byCountry = db.prepare(`
SELECT COALESCE(country,'??') AS country, COUNT(*) AS hits
FROM hits WHERE ${where}
GROUP BY country ORDER BY hits DESC LIMIT 20
`).all(...args);
const dead = db.prepare(`
SELECT d.id, d.domain, d.status, d.target_url, d.created_at,
(SELECT MAX(ts) FROM hits h WHERE h.domain_id = d.id) AS last_hit
FROM domains d
WHERE d.status = 'active'
AND NOT EXISTS (SELECT 1 FROM hits h WHERE h.domain_id = d.id AND h.ts > ?)
ORDER BY d.created_at
`).all(Date.now() - 90 * 24 * 60 * 60 * 1000);
return NextResponse.json({ daily, top, byCountry, dead });
}

View file

@ -0,0 +1,5 @@
import NextAuth from "next-auth";
import { authOptions } from "@/lib/auth";
const handler = NextAuth(authOptions);
export { handler as GET, handler as POST };

View file

@ -0,0 +1,17 @@
import { NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { reloadCaddy, buildCaddyfile } from "@/lib/caddy";
export async function POST() {
const session = await getServerSession(authOptions);
if (!session) return NextResponse.json({ error: "unauthorized" }, { status: 401 });
const result = await reloadCaddy();
return NextResponse.json(result);
}
export async function GET() {
const session = await getServerSession(authOptions);
if (!session) return NextResponse.json({ error: "unauthorized" }, { status: 401 });
return new NextResponse(buildCaddyfile(), { headers: { "Content-Type": "text/plain" } });
}

View file

@ -0,0 +1,79 @@
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 { reloadCaddy } from "@/lib/caddy";
import { invalidateRedirectCache } from "@/lib/redirect-resolver";
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(),
});
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 });
return NextResponse.json({ domain: row });
}
export async function PATCH(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 body = await req.json().catch(() => null);
const parsed = updateSchema.safeParse(body);
if (!parsed.success) return NextResponse.json({ error: "invalid", details: parsed.error.flatten() }, { status: 400 });
const db = getDb();
const existing = db.prepare("SELECT * FROM domains WHERE id = ?").get(Number(id)) as DomainRow | undefined;
if (!existing) return NextResponse.json({ error: "not_found" }, { status: 404 });
const fields: string[] = [];
const values: unknown[] = [];
for (const [key, val] of Object.entries(parsed.data)) {
if (val === undefined) continue;
if (key === "preserve_path" || key === "include_www") {
fields.push(`${key} = ?`);
values.push(val ? 1 : 0);
} else {
fields.push(`${key} = ?`);
values.push(val);
}
}
if (fields.length > 0) {
values.push(Number(id));
db.prepare(`UPDATE domains SET ${fields.join(", ")} WHERE id = ?`).run(...values);
}
if (existing.status === "active") {
invalidateRedirectCache();
reloadCaddy().catch(() => {});
}
const row = db.prepare("SELECT * FROM domains WHERE id = ?").get(Number(id)) as DomainRow;
return NextResponse.json({ domain: row });
}
export async function DELETE(_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 db = getDb();
const row = db.prepare("SELECT * FROM domains WHERE id = ?").get(Number(id)) as DomainRow | undefined;
if (!row) return NextResponse.json({ error: "not_found" }, { status: 404 });
db.prepare("DELETE FROM domains WHERE id = ?").run(Number(id));
invalidateRedirectCache();
if (row.status === "active") reloadCaddy().catch(() => {});
return NextResponse.json({ ok: true });
}

View file

@ -0,0 +1,29 @@
import { NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { getDb, type DomainRow } from "@/lib/db";
import { checkDomainDns } from "@/lib/dns";
import { reloadCaddy } from "@/lib/caddy";
import { invalidateRedirectCache } from "@/lib/redirect-resolver";
export async function POST(_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 db = getDb();
const row = db.prepare("SELECT * FROM domains WHERE id = ?").get(Number(id)) as DomainRow | undefined;
if (!row) return NextResponse.json({ error: "not_found" }, { status: 404 });
const result = await checkDomainDns(row.domain, !!row.include_www);
if (result.ok) {
db.prepare("UPDATE domains SET status = 'active', verified_at = ? WHERE id = ?").run(Date.now(), row.id);
invalidateRedirectCache();
const reload = await reloadCaddy();
return NextResponse.json({ ok: true, result, caddy_reloaded: reload.ok, caddy_error: reload.error });
}
db.prepare("UPDATE domains SET status = 'pending' WHERE id = ?").run(row.id);
return NextResponse.json({ ok: false, result });
}

68
app/api/domains/route.ts Normal file
View file

@ -0,0 +1,68 @@
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 { isValidDomain } from "@/lib/dns";
export async function GET() {
const session = await getServerSession(authOptions);
if (!session) return NextResponse.json({ error: "unauthorized" }, { status: 401 });
const rows = getDb()
.prepare(`
SELECT d.*, 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
FROM domains d
LEFT JOIN domain_groups g ON g.id = d.group_id
ORDER BY d.created_at DESC
`)
.all();
return NextResponse.json({ domains: rows });
}
const createSchema = z.object({
domain: z.string().min(3).transform((s) => s.toLowerCase().trim()),
target_url: z.string().url().optional(),
group_id: z.number().int().optional(),
redirect_code: z.union([z.literal(301), z.literal(302), z.literal(307), z.literal(308)]).default(301),
preserve_path: z.boolean().default(true),
include_www: z.boolean().default(true),
});
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 = createSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: "invalid", details: parsed.error.flatten() }, { status: 400 });
}
const { domain, target_url, group_id, redirect_code, preserve_path, include_www } = parsed.data;
if (!isValidDomain(domain)) return NextResponse.json({ error: "invalid_domain" }, { status: 400 });
if (!target_url && !group_id) return NextResponse.json({ error: "target_required" }, { status: 400 });
const db = getDb();
const existing = db.prepare("SELECT id FROM domains WHERE domain = ?").get(domain) as { id: number } | undefined;
if (existing) return NextResponse.json({ error: "domain_exists" }, { status: 409 });
const result = db
.prepare(`INSERT INTO domains (domain, status, target_url, group_id, redirect_code, preserve_path, include_www, created_by, created_at)
VALUES (?, 'pending', ?, ?, ?, ?, ?, ?, ?)`)
.run(
domain,
target_url ?? null,
group_id ?? null,
redirect_code,
preserve_path ? 1 : 0,
include_www ? 1 : 0,
Number(session.user.id),
Date.now()
);
const row = db.prepare("SELECT * FROM domains WHERE id = ?").get(result.lastInsertRowid) as DomainRow;
return NextResponse.json({ domain: row }, { status: 201 });
}

View file

@ -0,0 +1,53 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { getDb, type DomainGroupRow } from "@/lib/db";
import { reloadCaddy } from "@/lib/caddy";
import { invalidateRedirectCache } from "@/lib/redirect-resolver";
const updateSchema = z.object({
name: z.string().min(1).max(100).optional(),
target_url: z.string().url().optional(),
redirect_code: z.union([z.literal(301), z.literal(302), z.literal(307), z.literal(308)]).optional(),
});
export async function PATCH(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 body = await req.json().catch(() => null);
const parsed = updateSchema.safeParse(body);
if (!parsed.success) return NextResponse.json({ error: "invalid" }, { status: 400 });
const fields: string[] = [];
const values: unknown[] = [];
for (const [k, v] of Object.entries(parsed.data)) {
if (v === undefined) continue;
fields.push(`${k} = ?`);
values.push(v);
}
if (fields.length > 0) {
values.push(Number(id));
getDb().prepare(`UPDATE domain_groups SET ${fields.join(", ")} WHERE id = ?`).run(...values);
}
invalidateRedirectCache();
reloadCaddy().catch(() => {});
const group = getDb().prepare("SELECT * FROM domain_groups WHERE id = ?").get(Number(id)) as DomainGroupRow | undefined;
if (!group) return NextResponse.json({ error: "not_found" }, { status: 404 });
return NextResponse.json({ group });
}
export async function DELETE(_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 db = getDb();
const used = (db.prepare("SELECT COUNT(*) AS n FROM domains WHERE group_id = ?").get(Number(id)) as { n: number }).n;
if (used > 0) return NextResponse.json({ error: "group_in_use", domains: used }, { status: 409 });
db.prepare("DELETE FROM domain_groups WHERE id = ?").run(Number(id));
return NextResponse.json({ ok: true });
}

37
app/api/groups/route.ts Normal file
View file

@ -0,0 +1,37 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { getDb, type DomainGroupRow } from "@/lib/db";
export async function GET() {
const session = await getServerSession(authOptions);
if (!session) return NextResponse.json({ error: "unauthorized" }, { status: 401 });
const groups = getDb().prepare(`
SELECT g.*, (SELECT COUNT(*) FROM domains d WHERE d.group_id = g.id) AS domain_count
FROM domain_groups g ORDER BY g.created_at DESC
`).all();
return NextResponse.json({ groups });
}
const schema = z.object({
name: z.string().min(1).max(100),
target_url: z.string().url(),
redirect_code: z.union([z.literal(301), z.literal(302), z.literal(307), z.literal(308)]).default(301),
});
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", details: parsed.error.flatten() }, { status: 400 });
const { name, target_url, redirect_code } = parsed.data;
const result = getDb().prepare(
"INSERT INTO domain_groups (name, target_url, redirect_code, created_by, created_at) VALUES (?, ?, ?, ?, ?)"
).run(name, target_url, redirect_code, Number(session.user.id), Date.now());
const group = getDb().prepare("SELECT * FROM domain_groups WHERE id = ?").get(result.lastInsertRowid) as DomainGroupRow;
return NextResponse.json({ group }, { status: 201 });
}

25
app/api/settings/route.ts Normal file
View file

@ -0,0 +1,25 @@
import { NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { getSetting, setSetting } from "@/lib/db";
const PUBLIC_KEYS = ["base_domain", "admin_email", "update_auto", "update_include_prereleases"];
export async function GET() {
const session = await getServerSession(authOptions);
if (!session) return NextResponse.json({ error: "unauthorized" }, { status: 401 });
const out: Record<string, string | null> = {};
for (const k of PUBLIC_KEYS) out[k] = getSetting(k);
return NextResponse.json(out);
}
export async function PATCH(req: Request) {
const session = await getServerSession(authOptions);
if (!session) return NextResponse.json({ error: "unauthorized" }, { status: 401 });
const body = await req.json().catch(() => ({}));
for (const [k, v] of Object.entries(body)) {
if (!PUBLIC_KEYS.includes(k)) continue;
setSetting(k, String(v));
}
return NextResponse.json({ ok: true });
}

View file

@ -0,0 +1,10 @@
import { NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { getServerIps } from "@/lib/dns";
export async function GET() {
const session = await getServerSession(authOptions);
if (!session) return NextResponse.json({ error: "unauthorized" }, { status: 401 });
return NextResponse.json(await getServerIps());
}

42
app/api/setup/route.ts Normal file
View file

@ -0,0 +1,42 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import bcrypt from "bcryptjs";
import { getDb, isSetupComplete, setSetting, getDailySalt } from "@/lib/db";
const schema = z.object({
email: z.string().email(),
password: z.string().min(8),
baseDomain: z.string().optional(),
});
export async function POST(req: Request) {
if (isSetupComplete()) {
return NextResponse.json({ error: "Setup already complete" }, { status: 403 });
}
const body = await req.json().catch(() => null);
const parsed = schema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: "Invalid input", details: parsed.error.flatten() }, { status: 400 });
}
const { email, password, baseDomain } = parsed.data;
const password_hash = await bcrypt.hash(password, 12);
const now = Date.now();
const db = getDb();
db.transaction(() => {
db.prepare("INSERT INTO users (email, password_hash, role, created_at) VALUES (?, ?, 'admin', ?)")
.run(email.toLowerCase().trim(), password_hash, now);
if (baseDomain) setSetting("base_domain", baseDomain.trim());
setSetting("setup_complete", "true");
})();
getDailySalt();
return NextResponse.json({ ok: true });
}
export async function GET() {
return NextResponse.json({ setup_complete: isSetupComplete() });
}

View file

@ -0,0 +1,12 @@
import { NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { getDb } from "@/lib/db";
export async function DELETE(_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;
getDb().prepare("UPDATE api_tokens SET revoked_at = ? WHERE id = ?").run(Date.now(), Number(id));
return NextResponse.json({ ok: true });
}

36
app/api/tokens/route.ts Normal file
View file

@ -0,0 +1,36 @@
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 { ALL_SCOPES, generateToken, type Scope } from "@/lib/api-auth";
export async function GET() {
const session = await getServerSession(authOptions);
if (!session) return NextResponse.json({ error: "unauthorized" }, { status: 401 });
const tokens = getDb()
.prepare("SELECT id, name, scopes, created_at, last_used_at, revoked_at FROM api_tokens ORDER BY created_at DESC")
.all();
return NextResponse.json({ tokens });
}
const schema = z.object({
name: z.string().min(1).max(100),
scopes: z.array(z.enum(ALL_SCOPES as [Scope, ...Scope[]])).min(1),
});
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", details: parsed.error.flatten() }, { status: 400 });
const { plaintext, hash } = generateToken();
const result = getDb()
.prepare("INSERT INTO api_tokens (name, token_hash, scopes, created_by, created_at) VALUES (?, ?, ?, ?, ?)")
.run(parsed.data.name, hash, JSON.stringify(parsed.data.scopes), Number(session.user.id), Date.now());
return NextResponse.json({ id: result.lastInsertRowid, token: plaintext, name: parsed.data.name, scopes: parsed.data.scopes }, { status: 201 });
}

View file

@ -0,0 +1,13 @@
import { NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { applyUpdate } from "@/lib/updater";
export async function POST() {
const session = await getServerSession(authOptions);
if (!session || session.user.role !== "admin") {
return NextResponse.json({ error: "unauthorized" }, { status: 401 });
}
const result = await applyUpdate();
return NextResponse.json(result);
}

View file

@ -0,0 +1,21 @@
import { NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { checkForUpdate, getUpdateStatus } from "@/lib/updater";
export async function GET(req: Request) {
const session = await getServerSession(authOptions);
if (!session) return NextResponse.json({ error: "unauthorized" }, { status: 401 });
const force = new URL(req.url).searchParams.get("force") === "1";
const status = force ? await checkForUpdate() : getUpdateStatus();
if (!force && (!status.last_check || Date.now() - status.last_check > 60 * 60 * 1000)) {
return NextResponse.json(await checkForUpdate());
}
return NextResponse.json(status);
}
export async function POST() {
const session = await getServerSession(authOptions);
if (!session) return NextResponse.json({ error: "unauthorized" }, { status: 401 });
return NextResponse.json(await checkForUpdate());
}

View file

@ -0,0 +1,29 @@
import { NextResponse } from "next/server";
import { requireScope } from "@/lib/api-auth";
import { getDb } from "@/lib/db";
export async function GET(req: Request) {
const auth = requireScope(req, "read:analytics");
if (auth instanceof NextResponse) return auth;
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 db = getDb();
const total = (db.prepare("SELECT COUNT(*) AS n FROM hits WHERE ts > ?").get(since) as { n: number }).n;
const top = db.prepare(`
SELECT d.id, d.domain, COUNT(h.id) AS hits
FROM hits h JOIN domains d ON d.id = h.domain_id
WHERE h.ts > ? GROUP BY d.id, d.domain ORDER BY hits DESC LIMIT 50
`).all(since);
const byCountry = db.prepare(`
SELECT COALESCE(country,'??') AS country, COUNT(*) AS hits
FROM hits WHERE ts > ? GROUP BY country ORDER BY hits DESC
`).all(since);
const daily = db.prepare(`
SELECT strftime('%Y-%m-%d', ts/1000, 'unixepoch') AS day, COUNT(*) AS hits
FROM hits WHERE ts > ? GROUP BY day ORDER BY day
`).all(since);
return NextResponse.json({ days, total, daily, top, by_country: byCountry });
}

View file

@ -0,0 +1,27 @@
import { NextResponse } from "next/server";
import { requireScope } from "@/lib/api-auth";
import { getDb, type DomainRow } from "@/lib/db";
import { reloadCaddy } from "@/lib/caddy";
import { invalidateRedirectCache } from "@/lib/redirect-resolver";
export async function GET(req: Request, { params }: { params: Promise<{ id: string }> }) {
const auth = requireScope(req, "read:domains");
if (auth instanceof NextResponse) return auth;
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 });
return NextResponse.json({ domain: row });
}
export async function DELETE(req: Request, { params }: { params: Promise<{ id: string }> }) {
const auth = requireScope(req, "write:domains");
if (auth instanceof NextResponse) return auth;
const { id } = await params;
const db = getDb();
const row = db.prepare("SELECT * FROM domains WHERE id = ?").get(Number(id)) as DomainRow | undefined;
if (!row) return NextResponse.json({ error: "not_found" }, { status: 404 });
db.prepare("DELETE FROM domains WHERE id = ?").run(Number(id));
invalidateRedirectCache();
if (row.status === "active") reloadCaddy().catch(() => {});
return NextResponse.json({ ok: true });
}

View file

@ -0,0 +1,27 @@
import { NextResponse } from "next/server";
import { requireScope } from "@/lib/api-auth";
import { getDb } from "@/lib/db";
export async function GET(req: Request, { params }: { params: Promise<{ id: string }> }) {
const auth = requireScope(req, "read:analytics");
if (auth instanceof NextResponse) return auth;
const { id } = await params;
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 db = getDb();
const total = (db.prepare("SELECT COUNT(*) AS n FROM hits WHERE domain_id = ? AND ts > ?").get(Number(id), since) as { n: number }).n;
const daily = db.prepare(`
SELECT strftime('%Y-%m-%d', ts/1000, 'unixepoch') AS day, COUNT(*) AS hits
FROM hits WHERE domain_id = ? AND ts > ?
GROUP BY day ORDER BY day
`).all(Number(id), since);
const byCountry = db.prepare(`
SELECT COALESCE(country,'??') AS country, COUNT(*) AS hits
FROM hits WHERE domain_id = ? AND ts > ?
GROUP BY country ORDER BY hits DESC
`).all(Number(id), since);
return NextResponse.json({ domain_id: Number(id), days, total, daily, by_country: byCountry });
}

View file

@ -0,0 +1,59 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { requireScope } from "@/lib/api-auth";
import { getDb, type DomainRow } from "@/lib/db";
import { isValidDomain, getServerIps } from "@/lib/dns";
export async function GET(req: Request) {
const auth = requireScope(req, "read:domains");
if (auth instanceof NextResponse) return auth;
const rows = getDb().prepare(`
SELECT d.*, 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
FROM domains d LEFT JOIN domain_groups g ON g.id = d.group_id
ORDER BY d.created_at DESC
`).all();
return NextResponse.json({ domains: rows });
}
const schema = z.object({
domain: z.string().min(3).transform((s) => s.toLowerCase().trim()),
target_url: z.string().url().optional(),
group_id: z.number().int().optional(),
redirect_code: z.union([z.literal(301), z.literal(302), z.literal(307), z.literal(308)]).default(301),
preserve_path: z.boolean().default(true),
include_www: z.boolean().default(true),
});
export async function POST(req: Request) {
const auth = requireScope(req, "write:domains");
if (auth instanceof NextResponse) return auth;
const body = await req.json().catch(() => null);
const parsed = schema.safeParse(body);
if (!parsed.success) return NextResponse.json({ error: "invalid", details: parsed.error.flatten() }, { status: 400 });
const { domain, target_url, group_id, redirect_code, preserve_path, include_www } = parsed.data;
if (!isValidDomain(domain)) return NextResponse.json({ error: "invalid_domain" }, { status: 400 });
if (!target_url && !group_id) return NextResponse.json({ error: "target_required" }, { status: 400 });
const db = getDb();
const exists = db.prepare("SELECT id FROM domains WHERE domain = ?").get(domain);
if (exists) return NextResponse.json({ error: "domain_exists" }, { status: 409 });
const result = db.prepare(`INSERT INTO domains (domain, status, target_url, group_id, redirect_code, preserve_path, include_www, created_at)
VALUES (?, 'pending', ?, ?, ?, ?, ?, ?)`).run(
domain, target_url ?? null, group_id ?? null, redirect_code, preserve_path ? 1 : 0, include_www ? 1 : 0, Date.now()
);
const row = db.prepare("SELECT * FROM domains WHERE id = ?").get(result.lastInsertRowid) as DomainRow;
const ips = await getServerIps();
return NextResponse.json({
domain: row,
dns_records: [
{ type: "A", name: domain, value: ips.ipv4 ?? null },
...(ips.ipv6 ? [{ type: "AAAA", name: domain, value: ips.ipv6 }] : []),
...(include_www ? [{ type: "A", name: `www.${domain}`, value: ips.ipv4 ?? null }] : []),
],
}, { status: 201 });
}

View file

@ -0,0 +1,5 @@
import { NextResponse } from "next/server";
export async function GET() {
return NextResponse.json({ ok: true, ts: Date.now() });
}

18
app/api/v1/hits/route.ts Normal file
View file

@ -0,0 +1,18 @@
import { NextResponse } from "next/server";
import { requireScope } from "@/lib/api-auth";
import { getDb } from "@/lib/db";
export async function GET(req: Request) {
const auth = requireScope(req, "read:hits");
if (auth instanceof NextResponse) return auth;
const url = new URL(req.url);
const limit = Math.min(1000, Math.max(1, Number(url.searchParams.get("limit") || 100)));
const domainId = url.searchParams.get("domain_id");
const db = getDb();
const rows = domainId
? db.prepare("SELECT id, domain_id, ts, ip_hash, country, user_agent, referer, path FROM hits WHERE domain_id = ? ORDER BY ts DESC LIMIT ?").all(Number(domainId), limit)
: db.prepare("SELECT id, domain_id, ts, ip_hash, country, user_agent, referer, path FROM hits ORDER BY ts DESC LIMIT ?").all(limit);
return NextResponse.json({ hits: rows });
}

View file

@ -0,0 +1,6 @@
import { NextResponse } from "next/server";
import { getUpdateStatus } from "@/lib/updater";
export async function GET() {
return NextResponse.json(getUpdateStatus());
}

56
app/globals.css Normal file
View file

@ -0,0 +1,56 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 222 24% 5%;
--foreground: 220 15% 96%;
--card: 222 22% 8%;
--card-foreground: 220 15% 96%;
--popover: 222 22% 8%;
--popover-foreground: 220 15% 96%;
--primary: 187 96% 43%;
--primary-foreground: 220 20% 10%;
--secondary: 222 18% 12%;
--secondary-foreground: 220 15% 96%;
--muted: 222 18% 12%;
--muted-foreground: 220 9% 58%;
--accent: 168 72% 16%;
--accent-foreground: 168 30% 92%;
--destructive: 0 72% 48%;
--destructive-foreground: 0 0% 100%;
--border: 222 16% 18%;
--input: 222 16% 18%;
--ring: 187 96% 43%;
--radius: 0.75rem;
}
* { @apply border-border; }
html { overscroll-behavior: none; }
body {
@apply bg-background text-foreground font-mono antialiased;
background-image:
radial-gradient(circle at 12% -8%, rgba(45, 212, 191, 0.1), transparent 42%),
radial-gradient(circle at 100% 8%, rgba(245, 158, 11, 0.08), transparent 38%),
linear-gradient(180deg, rgba(9, 13, 20, 0.85) 0%, rgba(7, 10, 16, 0.98) 100%);
min-height: 100vh;
overflow-x: hidden;
}
}
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: hsl(var(--background)); }
::-webkit-scrollbar-thumb { background: rgba(45, 212, 191, 0.3); border-radius: 999px; }
::-webkit-scrollbar-thumb:hover { background: rgba(45, 212, 191, 0.5); }
.cx-logo-text {
font-family: Georgia, "Times New Roman", serif;
}
.cx-gradient {
background: linear-gradient(135deg, #22d3ee, #34d399);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}

18
app/layout.tsx Normal file
View file

@ -0,0 +1,18 @@
import type { Metadata } from "next";
import "./globals.css";
import { Providers } from "./providers";
export const metadata: Metadata = {
title: "CoreX NexRedirect",
description: "Self-hosted Domain-Redirect-Server mit Analytics",
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="de" className="dark">
<body>
<Providers>{children}</Providers>
</body>
</html>
);
}

11
app/page.tsx Normal file
View file

@ -0,0 +1,11 @@
import { redirect } from "next/navigation";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { isSetupComplete } from "@/lib/db";
export default async function RootPage() {
if (!isSetupComplete()) redirect("/setup");
const session = await getServerSession(authOptions);
if (!session) redirect("/login");
redirect("/dashboard");
}

6
app/providers.tsx Normal file
View file

@ -0,0 +1,6 @@
"use client";
import { SessionProvider } from "next-auth/react";
export function Providers({ children }: { children: React.ReactNode }) {
return <SessionProvider>{children}</SessionProvider>;
}

14
components/Logo.tsx Normal file
View file

@ -0,0 +1,14 @@
export function Logo({ size = 32 }: { size?: number }) {
const fontSize = Math.round(size * 0.46);
return (
<div
className="flex items-center justify-center rounded-lg border border-cyan-300/20 bg-zinc-900 shadow-[0_4px_12px_rgba(34,211,238,0.2)]"
style={{ width: size, height: size, fontFamily: "Georgia,'Times New Roman',serif" }}
>
<span style={{ fontSize, fontWeight: 400, letterSpacing: "-1px", lineHeight: 1 }}>
<span style={{ color: "#f3f4f6" }}>c</span>
<span style={{ background: "linear-gradient(135deg,#22d3ee,#34d399)", WebkitBackgroundClip: "text", WebkitTextFillColor: "transparent", backgroundClip: "text" }}>x</span>
</span>
</div>
);
}

11
components/PageHeader.tsx Normal file
View file

@ -0,0 +1,11 @@
export function PageHeader({ title, description, actions }: { title: string; description?: string; actions?: React.ReactNode }) {
return (
<div className="flex items-start justify-between gap-4 border-b border-zinc-800/70 px-8 py-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight text-zinc-100">{title}</h1>
{description && <p className="mt-1 text-sm text-muted-foreground">{description}</p>}
</div>
{actions && <div className="flex items-center gap-2">{actions}</div>}
</div>
);
}

65
components/Sidebar.tsx Normal file
View file

@ -0,0 +1,65 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { LayoutDashboard, Globe, Layers, BarChart3, Settings, LogOut, KeyRound } from "lucide-react";
import { Logo } from "./Logo";
import { cn } from "@/lib/utils";
const NAV = [
{ href: "/dashboard", label: "Dashboard", icon: LayoutDashboard },
{ href: "/domains", label: "Domains", icon: Globe },
{ href: "/groups", label: "Gruppen", icon: Layers },
{ href: "/analytics", label: "Analytics", icon: BarChart3 },
{ href: "/settings", label: "Einstellungen", icon: Settings },
{ href: "/settings/api-tokens", label: "API-Tokens", icon: KeyRound },
];
export function Sidebar({ user }: { user: { email: string } }) {
const pathname = usePathname();
return (
<aside className="relative z-10 flex w-60 shrink-0 flex-col border-r border-zinc-800/70 bg-zinc-950/80 backdrop-blur">
<div className="flex h-16 shrink-0 items-center gap-3 border-b border-zinc-800/70 px-5">
<Logo size={32} />
<div>
<p className="text-xs font-semibold tracking-tight text-zinc-100">NexRedirect</p>
<p className="text-[10px] text-muted-foreground">CoreX</p>
</div>
</div>
<nav className="flex-1 overflow-y-auto p-3 space-y-1">
{NAV.map((item) => {
const Icon = item.icon;
const active = pathname === item.href || (item.href !== "/dashboard" && pathname.startsWith(item.href));
return (
<Link
key={item.href}
href={item.href}
className={cn(
"flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm transition-colors",
active ? "bg-zinc-800/70 text-zinc-100" : "text-zinc-400 hover:bg-zinc-800/60 hover:text-zinc-100"
)}
>
<Icon className="h-4 w-4" />
{item.label}
</Link>
);
})}
</nav>
<div className="border-t border-zinc-800/70 p-3">
<div className="flex items-center gap-2.5 rounded-lg px-3 py-2">
<div className="flex h-7 w-7 items-center justify-center rounded-full bg-zinc-800 text-xs font-semibold text-cyan-400">
{user.email[0].toUpperCase()}
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-xs font-medium text-zinc-200">{user.email}</p>
<p className="truncate text-[10px] text-zinc-500">Admin</p>
</div>
<Link href="/api/auth/signout" className="rounded p-1 text-zinc-600 hover:text-zinc-300" title="Abmelden">
<LogOut className="h-3.5 w-3.5" />
</Link>
</div>
</div>
</aside>
);
}

View file

@ -0,0 +1,49 @@
"use client";
import { useEffect, useState } from "react";
import { ArrowUpCircle, X } from "lucide-react";
import Link from "next/link";
type VersionInfo = {
current: string;
latest: string | null;
update_available: boolean;
release_url?: string;
};
export function UpdateBanner() {
const [info, setInfo] = useState<VersionInfo | null>(null);
const [dismissed, setDismissed] = useState(false);
useEffect(() => {
fetch("/api/update/check")
.then((r) => r.json())
.then(setInfo)
.catch(() => {});
}, []);
if (!info?.update_available || dismissed) return null;
return (
<div className="flex items-center justify-between gap-3 border-b border-cyan-500/30 bg-cyan-500/10 px-6 py-2 text-sm">
<div className="flex items-center gap-2 text-cyan-300">
<ArrowUpCircle className="h-4 w-4" />
<span>
Update <span className="font-semibold">{info.latest}</span> verfügbar (aktuell {info.current}).
</span>
{info.release_url && (
<Link href={info.release_url} target="_blank" rel="noreferrer" className="underline hover:text-cyan-200">
Release-Notes
</Link>
)}
</div>
<div className="flex items-center gap-2">
<Link href="/settings" className="rounded bg-cyan-500/20 px-3 py-1 text-xs font-medium text-cyan-200 hover:bg-cyan-500/30">
Jetzt aktualisieren
</Link>
<button onClick={() => setDismissed(true)} className="rounded p-1 text-cyan-400/70 hover:text-cyan-200">
<X className="h-3.5 w-3.5" />
</button>
</div>
</div>
);
}

View file

@ -0,0 +1,19 @@
"use client";
import { ResponsiveContainer, PieChart, Pie, Cell, Tooltip, Legend } from "recharts";
const COLORS = ["#22d3ee", "#34d399", "#a78bfa", "#fbbf24", "#f87171", "#60a5fa", "#f472b6", "#facc15"];
export function CountryPie({ data }: { data: { country: string; hits: number }[] }) {
if (data.length === 0) return <p className="py-12 text-center text-sm text-muted-foreground">Noch keine Daten.</p>;
return (
<ResponsiveContainer width="100%" height={280}>
<PieChart>
<Pie data={data} dataKey="hits" nameKey="country" outerRadius={100} label={({ country }) => country}>
{data.map((_, i) => <Cell key={i} fill={COLORS[i % COLORS.length]} />)}
</Pie>
<Tooltip contentStyle={{ background: "#18181b", border: "1px solid #3f3f46", borderRadius: 8, fontSize: 12 }} />
<Legend wrapperStyle={{ fontSize: 11 }} />
</PieChart>
</ResponsiveContainer>
);
}

View file

@ -0,0 +1,19 @@
"use client";
import { ResponsiveContainer, LineChart, Line, XAxis, YAxis, Tooltip, CartesianGrid } from "recharts";
export function HitsLineChart({ data }: { data: { day: string; hits: number }[] }) {
if (data.length === 0) {
return <p className="py-12 text-center text-sm text-muted-foreground">Noch keine Hits.</p>;
}
return (
<ResponsiveContainer width="100%" height={240}>
<LineChart data={data}>
<CartesianGrid strokeDasharray="3 3" stroke="rgba(82,95,122,0.2)" />
<XAxis dataKey="day" tick={{ fontSize: 10, fill: "#a1a1aa" }} />
<YAxis tick={{ fontSize: 10, fill: "#a1a1aa" }} />
<Tooltip contentStyle={{ background: "#18181b", border: "1px solid #3f3f46", borderRadius: 8, fontSize: 12 }} />
<Line type="monotone" dataKey="hits" stroke="#22d3ee" strokeWidth={2} dot={false} />
</LineChart>
</ResponsiveContainer>
);
}

View file

@ -0,0 +1,19 @@
"use client";
import { ResponsiveContainer, BarChart, Bar, XAxis, YAxis, Tooltip, CartesianGrid } from "recharts";
export function TopDomainsBarChart({ data }: { data: { domain: string; hits: number }[] }) {
if (data.length === 0) {
return <p className="py-12 text-center text-sm text-muted-foreground">Noch keine Hits.</p>;
}
return (
<ResponsiveContainer width="100%" height={240}>
<BarChart data={data} layout="vertical" margin={{ left: 60 }}>
<CartesianGrid strokeDasharray="3 3" stroke="rgba(82,95,122,0.2)" />
<XAxis type="number" tick={{ fontSize: 10, fill: "#a1a1aa" }} />
<YAxis type="category" dataKey="domain" tick={{ fontSize: 10, fill: "#a1a1aa" }} width={120} />
<Tooltip contentStyle={{ background: "#18181b", border: "1px solid #3f3f46", borderRadius: 8, fontSize: 12 }} />
<Bar dataKey="hits" fill="#34d399" radius={[0, 4, 4, 0]} />
</BarChart>
</ResponsiveContainer>
);
}

33
components/ui/badge.tsx Normal file
View file

@ -0,0 +1,33 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const badgeVariants = cva(
"inline-flex items-center rounded-md border px-2 py-0.5 text-xs font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default: "border-transparent bg-primary/20 text-primary",
secondary: "border-transparent bg-secondary text-secondary-foreground",
destructive: "border-transparent bg-destructive/20 text-destructive-foreground",
outline: "text-foreground",
green: "border-transparent bg-green-500/20 text-green-400",
amber: "border-transparent bg-amber-500/20 text-amber-400",
blue: "border-transparent bg-blue-500/20 text-blue-400",
purple: "border-transparent bg-purple-500/20 text-purple-400",
zinc: "border-transparent bg-zinc-700 text-zinc-300",
},
},
defaultVariants: { variant: "default" },
}
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
}
export { Badge, badgeVariants };

44
components/ui/button.tsx Normal file
View file

@ -0,0 +1,44 @@
"use client";
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline: "border border-input bg-transparent hover:bg-secondary hover:text-foreground",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-secondary hover:text-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: { variant: "default", size: "default" },
}
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return <Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />;
}
);
Button.displayName = "Button";
export { Button, buttonVariants };

46
components/ui/card.tsx Normal file
View file

@ -0,0 +1,46 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("rounded-xl border bg-card text-card-foreground shadow-sm", className)} {...props} />
)
);
Card.displayName = "Card";
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />
)
);
CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h3 ref={ref} className={cn("text-lg font-semibold leading-none tracking-tight", className)} {...props} />
)
);
CardTitle.displayName = "CardTitle";
const CardDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
({ className, ...props }, ref) => (
<p ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
)
);
CardDescription.displayName = "CardDescription";
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
)
);
CardContent.displayName = "CardContent";
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex items-center p-6 pt-0", className)} {...props} />
)
);
CardFooter.displayName = "CardFooter";
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };

72
components/ui/dialog.tsx Normal file
View file

@ -0,0 +1,72 @@
"use client";
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close;
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn("fixed inset-0 z-50 bg-black/60 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0", className)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-card p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 rounded-xl",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)} {...props} />
);
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)} {...props} />
);
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title ref={ref} className={cn("text-lg font-semibold leading-none tracking-tight", className)} {...props} />
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export { Dialog, DialogPortal, DialogOverlay, DialogClose, DialogTrigger, DialogContent, DialogHeader, DialogFooter, DialogTitle, DialogDescription };

View file

@ -0,0 +1,103 @@
"use client";
import * as React from "react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { Check, ChevronRight, Circle } from "lucide-react";
import { cn } from "@/lib/utils";
const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & { inset?: boolean }
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger ref={ref} className={cn("flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-secondary data-[state=open]:bg-secondary", inset && "pl-8", className)} {...props}>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</DropdownMenuPrimitive.SubTrigger>
));
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent ref={ref} className={cn("z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg", className)} {...props} />
));
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName;
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn("z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md", className)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & { inset?: boolean }
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item ref={ref} className={cn("relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-secondary focus:text-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", inset && "pl-8", className)} {...props} />
));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem ref={ref} className={cn("relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-secondary", className)} checked={checked} {...props}>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator><Check className="h-4 w-4" /></DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
));
DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName;
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem ref={ref} className={cn("relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-secondary", className)} {...props}>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator><Circle className="h-2 w-2 fill-current" /></DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
));
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & { inset?: boolean }
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label ref={ref} className={cn("px-2 py-1.5 text-xs font-semibold text-muted-foreground", inset && "pl-8", className)} {...props} />
));
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator ref={ref} className={cn("-mx-1 my-1 h-px bg-border", className)} {...props} />
));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => (
<span className={cn("ml-auto text-xs tracking-widest opacity-60", className)} {...props} />
);
DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
export { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuCheckboxItem, DropdownMenuRadioItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuShortcut, DropdownMenuGroup, DropdownMenuPortal, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuRadioGroup };

19
components/ui/input.tsx Normal file
View file

@ -0,0 +1,19 @@
import * as React from "react";
import { cn } from "@/lib/utils";
export type InputProps = React.InputHTMLAttributes<HTMLInputElement>;
const Input = React.forwardRef<HTMLInputElement, InputProps>(({ className, type, ...props }, ref) => (
<input
type={type}
className={cn(
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
));
Input.displayName = "Input";
export { Input };

18
components/ui/label.tsx Normal file
View file

@ -0,0 +1,18 @@
"use client";
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import { cn } from "@/lib/utils";
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn("text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70", className)}
{...props}
/>
));
Label.displayName = LabelPrimitive.Root.displayName;
export { Label };

View file

@ -0,0 +1,18 @@
import * as React from "react";
import { cn } from "@/lib/utils";
export type TextareaProps = React.TextareaHTMLAttributes<HTMLTextAreaElement>;
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(({ className, ...props }, ref) => (
<textarea
className={cn(
"flex min-h-[80px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 resize-none",
className
)}
ref={ref}
{...props}
/>
));
Textarea.displayName = "Textarea";
export { Textarea };

172
docs/API.md Normal file
View file

@ -0,0 +1,172 @@
# API
Public REST-API für CoreX NexRedirect. Versioniert unter `/api/v1`. JSON-only.
## Auth
Tokens erstellen unter **Einstellungen → API-Tokens** im Web-UI. Token wird nur einmalig angezeigt — sicher kopieren.
Format: `nrx_<64-hex>`. Im Header senden:
```
Authorization: Bearer nrx_<your-token>
```
Tokens haben **Scopes**:
| Scope | Erlaubt |
|-------|---------|
| `read:domains` | Domains lesen |
| `write:domains` | Domains anlegen/löschen |
| `read:analytics` | Aggregierte Statistiken |
| `read:hits` | Roh-Hits (nur ip_hash, kein Klartext-IP) |
Fehler-Format:
```json
{ "error": "forbidden", "code": "missing_scope", "required": "read:domains" }
```
## Endpoints
### `GET /api/v1/health`
Liveness-Check. Kein Token nötig.
```bash
curl https://admin.firma.de/api/v1/health
# {"ok":true,"ts":1714500000000}
```
### `GET /api/v1/version`
Aktuelle Version + Update-Status. Kein Token nötig.
```json
{
"current": "0.1.0",
"latest": "0.1.1",
"update_available": true,
"release_url": "https://github.com/.../releases/tag/v0.1.1",
"auto_update": false
}
```
### `GET /api/v1/domains`
Scope: `read:domains`
```bash
curl -H "Authorization: Bearer nrx_..." https://admin.firma.de/api/v1/domains
```
```json
{
"domains": [
{
"id": 1, "domain": "alt-firma.de", "status": "active",
"target_url": "https://www.firma.de", "redirect_code": 301,
"preserve_path": 1, "include_www": 1,
"total_hits": 142, "last_hit": 1714499000000
}
]
}
```
### `POST /api/v1/domains`
Scope: `write:domains`
Body:
```json
{
"domain": "alt-firma.de",
"target_url": "https://www.firma.de",
"redirect_code": 301,
"preserve_path": true,
"include_www": true
}
```
Response (`201`):
```json
{
"domain": { "id": 5, "status": "pending", ... },
"dns_records": [
{ "type": "A", "name": "alt-firma.de", "value": "203.0.113.42" },
{ "type": "A", "name": "www.alt-firma.de", "value": "203.0.113.42" }
]
}
```
DNS muss anschließend gesetzt + verifiziert werden (über UI oder via internen `/api/domains/:id/verify`-Endpoint, der einen User-Login erfordert).
### `GET /api/v1/domains/:id`
Scope: `read:domains`
### `DELETE /api/v1/domains/:id`
Scope: `write:domains`
### `GET /api/v1/domains/:id/stats?days=30`
Scope: `read:analytics`
```json
{
"domain_id": 1, "days": 30, "total": 412,
"daily": [{"day":"2026-04-01","hits":12}, ...],
"by_country": [{"country":"DE","hits":380},{"country":"AT","hits":18}, ...]
}
```
### `GET /api/v1/analytics/summary?days=30`
Scope: `read:analytics`
```json
{
"days": 30, "total": 12480,
"daily": [...],
"top": [{"id":1,"domain":"alt-firma.de","hits":412}, ...],
"by_country": [...]
}
```
### `GET /api/v1/hits?domain_id=1&limit=100`
Scope: `read:hits`
```json
{
"hits": [
{
"id": 9001, "domain_id": 1, "ts": 1714499000000,
"ip_hash": "9f4a...", "country": "DE",
"user_agent": "Mozilla/5.0 ...", "referer": null, "path": "/"
}
]
}
```
`ip_hash` = `sha256(ip + täglicher Salt)`. Kein Klartext-IP wird gespeichert.
## Versionierung
`/api/v1` ist stabil. Breaking-Changes erscheinen unter `/api/v2`. Deprecation-Hinweise im `Sunset`-Header.
## Rate-Limits
Kein hartes Limit aktuell. Empfehlung: max. 60 Requests/Minute pro Token. Bei Problemen Token rotieren.
## Beispiele
**Uptime-Check eines Tokens:**
```bash
curl -fsS -H "Authorization: Bearer $NRX" https://admin.firma.de/api/v1/health || echo "DOWN"
```
**Liste tote Domains (0 Hits / 90d):**
```bash
curl -s -H "Authorization: Bearer $NRX" \
"https://admin.firma.de/api/v1/analytics/summary?days=90" \
| jq '.top | map(select(.hits == 0))'
```
**Domain anlegen + DNS-Records anzeigen:**
```bash
curl -X POST -H "Authorization: Bearer $NRX" -H "Content-Type: application/json" \
-d '{"domain":"shop.alt.de","target_url":"https://shop.firma.de"}' \
https://admin.firma.de/api/v1/domains | jq '.dns_records'
```

100
docs/INSTALL.md Normal file
View file

@ -0,0 +1,100 @@
# Installation
## One-Line (Debian/Ubuntu)
```bash
curl -sSL https://raw.githubusercontent.com/CoreXManagement/CoreX-NexRedirect/main/scripts/install.sh | sudo bash
```
Optional mit MaxMind-Lizenz für Geo-Lookup:
```bash
sudo MAXMIND_LICENSE_KEY=xxxxxxxx bash -c \
"$(curl -sSL https://raw.githubusercontent.com/CoreXManagement/CoreX-NexRedirect/main/scripts/install.sh)"
```
Das Script:
1. Prüft Debian/Ubuntu
2. Installiert Caddy (offizielles Repo), Node.js 20, git
3. Legt System-User `nexredirect` an
4. Cloned Repo nach `/opt/corex-nexredirect`
5. `npm ci && npm run build`
6. Holt GeoLite2-Country (falls Lizenz gesetzt)
7. Schreibt systemd-Unit `corex-nexredirect.service`
8. Schreibt minimale Caddyfile-Bootstrap-Config
9. `systemctl enable --now caddy corex-nexredirect`
10. Druckt Setup-URL
## Manueller Install
Wer das Curl-Pipe-Bash nicht mag:
```bash
sudo apt-get install -y caddy nodejs git
sudo useradd --system --home /opt/corex-nexredirect --shell /usr/sbin/nologin nexredirect
sudo mkdir -p /opt/corex-nexredirect /var/lib/corex-nexredirect
sudo git clone https://github.com/CoreXManagement/CoreX-NexRedirect /opt/corex-nexredirect
sudo chown -R nexredirect:nexredirect /opt/corex-nexredirect /var/lib/corex-nexredirect
sudo -u nexredirect bash -c "cd /opt/corex-nexredirect && npm ci && npm run build"
sudo cp /opt/corex-nexredirect/systemd/corex-nexredirect.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now caddy corex-nexredirect
```
Bootstrap-Caddyfile in `/etc/caddy/Caddyfile`:
```
:80 {
reverse_proxy localhost:3000
}
```
## Verzeichnisse
| Pfad | Inhalt |
|------|--------|
| `/opt/corex-nexredirect` | Code (git checkout) |
| `/var/lib/corex-nexredirect/nexredirect.db` | SQLite (alle Daten) |
| `/var/lib/corex-nexredirect/GeoLite2-Country.mmdb` | Geo-DB (optional) |
| `/etc/caddy/Caddyfile` | Caddy-Config (auto-generated) |
| `/etc/systemd/system/corex-nexredirect.service` | systemd-Unit |
| `/etc/sudoers.d/corex-nexredirect` | Sudo für update.sh |
## Backup / Restore
Sicherung:
```bash
sudo tar -czf nexredirect-backup-$(date +%F).tar.gz \
/var/lib/corex-nexredirect/nexredirect.db \
/etc/caddy/Caddyfile
```
Restore:
```bash
sudo systemctl stop corex-nexredirect caddy
sudo tar -xzf nexredirect-backup-XXXX.tar.gz -C /
sudo chown nexredirect:nexredirect /var/lib/corex-nexredirect/nexredirect.db
sudo systemctl start caddy corex-nexredirect
```
SQLite ist im WAL-Modus — Hot-Backup ohne Stop:
```bash
sqlite3 /var/lib/corex-nexredirect/nexredirect.db ".backup /tmp/db.sqlite"
```
## Logs
```bash
journalctl -u corex-nexredirect -f
journalctl -u caddy -f
```
## Deinstallation
```bash
sudo systemctl disable --now corex-nexredirect
sudo rm /etc/systemd/system/corex-nexredirect.service /etc/sudoers.d/corex-nexredirect
sudo systemctl daemon-reload
sudo userdel nexredirect
sudo rm -rf /opt/corex-nexredirect /var/lib/corex-nexredirect
# Caddy + Caddyfile bei Bedarf separat
```

57
docs/UPDATE.md Normal file
View file

@ -0,0 +1,57 @@
# Updates
NexRedirect prüft alle 60 Minuten gegen die GitHub-Releases-API auf neue Versionen. **Keine Auto-Updates** außer aktiviert.
## Update-Verhalten
| Setting | Verhalten |
|---------|-----------|
| (Default) | Stündlicher Check, Banner in der UI bei verfügbarem Update. Nichts wird ohne Klick installiert. |
| `update_auto = true` | Bei jedem Check wird ein verfügbares Update sofort installiert. |
| `update_include_prereleases = true` | Auch Pre-Releases werden als Update angezeigt. |
## Manuell aktualisieren
In der UI:
1. **Einstellungen** → "Update X.Y.Z installieren" klicken
2. Bestätigen
3. Server fährt herunter, zieht Tag, baut, startet neu (Admin-UI ~510s down)
4. Redirects bleiben über Caddy aktiv (Caddy-Block enthält statisches Fallback-`redir`)
Auf der Konsole:
```bash
sudo /opt/corex-nexredirect/scripts/update.sh v0.2.0
```
## Auto-Update aktivieren
**Einstellungen → "Auto-Update aktivieren"** klicken. Ab dann wird bei jedem stündlichen Check ein verfügbares Update direkt installiert. UI-Banner erscheint nicht mehr (oder kurz).
Empfehlung: Auto-Update nur in Test-Umgebungen, in Prod manuell prüfen.
## Rollback
```bash
cd /opt/corex-nexredirect
sudo -u nexredirect git tag --list | sort -V
sudo /opt/corex-nexredirect/scripts/update.sh v0.1.0 # Vorgänger-Tag
```
Der Update-Skript ruft `git checkout <tag>` und rebuilt — daher gleich für Forward- und Rollback-Updates.
## Schema-Migrationen
`ensureSchema` in `lib/db.ts` legt fehlende Tabellen/Indizes idempotent an — `CREATE TABLE IF NOT EXISTS` für jede Tabelle. Reine additive Migrationen sind damit automatisch.
Für **destruktive** Migrationen (Spalten umbenennen, droppen): manuelles SQL vor dem Update einspielen, Schritte werden im Release-Note dokumentiert.
## Update-Log
Jeder Update-Versuch wird in der `update_log`-Tabelle protokolliert:
```sql
SELECT ts, from_version, to_version, status FROM update_log ORDER BY ts DESC LIMIT 10;
```
Status: `success` oder `failed` (mit Log-Auszug in der `log`-Spalte).

51
lib/api-auth.ts Normal file
View file

@ -0,0 +1,51 @@
import crypto from "crypto";
import { NextResponse } from "next/server";
import { getDb, type ApiTokenRow } from "./db";
export type Scope = "read:domains" | "write:domains" | "read:analytics" | "read:hits";
export const ALL_SCOPES: Scope[] = ["read:domains", "write:domains", "read:analytics", "read:hits"];
export function generateToken(): { plaintext: string; hash: string } {
const random = crypto.randomBytes(32).toString("hex");
const plaintext = `nrx_${random}`;
const hash = crypto.createHash("sha256").update(plaintext).digest("hex");
return { plaintext, hash };
}
function hashToken(plaintext: string): string {
return crypto.createHash("sha256").update(plaintext).digest("hex");
}
export type AuthedToken = {
id: number;
name: string;
scopes: Scope[];
};
export function authenticateToken(req: Request): AuthedToken | null {
const auth = req.headers.get("authorization");
if (!auth || !auth.toLowerCase().startsWith("bearer ")) return null;
const token = auth.slice(7).trim();
if (!token.startsWith("nrx_")) return null;
const hash = hashToken(token);
const row = getDb()
.prepare("SELECT * FROM api_tokens WHERE token_hash = ? AND revoked_at IS NULL LIMIT 1")
.get(hash) as ApiTokenRow | undefined;
if (!row) return null;
getDb().prepare("UPDATE api_tokens SET last_used_at = ? WHERE id = ?").run(Date.now(), row.id);
let scopes: Scope[] = [];
try { scopes = JSON.parse(row.scopes); } catch {}
return { id: row.id, name: row.name, scopes };
}
export function requireScope(req: Request, scope: Scope): AuthedToken | NextResponse {
const t = authenticateToken(req);
if (!t) return NextResponse.json({ error: "unauthorized", code: "no_token" }, { status: 401 });
if (!t.scopes.includes(scope)) {
return NextResponse.json({ error: "forbidden", code: "missing_scope", required: scope }, { status: 403 });
}
return t;
}

54
lib/auth.ts Normal file
View file

@ -0,0 +1,54 @@
import type { NextAuthOptions } from "next-auth";
import CredentialsProvider from "next-auth/providers/credentials";
import bcrypt from "bcryptjs";
import { getDb, type UserRow } from "./db";
export const authOptions: NextAuthOptions = {
secret: process.env.NEXTAUTH_SECRET || "nexredirect-dev-secret-please-change",
session: { strategy: "jwt", maxAge: 30 * 24 * 60 * 60 },
pages: { signIn: "/login" },
providers: [
CredentialsProvider({
name: "Credentials",
credentials: {
email: { label: "E-Mail", type: "email" },
password: { label: "Passwort", type: "password" },
},
async authorize(credentials) {
if (!credentials?.email || !credentials?.password) return null;
const email = credentials.email.toLowerCase().trim();
const user = getDb()
.prepare("SELECT id, email, password_hash, role, created_at FROM users WHERE email = ? LIMIT 1")
.get(email) as UserRow | undefined;
if (!user) return null;
const valid = await bcrypt.compare(credentials.password, user.password_hash);
if (!valid) return null;
return {
id: String(user.id),
email: user.email,
name: user.email,
role: user.role,
};
},
}),
],
callbacks: {
async jwt({ token, user }) {
if (user) {
token.id = user.id;
token.role = user.role;
}
return token;
},
async session({ session, token }) {
if (session.user) {
session.user.id = token.id;
session.user.role = token.role;
}
return session;
},
},
};

78
lib/caddy.ts Normal file
View file

@ -0,0 +1,78 @@
import fs from "fs/promises";
import path from "path";
import { getDb, getSetting, type DomainRow, type DomainGroupRow } from "./db";
const CADDYFILE_PATH = process.env.NEXREDIRECT_CADDYFILE || "/etc/caddy/Caddyfile";
const CADDY_ADMIN = process.env.CADDY_ADMIN_URL || "http://localhost:2019";
const APP_PORT = process.env.PORT || "3000";
export function buildCaddyfile(): string {
const db = getDb();
const baseDomain = getSetting("base_domain");
const adminEmail = getSetting("admin_email") || "admin@example.com";
const domains = db.prepare("SELECT * FROM domains WHERE status = 'active'").all() as DomainRow[];
const groups = db.prepare("SELECT * FROM domain_groups").all() as DomainGroupRow[];
const groupMap = new Map(groups.map((g) => [g.id, g]));
const lines: string[] = [];
lines.push(`{`);
lines.push(` email ${adminEmail}`);
lines.push(`}`);
lines.push(``);
// Admin-UI
const adminHosts: string[] = [":80"];
if (baseDomain) adminHosts.push(baseDomain);
lines.push(`${adminHosts.join(", ")} {`);
lines.push(` reverse_proxy localhost:${APP_PORT}`);
lines.push(`}`);
lines.push(``);
// Per-Domain redirect blocks → reverse_proxy to app for hit logging
for (const d of domains) {
if (baseDomain && d.domain === baseDomain) continue;
const hosts = [d.domain];
if (d.include_www) hosts.push(`www.${d.domain}`);
lines.push(`${hosts.join(", ")} {`);
lines.push(` reverse_proxy localhost:${APP_PORT}`);
// Fallback redirect baked-in: if app is down, Caddy still redirects (no analytics)
const fallbackTarget = d.target_url || (d.group_id ? groupMap.get(d.group_id)?.target_url : null);
if (fallbackTarget) {
const code = d.redirect_code || 301;
const target = d.preserve_path ? `${fallbackTarget}{uri}` : fallbackTarget;
lines.push(` handle_errors {`);
lines.push(` redir ${target} ${code}`);
lines.push(` }`);
}
lines.push(`}`);
lines.push(``);
}
return lines.join("\n");
}
export async function writeCaddyfile(): Promise<void> {
const content = buildCaddyfile();
await fs.mkdir(path.dirname(CADDYFILE_PATH), { recursive: true }).catch(() => {});
await fs.writeFile(CADDYFILE_PATH, content, "utf8");
}
export async function reloadCaddy(): Promise<{ ok: boolean; error?: string }> {
try {
await writeCaddyfile();
const adapt = await fetch(`${CADDY_ADMIN}/load`, {
method: "POST",
headers: { "Content-Type": "text/caddyfile" },
body: buildCaddyfile(),
});
if (!adapt.ok) {
const text = await adapt.text().catch(() => "");
return { ok: false, error: `Caddy load failed: ${adapt.status} ${text}` };
}
return { ok: true };
} catch (e) {
return { ok: false, error: e instanceof Error ? e.message : String(e) };
}
}

167
lib/db.ts Normal file
View file

@ -0,0 +1,167 @@
import Database from "better-sqlite3";
import path from "path";
import fs from "fs";
import crypto from "crypto";
const DATA_DIR = process.env.NEXREDIRECT_DATA_DIR || path.join(process.cwd(), "data");
const DB_PATH = path.join(DATA_DIR, "nexredirect.db");
let _db: Database.Database | null = null;
export function getDb(): Database.Database {
if (_db) return _db;
fs.mkdirSync(DATA_DIR, { recursive: true });
const db = new Database(DB_PATH);
db.pragma("journal_mode = WAL");
db.pragma("foreign_keys = ON");
ensureSchema(db);
_db = db;
return db;
}
function ensureSchema(db: Database.Database) {
db.exec(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'admin',
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS domain_groups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
target_url TEXT NOT NULL,
redirect_code INTEGER NOT NULL DEFAULT 301,
created_by INTEGER REFERENCES users(id),
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS domains (
id INTEGER PRIMARY KEY AUTOINCREMENT,
domain TEXT UNIQUE NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
target_url TEXT,
group_id INTEGER REFERENCES domain_groups(id) ON DELETE SET NULL,
redirect_code INTEGER NOT NULL DEFAULT 301,
preserve_path INTEGER NOT NULL DEFAULT 1,
include_www INTEGER NOT NULL DEFAULT 1,
created_by INTEGER REFERENCES users(id),
created_at INTEGER NOT NULL,
verified_at INTEGER
);
CREATE INDEX IF NOT EXISTS idx_domains_status ON domains(status);
CREATE TABLE IF NOT EXISTS hits (
id INTEGER PRIMARY KEY AUTOINCREMENT,
domain_id INTEGER NOT NULL REFERENCES domains(id) ON DELETE CASCADE,
ts INTEGER NOT NULL,
ip_hash TEXT NOT NULL,
country TEXT,
user_agent TEXT,
referer TEXT,
path TEXT
);
CREATE INDEX IF NOT EXISTS idx_hits_domain_ts ON hits(domain_id, ts);
CREATE INDEX IF NOT EXISTS idx_hits_ts ON hits(ts);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS api_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
token_hash TEXT UNIQUE NOT NULL,
scopes TEXT NOT NULL,
created_by INTEGER REFERENCES users(id),
created_at INTEGER NOT NULL,
last_used_at INTEGER,
revoked_at INTEGER
);
CREATE TABLE IF NOT EXISTS update_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
from_version TEXT,
to_version TEXT,
ts INTEGER NOT NULL,
status TEXT NOT NULL,
log TEXT
);
`);
}
export function getSetting(key: string): string | null {
const row = getDb().prepare("SELECT value FROM settings WHERE key = ?").get(key) as { value: string } | undefined;
return row?.value ?? null;
}
export function setSetting(key: string, value: string) {
getDb()
.prepare("INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value")
.run(key, value);
}
export function isSetupComplete(): boolean {
return getSetting("setup_complete") === "true";
}
export function getDailySalt(): string {
const today = new Date().toISOString().slice(0, 10);
const stored = getSetting("daily_ip_salt");
if (stored) {
const [date, salt] = stored.split("|");
if (date === today) return salt;
}
const salt = crypto.randomBytes(16).toString("hex");
setSetting("daily_ip_salt", `${today}|${salt}`);
return salt;
}
export function hashIp(ip: string): string {
return crypto.createHash("sha256").update(ip + getDailySalt()).digest("hex");
}
export type DomainRow = {
id: number;
domain: string;
status: "pending" | "active" | "error";
target_url: string | null;
group_id: number | null;
redirect_code: number;
preserve_path: number;
include_www: number;
created_by: number | null;
created_at: number;
verified_at: number | null;
};
export type DomainGroupRow = {
id: number;
name: string;
target_url: string;
redirect_code: number;
created_by: number | null;
created_at: number;
};
export type UserRow = {
id: number;
email: string;
password_hash: string;
role: string;
created_at: number;
};
export type ApiTokenRow = {
id: number;
name: string;
token_hash: string;
scopes: string;
created_by: number | null;
created_at: number;
last_used_at: number | null;
revoked_at: number | null;
};

57
lib/dns.ts Normal file
View file

@ -0,0 +1,57 @@
import dns from "dns/promises";
import { getSetting } from "./db";
export type DnsCheckResult = {
ok: boolean;
expected: { ipv4?: string; ipv6?: string };
resolved: { a: string[]; aaaa: string[]; wwwA: string[]; wwwAaaa: string[] };
missing: string[];
};
export async function getServerIps() {
return {
ipv4: getSetting("server_ip") ?? undefined,
ipv6: getSetting("server_ipv6") ?? undefined,
};
}
async function resolveSafe(name: string, type: "A" | "AAAA"): Promise<string[]> {
try {
if (type === "A") return await dns.resolve4(name);
return await dns.resolve6(name);
} catch {
return [];
}
}
export async function checkDomainDns(domain: string, includeWww: boolean): Promise<DnsCheckResult> {
const expected = await getServerIps();
const [a, aaaa, wwwA, wwwAaaa] = await Promise.all([
resolveSafe(domain, "A"),
resolveSafe(domain, "AAAA"),
includeWww ? resolveSafe(`www.${domain}`, "A") : Promise.resolve([]),
includeWww ? resolveSafe(`www.${domain}`, "AAAA") : Promise.resolve([]),
]);
const missing: string[] = [];
const apexHasIpv4 = expected.ipv4 ? a.includes(expected.ipv4) : a.length > 0;
const apexHasIpv6 = expected.ipv6 ? aaaa.includes(expected.ipv6) : true;
if (!apexHasIpv4) missing.push(`A ${domain}${expected.ipv4 ?? "(server-IP)"}`);
if (expected.ipv6 && !apexHasIpv6) missing.push(`AAAA ${domain}${expected.ipv6}`);
if (includeWww) {
const wwwHasIpv4 = expected.ipv4 ? wwwA.includes(expected.ipv4) : wwwA.length > 0;
if (!wwwHasIpv4) missing.push(`A www.${domain}${expected.ipv4 ?? "(server-IP)"}`);
}
return {
ok: missing.length === 0,
expected,
resolved: { a, aaaa, wwwA, wwwAaaa },
missing,
};
}
export function isValidDomain(domain: string): boolean {
return /^(?!-)[a-z0-9-]+(\.[a-z0-9-]+)+$/i.test(domain) && !domain.includes("..") && domain.length <= 253;
}

35
lib/geo.ts Normal file
View file

@ -0,0 +1,35 @@
import path from "path";
import fs from "fs";
const MMDB_PATH = process.env.NEXREDIRECT_GEOIP_PATH || path.join(process.cwd(), "data", "GeoLite2-Country.mmdb");
type CountryResponse = { country?: { iso_code?: string } };
type Reader = { get: (ip: string) => CountryResponse | null };
let _reader: Reader | null = null;
let _loadAttempted = false;
async function getReader(): Promise<Reader | null> {
if (_reader) return _reader;
if (_loadAttempted) return null;
_loadAttempted = true;
if (!fs.existsSync(MMDB_PATH)) return null;
try {
const maxmind = await import("maxmind");
_reader = (await maxmind.open(MMDB_PATH)) as unknown as Reader;
return _reader;
} catch {
return null;
}
}
export async function lookupCountry(ip: string): Promise<string | null> {
const r = await getReader();
if (!r) return null;
try {
const result = r.get(ip);
return result?.country?.iso_code ?? null;
} catch {
return null;
}
}

66
lib/hits.ts Normal file
View file

@ -0,0 +1,66 @@
import { getDb, hashIp } from "./db";
import { lookupCountry } from "./geo";
type PendingHit = {
domain_id: number;
ts: number;
ip_hash: string;
country: string | null;
user_agent: string | null;
referer: string | null;
path: string | null;
};
const buffer: PendingHit[] = [];
let flushTimer: NodeJS.Timeout | null = null;
function flush() {
if (buffer.length === 0) return;
const batch = buffer.splice(0, buffer.length);
try {
const db = getDb();
const stmt = db.prepare(
"INSERT INTO hits (domain_id, ts, ip_hash, country, user_agent, referer, path) VALUES (?, ?, ?, ?, ?, ?, ?)"
);
db.transaction(() => {
for (const h of batch) {
stmt.run(h.domain_id, h.ts, h.ip_hash, h.country, h.user_agent, h.referer, h.path);
}
})();
} catch (e) {
console.error("[hits] flush failed", e);
}
}
function scheduleFlush() {
if (flushTimer) return;
flushTimer = setTimeout(() => {
flushTimer = null;
flush();
}, 5000);
}
export async function recordHit(input: {
domain_id: number;
ip: string;
user_agent: string | null;
referer: string | null;
path: string | null;
}) {
const country = await lookupCountry(input.ip).catch(() => null);
buffer.push({
domain_id: input.domain_id,
ts: Date.now(),
ip_hash: hashIp(input.ip),
country,
user_agent: input.user_agent ? input.user_agent.slice(0, 500) : null,
referer: input.referer ? input.referer.slice(0, 500) : null,
path: input.path ? input.path.slice(0, 500) : null,
});
if (buffer.length >= 100) flush();
else scheduleFlush();
}
export function flushHitsSync() {
flush();
}

57
lib/redirect-resolver.ts Normal file
View file

@ -0,0 +1,57 @@
import { getDb, getSetting, type DomainRow, type DomainGroupRow } from "./db";
export type ResolvedRedirect = {
domain_id: number;
target_url: string;
redirect_code: number;
preserve_path: boolean;
};
let cache: Map<string, ResolvedRedirect> | null = null;
let cacheLoadedAt = 0;
const CACHE_TTL_MS = 5_000;
function loadCache(): Map<string, ResolvedRedirect> {
const db = getDb();
const domains = db.prepare("SELECT * FROM domains WHERE status = 'active'").all() as DomainRow[];
const groups = db.prepare("SELECT * FROM domain_groups").all() as DomainGroupRow[];
const groupMap = new Map(groups.map((g) => [g.id, g]));
const m = new Map<string, ResolvedRedirect>();
for (const d of domains) {
const target = d.target_url ?? (d.group_id ? groupMap.get(d.group_id)?.target_url : null);
if (!target) continue;
const r: ResolvedRedirect = {
domain_id: d.id,
target_url: target,
redirect_code: d.redirect_code,
preserve_path: !!d.preserve_path,
};
m.set(d.domain.toLowerCase(), r);
if (d.include_www) m.set(`www.${d.domain.toLowerCase()}`, r);
}
return m;
}
export function invalidateRedirectCache() {
cache = null;
}
export function resolveHost(host: string): ResolvedRedirect | null {
const now = Date.now();
if (!cache || now - cacheLoadedAt > CACHE_TTL_MS) {
cache = loadCache();
cacheLoadedAt = now;
}
return cache.get(host.toLowerCase()) ?? null;
}
export function isAdminHost(host: string): boolean {
const baseDomain = getSetting("base_domain");
const serverIp = getSetting("server_ip");
const h = host.toLowerCase().split(":")[0];
if (h === "localhost" || h === "127.0.0.1" || h === "::1") return true;
if (baseDomain && h === baseDomain.toLowerCase()) return true;
if (serverIp && h === serverIp) return true;
return false;
}

118
lib/updater.ts Normal file
View file

@ -0,0 +1,118 @@
import { exec } from "child_process";
import { promisify } from "util";
import { getSetting, setSetting, getDb } from "./db";
import pkg from "../package.json";
const execAsync = promisify(exec);
const REPO = process.env.NEXREDIRECT_REPO || "CoreXManagement/CoreX-NexRedirect";
export type ReleaseInfo = {
tag_name: string;
name: string;
html_url: string;
prerelease: boolean;
published_at: string;
};
export type UpdateStatus = {
current: string;
latest: string | null;
update_available: boolean;
release_url?: string;
last_check?: number;
auto_update: boolean;
include_prereleases: boolean;
};
function cmpVersions(a: string, b: string): number {
const norm = (v: string) => v.replace(/^v/, "").split(/[.-]/).map((p) => /^\d+$/.test(p) ? Number(p) : p);
const aa = norm(a), bb = norm(b);
for (let i = 0; i < Math.max(aa.length, bb.length); i++) {
const x = aa[i] ?? 0, y = bb[i] ?? 0;
if (typeof x === "number" && typeof y === "number") {
if (x !== y) return x - y;
} else if (String(x) !== String(y)) {
return String(x) < String(y) ? -1 : 1;
}
}
return 0;
}
export async function fetchLatestRelease(includePrerelease = false): Promise<ReleaseInfo | null> {
const url = includePrerelease
? `https://api.github.com/repos/${REPO}/releases?per_page=10`
: `https://api.github.com/repos/${REPO}/releases/latest`;
try {
const res = await fetch(url, {
headers: { "Accept": "application/vnd.github+json", "User-Agent": "corex-nexredirect" },
});
if (!res.ok) return null;
const data = await res.json();
if (Array.isArray(data)) {
return data.find((r: ReleaseInfo) => includePrerelease || !r.prerelease) || null;
}
return data as ReleaseInfo;
} catch {
return null;
}
}
export async function checkForUpdate(): Promise<UpdateStatus> {
const current = pkg.version;
const includePrereleases = getSetting("update_include_prereleases") === "true";
const release = await fetchLatestRelease(includePrereleases);
const latest = release?.tag_name ?? null;
const update_available = !!latest && cmpVersions(current, latest) < 0;
setSetting("latest_version", latest ?? "");
setSetting("update_available", update_available ? "true" : "false");
setSetting("update_last_check", String(Date.now()));
if (release?.html_url) setSetting("update_release_url", release.html_url);
return {
current,
latest,
update_available,
release_url: release?.html_url,
last_check: Date.now(),
auto_update: getSetting("update_auto") === "true",
include_prereleases: includePrereleases,
};
}
export function getUpdateStatus(): UpdateStatus {
return {
current: pkg.version,
latest: getSetting("latest_version") || null,
update_available: getSetting("update_available") === "true",
release_url: getSetting("update_release_url") || undefined,
last_check: Number(getSetting("update_last_check") || 0) || undefined,
auto_update: getSetting("update_auto") === "true",
include_prereleases: getSetting("update_include_prereleases") === "true",
};
}
export async function applyUpdate(): Promise<{ ok: boolean; from: string; to: string | null; error?: string }> {
const from = pkg.version;
const status = await checkForUpdate();
const to = status.latest;
if (!to || !status.update_available) {
return { ok: false, from, to, error: "no_update" };
}
const updateScript = process.env.NEXREDIRECT_UPDATE_SCRIPT || "/opt/corex-nexredirect/scripts/update.sh";
const start = Date.now();
try {
const { stdout, stderr } = await execAsync(`sudo -n ${updateScript} ${to}`, { timeout: 5 * 60 * 1000 });
getDb().prepare("INSERT INTO update_log (from_version, to_version, ts, status, log) VALUES (?, ?, ?, 'success', ?)")
.run(from, to, start, (stdout + "\n" + stderr).slice(0, 10000));
return { ok: true, from, to };
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
getDb().prepare("INSERT INTO update_log (from_version, to_version, ts, status, log) VALUES (?, ?, ?, 'failed', ?)")
.run(from, to, start, msg.slice(0, 10000));
return { ok: false, from, to, error: msg };
}
}

6
lib/utils.ts Normal file
View file

@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

24
middleware.ts Normal file
View file

@ -0,0 +1,24 @@
import { NextResponse, type NextRequest } from "next/server";
import { getToken } from "next-auth/jwt";
const PUBLIC_PATHS = ["/login", "/setup", "/api/auth", "/api/setup", "/api/v1"];
export async function middleware(req: NextRequest) {
const { pathname } = req.nextUrl;
if (PUBLIC_PATHS.some((p) => pathname === p || pathname.startsWith(p + "/") || pathname.startsWith(p))) {
return NextResponse.next();
}
const token = await getToken({ req, secret: process.env.NEXTAUTH_SECRET || "nexredirect-dev-secret-please-change" });
if (!token) {
const url = req.nextUrl.clone();
url.pathname = "/login";
url.searchParams.set("from", pathname);
return NextResponse.redirect(url);
}
return NextResponse.next();
}
export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
};

6
next.config.js Normal file
View file

@ -0,0 +1,6 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
serverExternalPackages: ["better-sqlite3", "maxmind"],
};
module.exports = nextConfig;

4577
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

44
package.json Normal file
View file

@ -0,0 +1,44 @@
{
"name": "corex-nexredirect",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "tsx watch server.ts",
"build": "next build",
"start": "NODE_ENV=production tsx server.ts",
"lint": "next lint"
},
"dependencies": {
"@radix-ui/react-dialog": "^1.1.4",
"@radix-ui/react-dropdown-menu": "^2.1.6",
"@radix-ui/react-label": "^2.1.2",
"@radix-ui/react-slot": "^1.2.0",
"@radix-ui/react-tooltip": "^1.1.8",
"bcryptjs": "^2.4.3",
"better-sqlite3": "^11.5.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.511.0",
"maxmind": "^4.3.20",
"next": "15.3.1",
"next-auth": "^4.24.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"recharts": "^2.13.3",
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7",
"tsx": "^4.21.0",
"zod": "^3.24.0"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/better-sqlite3": "^7.6.11",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"autoprefixer": "^10.4.21",
"postcss": "^8.5.3",
"tailwindcss": "^3.4.17",
"typescript": "^5"
}
}

6
postcss.config.js Normal file
View file

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

144
scripts/install.sh Normal file
View file

@ -0,0 +1,144 @@
#!/usr/bin/env bash
# CoreX NexRedirect — One-line install
# Usage: curl -sSL https://raw.githubusercontent.com/CoreXManagement/CoreX-NexRedirect/main/scripts/install.sh | sudo bash
set -euo pipefail
if [[ $EUID -ne 0 ]]; then
echo "Bitte als root ausführen (sudo)."
exit 1
fi
if ! command -v apt-get >/dev/null 2>&1; then
echo "Nur Debian/Ubuntu wird unterstützt."
exit 1
fi
REPO="${NEXREDIRECT_REPO:-CoreXManagement/CoreX-NexRedirect}"
INSTALL_DIR="${NEXREDIRECT_DIR:-/opt/corex-nexredirect}"
DATA_DIR="${NEXREDIRECT_DATA_DIR:-/var/lib/corex-nexredirect}"
SERVICE_USER="nexredirect"
APP_PORT="${NEXREDIRECT_PORT:-3000}"
NODE_MAJOR=20
echo "==> CoreX NexRedirect Install"
echo " Repo: $REPO"
echo " Install: $INSTALL_DIR"
echo " Data: $DATA_DIR"
echo "==> Pakete installieren"
export DEBIAN_FRONTEND=noninteractive
apt-get update -qq
apt-get install -y -qq curl ca-certificates gnupg git debian-keyring debian-archive-keyring apt-transport-https sudo
if ! command -v node >/dev/null 2>&1 || [[ "$(node -v 2>/dev/null | cut -c2-3)" != "${NODE_MAJOR}" ]]; then
echo "==> Node.js ${NODE_MAJOR} installieren"
curl -fsSL "https://deb.nodesource.com/setup_${NODE_MAJOR}.x" | bash -
apt-get install -y -qq nodejs
fi
if ! command -v caddy >/dev/null 2>&1; then
echo "==> Caddy installieren"
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | tee /etc/apt/sources.list.d/caddy-stable.list >/dev/null
apt-get update -qq
apt-get install -y -qq caddy
fi
if ! id "$SERVICE_USER" >/dev/null 2>&1; then
echo "==> User $SERVICE_USER anlegen"
useradd --system --home "$INSTALL_DIR" --shell /usr/sbin/nologin "$SERVICE_USER"
fi
echo "==> Repo clonen / aktualisieren"
if [[ -d "$INSTALL_DIR/.git" ]]; then
git -C "$INSTALL_DIR" fetch --tags --quiet
git -C "$INSTALL_DIR" reset --hard origin/main --quiet
else
rm -rf "$INSTALL_DIR"
git clone --quiet "https://github.com/${REPO}.git" "$INSTALL_DIR"
fi
mkdir -p "$DATA_DIR"
chown -R "$SERVICE_USER:$SERVICE_USER" "$INSTALL_DIR" "$DATA_DIR"
echo "==> Dependencies installieren"
sudo -u "$SERVICE_USER" -H bash -c "cd '$INSTALL_DIR' && npm ci --no-audit --no-fund"
sudo -u "$SERVICE_USER" -H bash -c "cd '$INSTALL_DIR' && npm run build"
echo "==> GeoLite2-Country DB"
GEOIP_PATH="$DATA_DIR/GeoLite2-Country.mmdb"
if [[ ! -f "$GEOIP_PATH" ]]; then
if [[ -n "${MAXMIND_LICENSE_KEY:-}" ]]; then
curl -sSL "https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-Country&license_key=${MAXMIND_LICENSE_KEY}&suffix=tar.gz" -o /tmp/geo.tgz
tar -xzf /tmp/geo.tgz -C /tmp
find /tmp -name "GeoLite2-Country.mmdb" -exec cp {} "$GEOIP_PATH" \;
rm -rf /tmp/geo.tgz /tmp/GeoLite2-Country_*
else
echo " (MAXMIND_LICENSE_KEY nicht gesetzt — Geo-Lookup deaktiviert. Später unter Settings nachholen.)"
fi
chown "$SERVICE_USER:$SERVICE_USER" "$GEOIP_PATH" 2>/dev/null || true
fi
echo "==> Server-IP ermitteln"
SERVER_IP=$(curl -s4 ifconfig.me || echo "")
SERVER_IPV6=$(curl -s6 ifconfig.me || echo "")
NEXTAUTH_SECRET=$(openssl rand -hex 32)
echo "==> systemd Unit"
cat > /etc/systemd/system/corex-nexredirect.service <<EOF
[Unit]
Description=CoreX NexRedirect
After=network.target
[Service]
Type=simple
User=$SERVICE_USER
WorkingDirectory=$INSTALL_DIR
Environment=NODE_ENV=production
Environment=PORT=$APP_PORT
Environment=NEXREDIRECT_DATA_DIR=$DATA_DIR
Environment=NEXREDIRECT_GEOIP_PATH=$GEOIP_PATH
Environment=NEXREDIRECT_CADDYFILE=/etc/caddy/Caddyfile
Environment=NEXREDIRECT_UPDATE_SCRIPT=$INSTALL_DIR/scripts/update.sh
Environment=NEXTAUTH_SECRET=$NEXTAUTH_SECRET
Environment=NEXTAUTH_URL=http://$SERVER_IP
ExecStart=$INSTALL_DIR/node_modules/.bin/tsx $INSTALL_DIR/server.ts
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.target
EOF
# Allow nexredirect to run update.sh as root via sudo
cat > /etc/sudoers.d/corex-nexredirect <<EOF
$SERVICE_USER ALL=(root) NOPASSWD: $INSTALL_DIR/scripts/update.sh
EOF
chmod 0440 /etc/sudoers.d/corex-nexredirect
echo "==> Caddy Bootstrap-Config"
cat > /etc/caddy/Caddyfile <<EOF
{
email admin@example.com
}
:80 {
reverse_proxy localhost:$APP_PORT
}
EOF
# Server-IPs in DB-Settings schreiben (via tsx)
sudo -u "$SERVICE_USER" -H bash -c "cd '$INSTALL_DIR' && NEXREDIRECT_DATA_DIR='$DATA_DIR' SERVER_IP='$SERVER_IP' SERVER_IPV6='$SERVER_IPV6' ./node_modules/.bin/tsx -e \"import('./lib/db').then(({setSetting})=>{if(process.env.SERVER_IP)setSetting('server_ip',process.env.SERVER_IP);if(process.env.SERVER_IPV6)setSetting('server_ipv6',process.env.SERVER_IPV6);})\"" || \
echo " (Server-IP konnte nicht direkt gesetzt werden — manuell via /settings nachholen.)"
systemctl daemon-reload
systemctl enable --now caddy
systemctl enable --now corex-nexredirect
echo ""
echo "==> Fertig!"
echo ""
echo " Setup unter: http://${SERVER_IP}/setup"
echo " Logs: journalctl -u corex-nexredirect -f"
echo ""

25
scripts/update.sh Normal file
View file

@ -0,0 +1,25 @@
#!/usr/bin/env bash
# CoreX NexRedirect — Self-update
# Usage: sudo /opt/corex-nexredirect/scripts/update.sh [tag]
# Aufgerufen von der App via sudo (siehe install.sh / sudoers.d/corex-nexredirect)
set -euo pipefail
TAG="${1:-}"
INSTALL_DIR="${NEXREDIRECT_DIR:-/opt/corex-nexredirect}"
SERVICE_USER="nexredirect"
cd "$INSTALL_DIR"
git fetch --tags --quiet
if [[ -n "$TAG" ]]; then
git checkout --quiet "$TAG"
else
git pull --ff-only --quiet
fi
sudo -u "$SERVICE_USER" -H bash -c "cd '$INSTALL_DIR' && npm ci --no-audit --no-fund"
sudo -u "$SERVICE_USER" -H bash -c "cd '$INSTALL_DIR' && npm run build"
systemctl restart corex-nexredirect
echo "Update auf $(git describe --tags --always) abgeschlossen"

70
server.ts Normal file
View file

@ -0,0 +1,70 @@
// Custom Node server. Intercepts requests by Host header.
// - Host matches active redirect-domain → log hit + 301/302 + end (skip Next.js)
// - Else → delegate to Next.js (admin UI / API)
//
// Run via: tsx server.ts
import http from "http";
import { parse } from "url";
import next from "next";
import { resolveHost, isAdminHost } from "./lib/redirect-resolver";
import { recordHit } from "./lib/hits";
const dev = process.env.NODE_ENV !== "production";
const port = parseInt(process.env.PORT || "3000", 10);
const hostname = process.env.HOSTNAME || "0.0.0.0";
const app = next({ dev, hostname, port });
const handle = app.getRequestHandler();
app.prepare().then(() => {
const server = http.createServer(async (req, res) => {
try {
const host = (req.headers.host || "").split(":")[0].toLowerCase();
const parsedUrl = parse(req.url || "/", true);
if (host && !isAdminHost(host)) {
const resolved = resolveHost(host);
if (resolved) {
const ip =
((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 target = resolved.preserve_path
? resolved.target_url + (parsedUrl.path || "")
: resolved.target_url;
res.writeHead(resolved.redirect_code || 301, { Location: target });
res.end();
return;
}
res.writeHead(404, { "Content-Type": "text/html; charset=utf-8" });
res.end(
`<!doctype html><html><head><title>Domain not configured</title><style>body{background:#0a0c10;color:#e5e7eb;font-family:ui-monospace,monospace;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0}</style></head><body><div><h1>Domain nicht konfiguriert</h1><p>Diese Domain ist auf diesem Server nicht eingerichtet.</p></div></body></html>`
);
return;
}
await handle(req, res, parsedUrl);
} catch (err) {
console.error("[server] error", err);
res.statusCode = 500;
res.end("Internal Server Error");
}
});
server.listen(port, hostname, () => {
console.log(`> CoreX NexRedirect on http://${hostname}:${port} (${dev ? "dev" : "prod"})`);
});
}).catch((err) => {
console.error("Failed to start Next.js", err);
process.exit(1);
});

View file

@ -0,0 +1,20 @@
[Unit]
Description=CoreX NexRedirect
After=network.target
[Service]
Type=simple
User=nexredirect
WorkingDirectory=/opt/corex-nexredirect
Environment=NODE_ENV=production
Environment=PORT=3000
Environment=NEXREDIRECT_DATA_DIR=/var/lib/corex-nexredirect
Environment=NEXREDIRECT_GEOIP_PATH=/var/lib/corex-nexredirect/GeoLite2-Country.mmdb
Environment=NEXREDIRECT_CADDYFILE=/etc/caddy/Caddyfile
Environment=NEXREDIRECT_UPDATE_SCRIPT=/opt/corex-nexredirect/scripts/update.sh
ExecStart=/opt/corex-nexredirect/node_modules/.bin/tsx /opt/corex-nexredirect/server.ts
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.target

73
tailwind.config.ts Normal file
View file

@ -0,0 +1,73 @@
import type { Config } from "tailwindcss";
import tailwindcssAnimate from "tailwindcss-animate";
const config: Config = {
darkMode: ["class"],
content: [
"./components/**/*.{ts,tsx}",
"./app/**/*.{ts,tsx}",
"./lib/**/*.{ts,tsx}",
],
theme: {
container: { center: true, padding: "2rem", screens: { "2xl": "1400px" } },
extend: {
fontFamily: {
mono: ["ui-monospace", "SFMono-Regular", "Menlo", "monospace"],
sans: ["ui-monospace", "SFMono-Regular", "Menlo", "monospace"],
},
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
keyframes: {
"accordion-down": { from: { height: "0" }, to: { height: "var(--radix-accordion-content-height)" } },
"accordion-up": { from: { height: "var(--radix-accordion-content-height)" }, to: { height: "0" } },
"slide-in-from-right": { from: { transform: "translateX(100%)" }, to: { transform: "translateX(0)" } },
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
"slide-in": "slide-in-from-right 0.2s ease-out",
},
},
},
plugins: [tailwindcssAnimate],
};
export default config;

21
tsconfig.json Normal file
View file

@ -0,0 +1,21 @@
{
"compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": { "@/*": ["./*"] },
"target": "ES2017"
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}

26
types/next-auth.d.ts vendored Normal file
View file

@ -0,0 +1,26 @@
import "next-auth";
import "next-auth/jwt";
declare module "next-auth" {
interface User {
id: string;
email: string;
name?: string | null;
role: string;
}
interface Session {
user: {
id: string;
email: string;
name?: string | null;
role: string;
};
}
}
declare module "next-auth/jwt" {
interface JWT {
id: string;
role: string;
}
}