Initial NexRedirect: redirect server with admin UI, analytics, API tokens, self-update
This commit is contained in:
commit
d7272c5e58
81 changed files with 8934 additions and 0 deletions
16
.gitignore
vendored
Normal file
16
.gitignore
vendored
Normal 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
87
README.md
Normal 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
|
||||
90
app/(app)/analytics/page.tsx
Normal file
90
app/(app)/analytics/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
103
app/(app)/dashboard/page.tsx
Normal file
103
app/(app)/dashboard/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
50
app/(app)/domains/[id]/DomainActions.tsx
Normal file
50
app/(app)/domains/[id]/DomainActions.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
112
app/(app)/domains/[id]/page.tsx
Normal file
112
app/(app)/domains/[id]/page.tsx
Normal 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>;
|
||||
}
|
||||
306
app/(app)/domains/new/page.tsx
Normal file
306
app/(app)/domains/new/page.tsx
Normal 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
128
app/(app)/domains/page.tsx
Normal 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
140
app/(app)/groups/page.tsx
Normal 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
25
app/(app)/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
177
app/(app)/settings/api-tokens/page.tsx
Normal file
177
app/(app)/settings/api-tokens/page.tsx
Normal 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
157
app/(app)/settings/page.tsx
Normal 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'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's Encrypt benötigt.</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
7
app/(auth)/layout.tsx
Normal file
7
app/(auth)/layout.tsx
Normal 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
68
app/(auth)/login/page.tsx
Normal 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
107
app/(setup)/setup/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
49
app/api/analytics/route.ts
Normal file
49
app/api/analytics/route.ts
Normal 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 });
|
||||
}
|
||||
5
app/api/auth/[...nextauth]/route.ts
Normal file
5
app/api/auth/[...nextauth]/route.ts
Normal 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 };
|
||||
17
app/api/caddy/reload/route.ts
Normal file
17
app/api/caddy/reload/route.ts
Normal 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" } });
|
||||
}
|
||||
79
app/api/domains/[id]/route.ts
Normal file
79
app/api/domains/[id]/route.ts
Normal 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 });
|
||||
}
|
||||
29
app/api/domains/[id]/verify/route.ts
Normal file
29
app/api/domains/[id]/verify/route.ts
Normal 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
68
app/api/domains/route.ts
Normal 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 });
|
||||
}
|
||||
53
app/api/groups/[id]/route.ts
Normal file
53
app/api/groups/[id]/route.ts
Normal 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
37
app/api/groups/route.ts
Normal 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
25
app/api/settings/route.ts
Normal 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 });
|
||||
}
|
||||
10
app/api/settings/server-ip/route.ts
Normal file
10
app/api/settings/server-ip/route.ts
Normal 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
42
app/api/setup/route.ts
Normal 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() });
|
||||
}
|
||||
12
app/api/tokens/[id]/route.ts
Normal file
12
app/api/tokens/[id]/route.ts
Normal 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
36
app/api/tokens/route.ts
Normal 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 });
|
||||
}
|
||||
13
app/api/update/apply/route.ts
Normal file
13
app/api/update/apply/route.ts
Normal 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);
|
||||
}
|
||||
21
app/api/update/check/route.ts
Normal file
21
app/api/update/check/route.ts
Normal 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());
|
||||
}
|
||||
29
app/api/v1/analytics/summary/route.ts
Normal file
29
app/api/v1/analytics/summary/route.ts
Normal 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 });
|
||||
}
|
||||
27
app/api/v1/domains/[id]/route.ts
Normal file
27
app/api/v1/domains/[id]/route.ts
Normal 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 });
|
||||
}
|
||||
27
app/api/v1/domains/[id]/stats/route.ts
Normal file
27
app/api/v1/domains/[id]/stats/route.ts
Normal 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 });
|
||||
}
|
||||
59
app/api/v1/domains/route.ts
Normal file
59
app/api/v1/domains/route.ts
Normal 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 });
|
||||
}
|
||||
5
app/api/v1/health/route.ts
Normal file
5
app/api/v1/health/route.ts
Normal 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
18
app/api/v1/hits/route.ts
Normal 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 });
|
||||
}
|
||||
6
app/api/v1/version/route.ts
Normal file
6
app/api/v1/version/route.ts
Normal 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
56
app/globals.css
Normal 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
18
app/layout.tsx
Normal 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
11
app/page.tsx
Normal 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
6
app/providers.tsx
Normal 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
14
components/Logo.tsx
Normal 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
11
components/PageHeader.tsx
Normal 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
65
components/Sidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
49
components/UpdateBanner.tsx
Normal file
49
components/UpdateBanner.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
19
components/charts/CountryPie.tsx
Normal file
19
components/charts/CountryPie.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
19
components/charts/HitsLineChart.tsx
Normal file
19
components/charts/HitsLineChart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
19
components/charts/TopDomainsBarChart.tsx
Normal file
19
components/charts/TopDomainsBarChart.tsx
Normal 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
33
components/ui/badge.tsx
Normal 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
44
components/ui/button.tsx
Normal 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
46
components/ui/card.tsx
Normal 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
72
components/ui/dialog.tsx
Normal 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 };
|
||||
103
components/ui/dropdown-menu.tsx
Normal file
103
components/ui/dropdown-menu.tsx
Normal 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
19
components/ui/input.tsx
Normal 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
18
components/ui/label.tsx
Normal 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 };
|
||||
18
components/ui/textarea.tsx
Normal file
18
components/ui/textarea.tsx
Normal 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
172
docs/API.md
Normal 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
100
docs/INSTALL.md
Normal 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
57
docs/UPDATE.md
Normal 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 ~5–10s 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
51
lib/api-auth.ts
Normal 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
54
lib/auth.ts
Normal 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
78
lib/caddy.ts
Normal 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
167
lib/db.ts
Normal 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
57
lib/dns.ts
Normal 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
35
lib/geo.ts
Normal 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
66
lib/hits.ts
Normal 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
57
lib/redirect-resolver.ts
Normal 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
118
lib/updater.ts
Normal 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
6
lib/utils.ts
Normal 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
24
middleware.ts
Normal 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
6
next.config.js
Normal 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
4577
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
44
package.json
Normal file
44
package.json
Normal 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
6
postcss.config.js
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
144
scripts/install.sh
Normal file
144
scripts/install.sh
Normal 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
25
scripts/update.sh
Normal 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
70
server.ts
Normal 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);
|
||||
});
|
||||
20
systemd/corex-nexredirect.service
Normal file
20
systemd/corex-nexredirect.service
Normal 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
73
tailwind.config.ts
Normal 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
21
tsconfig.json
Normal 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
26
types/next-auth.d.ts
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue