commit d7272c5e58bde36a53498829af678a16e00c749b Author: Hendrik Date: Fri May 1 17:51:12 2026 +0200 Initial NexRedirect: redirect server with admin UI, analytics, API tokens, self-update diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0eb0f7a --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..4656482 --- /dev/null +++ b/README.md @@ -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:///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 diff --git a/app/(app)/analytics/page.tsx b/app/(app)/analytics/page.tsx new file mode 100644 index 0000000..f698d96 --- /dev/null +++ b/app/(app)/analytics/page.tsx @@ -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 ( +
+ + +
+
+ + Hits pro Tag + + + + Top 10 Domains + + + + Top Länder + + + + + Tote Domains + Aktive Domains ohne Hits in den letzten 90 Tagen — kandidaten zum Kündigen + + + {s.dead.length === 0 ? ( +

Keine — alle aktiven Domains werden genutzt.

+ ) : ( +
    + {s.dead.map((d) => ( +
  • + {d.domain} + 0 Hits / 90d +
  • + ))} +
+ )} +
+
+
+
+
+ ); +} diff --git a/app/(app)/dashboard/page.tsx b/app/(app)/dashboard/page.tsx new file mode 100644 index 0000000..dc97ce8 --- /dev/null +++ b/app/(app)/dashboard/page.tsx @@ -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 ( +
+ + + Domain hinzufügen + + } + /> + +
+
+ } label="Domains" value={s.totalDomains} sub={`${s.activeDomains} aktiv`} /> + } label="Wartend" value={s.pendingDomains} sub="DNS-Verify nötig" /> + } label="Hits (24h)" value={s.hits24h} sub={`${s.hits30d} in 30 Tagen`} /> + } label="Gruppen" value={s.groups} /> +
+ +
+ + + Hits letzte 30 Tage + Aggregiert über alle Domains + + + + + + + + Top Domains + Hits letzte 30 Tage + + + + + +
+
+
+ ); +} + +function StatCard({ icon, label, value, sub }: { icon: React.ReactNode; label: string; value: number; sub?: string }) { + return ( + + +
+ {icon} + {label} +
+

{value.toLocaleString("de-DE")}

+ {sub &&

{sub}

} +
+
+ ); +} diff --git a/app/(app)/domains/[id]/DomainActions.tsx b/app/(app)/domains/[id]/DomainActions.tsx new file mode 100644 index 0000000..25370c5 --- /dev/null +++ b/app/(app)/domains/[id]/DomainActions.tsx @@ -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 ( +
+ + + {msg &&

{msg}

} + {status === "pending" &&

Domain ist noch nicht aktiv. DNS muss korrekt eingetragen sein, bevor Aufrufe weitergeleitet werden.

} +
+ ); +} diff --git a/app/(app)/domains/[id]/page.tsx b/app/(app)/domains/[id]/page.tsx new file mode 100644 index 0000000..6fb2cc3 --- /dev/null +++ b/app/(app)/domains/[id]/page.tsx @@ -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 ( +
+ + Zurück + + } + /> + +
+ + Konfiguration + + + + {target ? ( + + {target} + + ) : "—"} + + {group && {group.name}} + {domain.redirect_code} + {domain.preserve_path ? "ja" : "nein"} + {domain.include_www ? "ja" : "nein"} + {domain.verified_at ? new Date(domain.verified_at).toLocaleString("de-DE") : "—"} + + + + + Hits + + {hits24h.toLocaleString("de-DE")} + {hits30d.toLocaleString("de-DE")} + {hitsTotal.toLocaleString("de-DE")} + + + + + Aktionen + + + + + + + + Hits letzte 30 Tage + Tagesgenaue Aufrufe + + + + + +
+
+ ); +} + +function Row({ k, children }: { k: string; children: React.ReactNode }) { + return ( +
+ {k} + {children} +
+ ); +} + +function StatusBadge({ status }: { status: string }) { + if (status === "active") return aktiv; + if (status === "pending") return wartet; + return {status}; +} diff --git a/app/(app)/domains/new/page.tsx b/app/(app)/domains/new/page.tsx new file mode 100644 index 0000000..da332d3 --- /dev/null +++ b/app/(app)/domains/new/page.tsx @@ -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(null); + + // Step 1 form + const [domain, setDomain] = useState(""); + const [targetMode, setTargetMode] = useState<"url" | "group">("url"); + const [targetUrl, setTargetUrl] = useState(""); + const [groupId, setGroupId] = useState(""); + const [redirectCode, setRedirectCode] = useState<301 | 302>(301); + const [preservePath, setPreservePath] = useState(true); + const [includeWww, setIncludeWww] = useState(true); + const [groups, setGroups] = useState([]); + 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(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 = { + 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 ( +
+ + +
+ + + {step === 1 && ( + + + Domain & Ziel + Lege fest, welche Domain wohin weiterleiten soll. + + +
+
+ + setDomain(e.target.value)} /> +
+ +
+ +
+ + +
+
+ + {targetMode === "url" ? ( +
+ + setTargetUrl(e.target.value)} /> +
+ ) : ( +
+ + +
+ )} + +
+
+ + +
+
+ + +
+
+ + {error &&

{error}

} + +
+ +
+
+
+
+ )} + + {step === 2 && ( + + + DNS-Records eintragen + Trage diese Records bei deinem DNS-Provider ein: + + + +
+ Hinweis: DNS-Änderungen können einige Minuten bis zur Sichtbarkeit dauern. +
+
+ +
+
+
+ )} + + {step === 3 && ( + + + Validierung + Prüft, ob die DNS-Records korrekt gesetzt sind. + + + + + {verifyResult && ( +
+ {verifyResult.ok ? ( +
+
+ + DNS korrekt — Domain ist aktiv! +
+

Caddy wurde neu geladen. Aufrufe werden jetzt weitergeleitet.

+ +
+ ) : ( +
+
+ + DNS-Records noch nicht korrekt +
+

Fehlend:

+
    + {verifyResult.result.missing.map((m, i) =>
  • {m}
  • )} +
+
+
+

Aufgelöst (A):

+

{verifyResult.result.resolved.a.join(", ") || "—"}

+
+
+

Erwartet:

+

{verifyResult.result.expected.ipv4 || "(server-IP)"}

+
+
+
+ )} +
+ )} +
+
+ )} +
+
+ ); +} + +function StepIndicator({ step }: { step: 1 | 2 | 3 }) { + return ( +
+ {[1, 2, 3].map((n) => ( + + {n < step ? : null} + Schritt {n} + + ))} +
+ ); +} + +function DnsRecordsTable({ domain, ipv4, ipv6, includeWww }: { domain: string; ipv4?: string; ipv6?: string; includeWww: boolean }) { + const records = [ + { type: "A", name: domain, value: ipv4 || "" }, + ...(ipv6 ? [{ type: "AAAA", name: domain, value: ipv6 }] : []), + ...(includeWww ? [{ type: "A", name: `www.${domain}`, value: ipv4 || "" }] : []), + ]; + + return ( +
+ + + + + + + + + + + {records.map((r, i) => ( + + + + + + + ))} + +
TypNameWert
{r.type}{r.name}{r.value} + +
+
+ ); +} diff --git a/app/(app)/domains/page.tsx b/app/(app)/domains/page.tsx new file mode 100644 index 0000000..fda3a40 --- /dev/null +++ b/app/(app)/domains/page.tsx @@ -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 ( +
+ + + Domain hinzufügen + + } + /> + +
+ {domains.length === 0 ? ( + + +

Noch keine Domain angelegt.

+ +
+
+ ) : ( + + + + + + + + + + + + + + + {domains.map((d) => ( + + + + + + + + + ))} + +
DomainStatusZielHitsLetzter Hit
{d.domain} + + + {d.target_url || d.group_target || "—"} + {d.group_name && ({d.group_name})} + {d.total_hits.toLocaleString("de-DE")}{timeAgo(d.last_hit)} + +
+
+
+ )} +
+
+ ); +} + +function StatusBadge({ status }: { status: "pending" | "active" | "error" }) { + if (status === "active") { + return aktiv; + } + if (status === "pending") { + return wartet; + } + return fehler; +} diff --git a/app/(app)/groups/page.tsx b/app/(app)/groups/page.tsx new file mode 100644 index 0000000..0039910 --- /dev/null +++ b/app/(app)/groups/page.tsx @@ -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([]); + 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 ( +
+ + + + + + + Neue Gruppe + Mehrere Domains können dasselbe Ziel teilen. + +
+
+ + setName(e.target.value)} placeholder="Marketing-Domains" /> +
+
+ + setTargetUrl(e.target.value)} placeholder="https://www.firma.de" /> +
+
+ + +
+ {error &&

{error}

} +
+ +
+
+
+ + } + /> + +
+ {loading ? ( +
+ ) : groups.length === 0 ? ( + Noch keine Gruppen angelegt. + ) : ( +
+ {groups.map((g) => ( + + +
+ {g.name} + +
+
+ +

{g.target_url}

+
+ {g.redirect_code} + {g.domain_count} Domain{g.domain_count === 1 ? "" : "s"} +
+
+
+ ))} +
+ )} +
+
+ ); +} diff --git a/app/(app)/layout.tsx b/app/(app)/layout.tsx new file mode 100644 index 0000000..3c455cf --- /dev/null +++ b/app/(app)/layout.tsx @@ -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 ( +
+
+ + + +
+ +
{children}
+
+
+ ); +} diff --git a/app/(app)/settings/api-tokens/page.tsx b/app/(app)/settings/api-tokens/page.tsx new file mode 100644 index 0000000..603ddc8 --- /dev/null +++ b/app/(app)/settings/api-tokens/page.tsx @@ -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([]); + const [loading, setLoading] = useState(true); + const [open, setOpen] = useState(false); + const [name, setName] = useState(""); + const [selectedScopes, setSelectedScopes] = useState(["read:domains", "read:analytics"]); + const [creating, setCreating] = useState(false); + const [newToken, setNewToken] = useState(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 ( +
+ { setOpen(v); if (!v) setNewToken(null); }}> + + + + + {newToken ? ( +
+ + Token erstellt + Wird nur einmal angezeigt — sicher kopieren. + +
+ {newToken} +
+
+ + +
+
+ ) : ( +
+ + Neuen Token erstellen + +
+ + setName(e.target.value)} placeholder="Uptime-Monitor" /> +
+
+ +
+ {SCOPES.map((s) => ( + + ))} +
+
+
+ +
+
+ )} +
+ + } + /> + +
+ {loading ? ( +
+ ) : tokens.length === 0 ? ( + Keine Tokens. + ) : ( + + + + + + + + + + + + + + + {tokens.map((t) => { + const scopes = JSON.parse(t.scopes) as string[]; + return ( + + + + + + + + + ); + })} + +
NameScopesErstelltZuletzt benutztStatus
{t.name}
{scopes.map((s) => {s})}
{new Date(t.created_at).toLocaleDateString("de-DE")}{t.last_used_at ? new Date(t.last_used_at).toLocaleString("de-DE") : "—"}{t.revoked_at ? widerrufen : aktiv} + {!t.revoked_at && ( + + )} +
+
+
+ )} +
+
+ ); +} diff --git a/app/(app)/settings/page.tsx b/app/(app)/settings/page.tsx new file mode 100644 index 0000000..dd49c1c --- /dev/null +++ b/app/(app)/settings/page.tsx @@ -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(null); + const [status, setStatus] = useState(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) { + 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
; + } + + return ( +
+ + +
+ + + + Updates {status.update_available && Verfügbar} + + + Aktuelle Version {status.current} + {status.latest && <> • Neueste {status.latest}} + + + +
+ + {status.update_available && ( + + )} + {status.release_url && Release-Notes →} +
+
+ + +
+ {msg &&

{msg}

} + {status.last_check &&

Letzte Prüfung: {new Date(status.last_check).toLocaleString("de-DE")}

} +
+
+ + + + Server + Allgemeine Konfiguration + + +
+ + save({ base_domain: e.target.value.trim() })} + /> +

