From 26725f9f15f6a2f41a18bc9ab299b035a68924ea Mon Sep 17 00:00:00 2001 From: Hendrik Date: Fri, 1 May 2026 18:31:21 +0200 Subject: [PATCH] =?UTF-8?q?v0.1.2=20=E2=80=94=20UX=20fixes:=20dark=20dropd?= =?UTF-8?q?owns,=20@=20for=20root=20in=20DNS=20table,=20copy=20fallback,?= =?UTF-8?q?=20geo=20settings=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(app)/domains/new/page.tsx | 97 +++++++++++++++++++++------------- app/(app)/groups/page.tsx | 6 +-- app/(app)/settings/page.tsx | 86 ++++++++++++++++++++++++++++-- app/api/settings/geo/route.ts | 69 ++++++++++++++++++++++++ lib/clipboard.ts | 26 +++++++++ lib/geo.ts | 9 ++++ 6 files changed, 249 insertions(+), 44 deletions(-) create mode 100644 app/api/settings/geo/route.ts create mode 100644 lib/clipboard.ts 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 ( -
- - - - - - - - - - - {records.map((r, i) => ( - - - - - +
+
+
TypNameWert
{r.type}{r.name}{r.value} - -
+ + + + + + - ))} - -
TypNameWert
+ + + {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() {
- setCode(Number(e.target.value) as 301 | 302)} className="flex h-9 w-full rounded-md border border-input bg-zinc-950 px-3 py-1 text-sm text-zinc-100"> + +
{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;