diff --git a/app/(app)/domains/new/page.tsx b/app/(app)/domains/new/page.tsx
index da332d3..f82d890 100644
--- a/app/(app)/domains/new/page.tsx
+++ b/app/(app)/domains/new/page.tsx
@@ -1,13 +1,14 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
-import { Loader2, Copy, CheckCircle2, AlertTriangle, ArrowRight } from "lucide-react";
+import { Loader2, Copy, Check, 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";
+import { copyToClipboard } from "@/lib/clipboard";
type Group = { id: number; name: string; target_url: string };
@@ -132,11 +133,11 @@ export default function NewDomainPage() {
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"
+ className="flex h-9 w-full rounded-md border border-input bg-zinc-950 px-3 py-1 text-sm text-zinc-100 shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
-
+
{groups.map((g) => (
-
+
))}
@@ -149,10 +150,10 @@ export default function NewDomainPage() {
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"
+ className="flex h-9 w-full rounded-md border border-input bg-zinc-950 px-3 py-1 text-sm text-zinc-100"
>
-
-
+
+
@@ -266,41 +267,61 @@ function StepIndicator({ step }: { step: 1 | 2 | 3 }) {
function DnsRecordsTable({ domain, ipv4, ipv6, includeWww }: { domain: string; ipv4?: string; ipv6?: string; includeWww: boolean }) {
const records = [
- { type: "A", name: domain, value: ipv4 || "
" },
- ...(ipv6 ? [{ type: "AAAA", name: domain, value: ipv6 }] : []),
- ...(includeWww ? [{ type: "A", name: `www.${domain}`, value: ipv4 || "" }] : []),
+ { type: "A", name: "@", note: `(Root: ${domain})`, value: ipv4 || "" },
+ ...(ipv6 ? [{ type: "AAAA", name: "@", note: `(Root: ${domain})`, value: ipv6 }] : []),
+ ...(includeWww ? [{ type: "A", name: "www", note: `(www.${domain})`, value: ipv4 || "" }] : []),
];
+ const [copied, setCopied] = useState(null);
+ async function copy(idx: number, val: string) {
+ const ok = await copyToClipboard(val);
+ if (ok) {
+ setCopied(idx);
+ setTimeout(() => setCopied(null), 1500);
+ } else {
+ alert("Kopieren fehlgeschlagen — bitte manuell markieren und kopieren.");
+ }
+ }
+
return (
-
-
-
-
- | Typ |
- Name |
- Wert |
- |
-
-
-
- {records.map((r, i) => (
-
- | {r.type} |
- {r.name} |
- {r.value} |
-
-
- |
+
+
+
+
+
+ | Typ |
+ Name |
+ Wert |
+ |
- ))}
-
-
+
+
+ {records.map((r, i) => (
+
+ | {r.type} |
+
+ {r.name}
+ {r.note}
+ |
+ {r.value} |
+
+
+ |
+
+ ))}
+
+
+
+
+ Hinweis: @ steht für die Root-Domain. Manche DNS-Provider erwarten stattdessen {domain} oder ein leeres Feld.
+
);
}
diff --git a/app/(app)/groups/page.tsx b/app/(app)/groups/page.tsx
index 0039910..3cf6e90 100644
--- a/app/(app)/groups/page.tsx
+++ b/app/(app)/groups/page.tsx
@@ -89,9 +89,9 @@ export default function GroupsPage() {
-
{error && {error}
}
diff --git a/app/(app)/settings/page.tsx b/app/(app)/settings/page.tsx
index dd49c1c..c26ae5e 100644
--- a/app/(app)/settings/page.tsx
+++ b/app/(app)/settings/page.tsx
@@ -1,6 +1,6 @@
"use client";
import { useEffect, useState } from "react";
-import { Loader2, RefreshCcw, ArrowUpCircle } from "lucide-react";
+import { Loader2, RefreshCcw, ArrowUpCircle, Globe2, CheckCircle2, Trash2 } from "lucide-react";
import { PageHeader } from "@/components/PageHeader";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
@@ -27,21 +27,56 @@ type UpdateStatus = {
export default function SettingsPage() {
const [settings, setSettings] = useState(null);
const [status, setStatus] = useState(null);
+ const [geo, setGeo] = useState<{ available: boolean; path: string } | null>(null);
+ const [licenseKey, setLicenseKey] = useState("");
+ const [installingGeo, setInstallingGeo] = useState(false);
+ const [geoMsg, setGeoMsg] = useState("");
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([
+ const [s, u, g] = await Promise.all([
fetch("/api/settings").then((r) => r.json()),
fetch("/api/update/check").then((r) => r.json()),
+ fetch("/api/settings/geo").then((r) => r.json()),
]);
setSettings(s);
setStatus(u);
+ setGeo(g);
}
useEffect(() => { load(); }, []);
+ async function installGeo() {
+ if (!licenseKey.trim()) return;
+ setInstallingGeo(true);
+ setGeoMsg("");
+ try {
+ const r = await fetch("/api/settings/geo", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ license_key: licenseKey.trim() }),
+ });
+ const d = await r.json();
+ if (r.ok) {
+ setGeoMsg("GeoLite2-DB installiert.");
+ setLicenseKey("");
+ load();
+ } else {
+ setGeoMsg(`Fehler: ${d.error || "Download fehlgeschlagen"}`);
+ }
+ } finally {
+ setInstallingGeo(false);
+ }
+ }
+
+ async function removeGeo() {
+ if (!confirm("GeoIP-DB entfernen? Geo-Lookup wird deaktiviert.")) return;
+ await fetch("/api/settings/geo", { method: "DELETE" });
+ load();
+ }
+
async function save(patch: Partial) {
setSaving(true);
await fetch("/api/settings", {
@@ -74,7 +109,7 @@ export default function SettingsPage() {
}
}
- if (!settings || !status) {
+ if (!settings || !status || !geo) {
return
;
}
@@ -151,6 +186,51 @@ export default function SettingsPage() {
+
+
+
+
+
+ GeoIP-Tracking
+ {geo.available
+ ? aktiv
+ : deaktiviert}
+
+
+ MaxMind GeoLite2-Country für Land-Auflösung pro Hit. Lizenz-Key kostenlos unter maxmind.com generieren.
+
+
+
+ {geo.available ? (
+
+
DB-Pfad: {geo.path}
+
+
+ ) : (
+
+
+
+ setLicenseKey(e.target.value)}
+ disabled={installingGeo}
+ />
+
+
+
Lädt GeoLite2-Country.mmdb herunter und aktiviert Geo-Lookup.
+
+ )}
+ {geoMsg && {geoMsg}
}
+
+
);
diff --git a/app/api/settings/geo/route.ts b/app/api/settings/geo/route.ts
new file mode 100644
index 0000000..b214974
--- /dev/null
+++ b/app/api/settings/geo/route.ts
@@ -0,0 +1,69 @@
+import { NextResponse } from "next/server";
+import { z } from "zod";
+import { spawn } from "child_process";
+import path from "path";
+import fs from "fs/promises";
+import os from "os";
+import { getServerSession } from "next-auth";
+import { authOptions } from "@/lib/auth";
+import { resetGeoReader, geoStatus } from "@/lib/geo";
+
+const DATA_DIR = process.env.NEXREDIRECT_DATA_DIR || path.join(process.cwd(), "data");
+const MMDB_PATH = process.env.NEXREDIRECT_GEOIP_PATH || path.join(DATA_DIR, "GeoLite2-Country.mmdb");
+
+export async function GET() {
+ const session = await getServerSession(authOptions);
+ if (!session) return NextResponse.json({ error: "unauthorized" }, { status: 401 });
+ return NextResponse.json(geoStatus());
+}
+
+const schema = z.object({
+ license_key: z.string().min(10),
+});
+
+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" }, { status: 400 });
+
+ const url = `https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-Country&license_key=${encodeURIComponent(parsed.data.license_key)}&suffix=tar.gz`;
+
+ try {
+ const res = await fetch(url);
+ if (!res.ok) return NextResponse.json({ error: "download_failed", status: res.status }, { status: 502 });
+ const buf = Buffer.from(await res.arrayBuffer());
+ const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "geo-"));
+ const tarPath = path.join(tmpDir, "geo.tgz");
+ await fs.writeFile(tarPath, buf);
+
+ await new Promise((resolve, reject) => {
+ const p = spawn("tar", ["-xzf", tarPath, "-C", tmpDir]);
+ p.on("error", reject);
+ p.on("exit", (code) => (code === 0 ? resolve() : reject(new Error(`tar exit ${code}`))));
+ });
+
+ const entries = await fs.readdir(tmpDir, { recursive: true }) as string[];
+ const mmdb = entries.find((e) => e.endsWith("GeoLite2-Country.mmdb"));
+ if (!mmdb) return NextResponse.json({ error: "mmdb_not_found_in_archive" }, { status: 500 });
+
+ await fs.mkdir(path.dirname(MMDB_PATH), { recursive: true });
+ await fs.copyFile(path.join(tmpDir, mmdb), MMDB_PATH);
+ await fs.rm(tmpDir, { recursive: true, force: true });
+
+ resetGeoReader();
+ return NextResponse.json({ ok: true, path: MMDB_PATH });
+ } catch (e) {
+ return NextResponse.json({ error: e instanceof Error ? e.message : String(e) }, { status: 500 });
+ }
+}
+
+export async function DELETE() {
+ const session = await getServerSession(authOptions);
+ if (!session) return NextResponse.json({ error: "unauthorized" }, { status: 401 });
+ await fs.unlink(MMDB_PATH).catch(() => {});
+ resetGeoReader();
+ return NextResponse.json({ ok: true });
+}
diff --git a/lib/clipboard.ts b/lib/clipboard.ts
new file mode 100644
index 0000000..4902fe6
--- /dev/null
+++ b/lib/clipboard.ts
@@ -0,0 +1,26 @@
+export async function copyToClipboard(text: string): Promise {
+ try {
+ if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) {
+ await navigator.clipboard.writeText(text);
+ return true;
+ }
+ } catch {
+ // fall through
+ }
+ try {
+ const el = document.createElement("textarea");
+ el.value = text;
+ el.style.position = "fixed";
+ el.style.left = "-9999px";
+ el.style.top = "0";
+ el.setAttribute("readonly", "");
+ document.body.appendChild(el);
+ el.select();
+ el.setSelectionRange(0, text.length);
+ const ok = document.execCommand("copy");
+ document.body.removeChild(el);
+ return ok;
+ } catch {
+ return false;
+ }
+}
diff --git a/lib/geo.ts b/lib/geo.ts
index 82f9d39..e22930f 100644
--- a/lib/geo.ts
+++ b/lib/geo.ts
@@ -23,6 +23,15 @@ async function getReader(): Promise {
}
}
+export function resetGeoReader() {
+ _reader = null;
+ _loadAttempted = false;
+}
+
+export function geoStatus(): { available: boolean; path: string } {
+ return { available: fs.existsSync(MMDB_PATH), path: MMDB_PATH };
+}
+
export async function lookupCountry(ip: string): Promise {
const r = await getReader();
if (!r) return null;