Optional. Bestimmt unter welcher Domain die Admin-UI erreichbar ist.

+
+
+ + save({ admin_email: e.target.value.trim() })} + /> +

Wird von Caddy für ACME/Let's Encrypt benötigt.

+
+
+
+
+
+ ); +} diff --git a/app/(auth)/layout.tsx b/app/(auth)/layout.tsx new file mode 100644 index 0000000..ba540d4 --- /dev/null +++ b/app/(auth)/layout.tsx @@ -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}; +} diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx new file mode 100644 index 0000000..ddb3645 --- /dev/null +++ b/app/(auth)/login/page.tsx @@ -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 ( +
+ + +
+ +
+ CoreX NexRedirect + Melde dich mit deinem Admin-Account an +
+ +
+
+ + setEmail(e.target.value)} disabled={loading} autoComplete="email" /> +
+
+ + setPassword(e.target.value)} disabled={loading} autoComplete="current-password" /> +
+ {error &&

{error}

} + +
+
+
+
+ ); +} diff --git a/app/(setup)/setup/page.tsx b/app/(setup)/setup/page.tsx new file mode 100644 index 0000000..ac0bd2b --- /dev/null +++ b/app/(setup)/setup/page.tsx @@ -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 ( +
+ +
+ ); + } + + return ( +
+ + +
+ +
+ Erstes Setup + Erstelle deinen Admin-Account für CoreX NexRedirect +
+ +
+
+ + setEmail(e.target.value)} disabled={loading} placeholder="admin@beispiel.de" autoComplete="email" /> +
+
+ + setPassword(e.target.value)} disabled={loading} autoComplete="new-password" /> +
+
+ + setPassword2(e.target.value)} disabled={loading} autoComplete="new-password" /> +
+
+ + setBaseDomain(e.target.value)} disabled={loading} placeholder="admin.beispiel.de" /> +

Lass leer um die Server-IP zu verwenden.

+
+ {error &&

{error}

} + +
+
+
+
+ ); +} diff --git a/app/api/analytics/route.ts b/app/api/analytics/route.ts new file mode 100644 index 0000000..5566f57 --- /dev/null +++ b/app/api/analytics/route.ts @@ -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 }); +} diff --git a/app/api/auth/[...nextauth]/route.ts b/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000..9cd7923 --- /dev/null +++ b/app/api/auth/[...nextauth]/route.ts @@ -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 }; diff --git a/app/api/caddy/reload/route.ts b/app/api/caddy/reload/route.ts new file mode 100644 index 0000000..cf606e0 --- /dev/null +++ b/app/api/caddy/reload/route.ts @@ -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" } }); +} diff --git a/app/api/domains/[id]/route.ts b/app/api/domains/[id]/route.ts new file mode 100644 index 0000000..ffe463f --- /dev/null +++ b/app/api/domains/[id]/route.ts @@ -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 }); +} diff --git a/app/api/domains/[id]/verify/route.ts b/app/api/domains/[id]/verify/route.ts new file mode 100644 index 0000000..bea4956 --- /dev/null +++ b/app/api/domains/[id]/verify/route.ts @@ -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 }); +} diff --git a/app/api/domains/route.ts b/app/api/domains/route.ts new file mode 100644 index 0000000..e2bdbc7 --- /dev/null +++ b/app/api/domains/route.ts @@ -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 }); +} diff --git a/app/api/groups/[id]/route.ts b/app/api/groups/[id]/route.ts new file mode 100644 index 0000000..e6f3578 --- /dev/null +++ b/app/api/groups/[id]/route.ts @@ -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 }); +} diff --git a/app/api/groups/route.ts b/app/api/groups/route.ts new file mode 100644 index 0000000..8dd4f1e --- /dev/null +++ b/app/api/groups/route.ts @@ -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 }); +} diff --git a/app/api/settings/route.ts b/app/api/settings/route.ts new file mode 100644 index 0000000..08a0007 --- /dev/null +++ b/app/api/settings/route.ts @@ -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 = {}; + 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 }); +} diff --git a/app/api/settings/server-ip/route.ts b/app/api/settings/server-ip/route.ts new file mode 100644 index 0000000..34b0eb0 --- /dev/null +++ b/app/api/settings/server-ip/route.ts @@ -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()); +} diff --git a/app/api/setup/route.ts b/app/api/setup/route.ts new file mode 100644 index 0000000..7601d1a --- /dev/null +++ b/app/api/setup/route.ts @@ -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() }); +} diff --git a/app/api/tokens/[id]/route.ts b/app/api/tokens/[id]/route.ts new file mode 100644 index 0000000..183bd05 --- /dev/null +++ b/app/api/tokens/[id]/route.ts @@ -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 }); +} diff --git a/app/api/tokens/route.ts b/app/api/tokens/route.ts new file mode 100644 index 0000000..51248c2 --- /dev/null +++ b/app/api/tokens/route.ts @@ -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 }); +} diff --git a/app/api/update/apply/route.ts b/app/api/update/apply/route.ts new file mode 100644 index 0000000..3dfcd76 --- /dev/null +++ b/app/api/update/apply/route.ts @@ -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); +} diff --git a/app/api/update/check/route.ts b/app/api/update/check/route.ts new file mode 100644 index 0000000..1f2497f --- /dev/null +++ b/app/api/update/check/route.ts @@ -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()); +} diff --git a/app/api/v1/analytics/summary/route.ts b/app/api/v1/analytics/summary/route.ts new file mode 100644 index 0000000..4af0b3a --- /dev/null +++ b/app/api/v1/analytics/summary/route.ts @@ -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 }); +} diff --git a/app/api/v1/domains/[id]/route.ts b/app/api/v1/domains/[id]/route.ts new file mode 100644 index 0000000..36ce0f9 --- /dev/null +++ b/app/api/v1/domains/[id]/route.ts @@ -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 }); +} diff --git a/app/api/v1/domains/[id]/stats/route.ts b/app/api/v1/domains/[id]/stats/route.ts new file mode 100644 index 0000000..23dbae2 --- /dev/null +++ b/app/api/v1/domains/[id]/stats/route.ts @@ -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 }); +} diff --git a/app/api/v1/domains/route.ts b/app/api/v1/domains/route.ts new file mode 100644 index 0000000..30d55f1 --- /dev/null +++ b/app/api/v1/domains/route.ts @@ -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 }); +} diff --git a/app/api/v1/health/route.ts b/app/api/v1/health/route.ts new file mode 100644 index 0000000..b29045c --- /dev/null +++ b/app/api/v1/health/route.ts @@ -0,0 +1,5 @@ +import { NextResponse } from "next/server"; + +export async function GET() { + return NextResponse.json({ ok: true, ts: Date.now() }); +} diff --git a/app/api/v1/hits/route.ts b/app/api/v1/hits/route.ts new file mode 100644 index 0000000..3ef5bde --- /dev/null +++ b/app/api/v1/hits/route.ts @@ -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 }); +} diff --git a/app/api/v1/version/route.ts b/app/api/v1/version/route.ts new file mode 100644 index 0000000..22159d6 --- /dev/null +++ b/app/api/v1/version/route.ts @@ -0,0 +1,6 @@ +import { NextResponse } from "next/server"; +import { getUpdateStatus } from "@/lib/updater"; + +export async function GET() { + return NextResponse.json(getUpdateStatus()); +} diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..67b57c4 --- /dev/null +++ b/app/globals.css @@ -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; +} diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..73509f0 --- /dev/null +++ b/app/layout.tsx @@ -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 ( + + + {children} + + + ); +} diff --git a/app/page.tsx b/app/page.tsx new file mode 100644 index 0000000..889dfdc --- /dev/null +++ b/app/page.tsx @@ -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"); +} diff --git a/app/providers.tsx b/app/providers.tsx new file mode 100644 index 0000000..e294e82 --- /dev/null +++ b/app/providers.tsx @@ -0,0 +1,6 @@ +"use client"; +import { SessionProvider } from "next-auth/react"; + +export function Providers({ children }: { children: React.ReactNode }) { + return {children}; +} diff --git a/components/Logo.tsx b/components/Logo.tsx new file mode 100644 index 0000000..07c7507 --- /dev/null +++ b/components/Logo.tsx @@ -0,0 +1,14 @@ +export function Logo({ size = 32 }: { size?: number }) { + const fontSize = Math.round(size * 0.46); + return ( +
+ + c + x + +
+ ); +} diff --git a/components/PageHeader.tsx b/components/PageHeader.tsx new file mode 100644 index 0000000..ad2807c --- /dev/null +++ b/components/PageHeader.tsx @@ -0,0 +1,11 @@ +export function PageHeader({ title, description, actions }: { title: string; description?: string; actions?: React.ReactNode }) { + return ( +
+
+

{title}

+ {description &&

{description}

} +
+ {actions &&
{actions}
} +
+ ); +} diff --git a/components/Sidebar.tsx b/components/Sidebar.tsx new file mode 100644 index 0000000..585d23f --- /dev/null +++ b/components/Sidebar.tsx @@ -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 ( + + ); +} diff --git a/components/UpdateBanner.tsx b/components/UpdateBanner.tsx new file mode 100644 index 0000000..491ed57 --- /dev/null +++ b/components/UpdateBanner.tsx @@ -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(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 ( +
+
+ + + Update {info.latest} verfügbar (aktuell {info.current}). + + {info.release_url && ( + + Release-Notes + + )} +
+
+ + Jetzt aktualisieren + + +
+
+ ); +} diff --git a/components/charts/CountryPie.tsx b/components/charts/CountryPie.tsx new file mode 100644 index 0000000..37e03b1 --- /dev/null +++ b/components/charts/CountryPie.tsx @@ -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

Noch keine Daten.

; + return ( + + + country}> + {data.map((_, i) => )} + + + + + + ); +} diff --git a/components/charts/HitsLineChart.tsx b/components/charts/HitsLineChart.tsx new file mode 100644 index 0000000..06ab7e5 --- /dev/null +++ b/components/charts/HitsLineChart.tsx @@ -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

Noch keine Hits.

; + } + return ( + + + + + + + + + + ); +} diff --git a/components/charts/TopDomainsBarChart.tsx b/components/charts/TopDomainsBarChart.tsx new file mode 100644 index 0000000..8a84378 --- /dev/null +++ b/components/charts/TopDomainsBarChart.tsx @@ -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

Noch keine Hits.

; + } + return ( + + + + + + + + + + ); +} diff --git a/components/ui/badge.tsx b/components/ui/badge.tsx new file mode 100644 index 0000000..ca3d270 --- /dev/null +++ b/components/ui/badge.tsx @@ -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, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return
; +} + +export { Badge, badgeVariants }; diff --git a/components/ui/button.tsx b/components/ui/button.tsx new file mode 100644 index 0000000..b07919b --- /dev/null +++ b/components/ui/button.tsx @@ -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, + VariantProps { + asChild?: boolean; +} + +const Button = React.forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button"; + return ; + } +); +Button.displayName = "Button"; + +export { Button, buttonVariants }; diff --git a/components/ui/card.tsx b/components/ui/card.tsx new file mode 100644 index 0000000..7f6cadc --- /dev/null +++ b/components/ui/card.tsx @@ -0,0 +1,46 @@ +import * as React from "react"; +import { cn } from "@/lib/utils"; + +const Card = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ) +); +Card.displayName = "Card"; + +const CardHeader = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ) +); +CardHeader.displayName = "CardHeader"; + +const CardTitle = React.forwardRef>( + ({ className, ...props }, ref) => ( +

+ ) +); +CardTitle.displayName = "CardTitle"; + +const CardDescription = React.forwardRef>( + ({ className, ...props }, ref) => ( +

+ ) +); +CardDescription.displayName = "CardDescription"; + +const CardContent = React.forwardRef>( + ({ className, ...props }, ref) => ( +

+ ) +); +CardContent.displayName = "CardContent"; + +const CardFooter = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ) +); +CardFooter.displayName = "CardFooter"; + +export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }; diff --git a/components/ui/dialog.tsx b/components/ui/dialog.tsx new file mode 100644 index 0000000..a37ab15 --- /dev/null +++ b/components/ui/dialog.tsx @@ -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, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; + +const DialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + {children} + + + Close + + + +)); +DialogContent.displayName = DialogPrimitive.Content.displayName; + +const DialogHeader = ({ className, ...props }: React.HTMLAttributes) => ( +
+); + +const DialogFooter = ({ className, ...props }: React.HTMLAttributes) => ( +
+); + +const DialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogTitle.displayName = DialogPrimitive.Title.displayName; + +const DialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogDescription.displayName = DialogPrimitive.Description.displayName; + +export { Dialog, DialogPortal, DialogOverlay, DialogClose, DialogTrigger, DialogContent, DialogHeader, DialogFooter, DialogTitle, DialogDescription }; diff --git a/components/ui/dropdown-menu.tsx b/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000..8b85387 --- /dev/null +++ b/components/ui/dropdown-menu.tsx @@ -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, + React.ComponentPropsWithoutRef & { inset?: boolean } +>(({ className, inset, children, ...props }, ref) => ( + + {children} + + +)); +DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName; + +const DropdownMenuSubContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName; + +const DropdownMenuContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + + + +)); +DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName; + +const DropdownMenuItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { inset?: boolean } +>(({ className, inset, ...props }, ref) => ( + +)); +DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName; + +const DropdownMenuCheckboxItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, checked, ...props }, ref) => ( + + + + + {children} + +)); +DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName; + +const DropdownMenuRadioItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + {children} + +)); +DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName; + +const DropdownMenuLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { inset?: boolean } +>(({ className, inset, ...props }, ref) => ( + +)); +DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName; + +const DropdownMenuSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName; + +const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes) => ( + +); +DropdownMenuShortcut.displayName = "DropdownMenuShortcut"; + +export { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuCheckboxItem, DropdownMenuRadioItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuShortcut, DropdownMenuGroup, DropdownMenuPortal, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuRadioGroup }; diff --git a/components/ui/input.tsx b/components/ui/input.tsx new file mode 100644 index 0000000..803cee6 --- /dev/null +++ b/components/ui/input.tsx @@ -0,0 +1,19 @@ +import * as React from "react"; +import { cn } from "@/lib/utils"; + +export type InputProps = React.InputHTMLAttributes; + +const Input = React.forwardRef(({ className, type, ...props }, ref) => ( + +)); +Input.displayName = "Input"; + +export { Input }; diff --git a/components/ui/label.tsx b/components/ui/label.tsx new file mode 100644 index 0000000..ddda62b --- /dev/null +++ b/components/ui/label.tsx @@ -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, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +Label.displayName = LabelPrimitive.Root.displayName; + +export { Label }; diff --git a/components/ui/textarea.tsx b/components/ui/textarea.tsx new file mode 100644 index 0000000..12be3dd --- /dev/null +++ b/components/ui/textarea.tsx @@ -0,0 +1,18 @@ +import * as React from "react"; +import { cn } from "@/lib/utils"; + +export type TextareaProps = React.TextareaHTMLAttributes; + +const Textarea = React.forwardRef(({ className, ...props }, ref) => ( +