From f06e6e7df02dc3b5108c2da3cebecaa098f1dd71 Mon Sep 17 00:00:00 2001 From: Hendrik Date: Fri, 1 May 2026 22:16:52 +0200 Subject: [PATCH] =?UTF-8?q?v0.1.27=20=E2=80=94=20fix:=20blocklist=20self-c?= =?UTF-8?q?reates=20table;=20settings=20UI=20redesign=20mit=20overview-car?= =?UTF-8?q?ds=20+=20edit-popups?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(app)/settings/page.tsx | 594 +++++++++++++++++++----------------- app/api/v1/health/route.ts | 5 +- lib/blocklist.ts | 51 +++- package.json | 2 +- 4 files changed, 359 insertions(+), 293 deletions(-) diff --git a/app/(app)/settings/page.tsx b/app/(app)/settings/page.tsx index 6cec283..632d816 100644 --- a/app/(app)/settings/page.tsx +++ b/app/(app)/settings/page.tsx @@ -1,12 +1,13 @@ "use client"; import { useEffect, useState } from "react"; -import { Loader2, RefreshCcw, ArrowUpCircle, Globe2, CheckCircle2, Trash2, Mail, Send } from "lucide-react"; +import { Loader2, RefreshCcw, ArrowUpCircle, Globe2, CheckCircle2, Trash2, Mail, Send, Pencil, Server, Bell } 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, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; type Settings = { base_domain: string | null; @@ -26,23 +27,29 @@ type UpdateStatus = { auto_update: boolean; }; +type Smtp = { + smtp_host: string; + smtp_port: string; + smtp_user: string; + smtp_password: string; + smtp_from: string; + smtp_secure: string; +}; + 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 [accountId, setAccountId] = useState(""); - const [installingGeo, setInstallingGeo] = useState(false); - const [geoMsg, setGeoMsg] = useState(""); - const [smtp, setSmtp] = useState<{ smtp_host: string; smtp_port: string; smtp_user: string; smtp_password: string; smtp_from: string; smtp_secure: string }>({ smtp_host: "", smtp_port: "587", smtp_user: "", smtp_password: "", smtp_from: "", smtp_secure: "false" }); - const [smtpSaving, setSmtpSaving] = useState(false); - const [smtpTestTo, setSmtpTestTo] = useState(""); - const [smtpTestBusy, setSmtpTestBusy] = useState(false); - const [smtpTestMsg, setSmtpTestMsg] = useState(""); - const [saving, setSaving] = useState(false); + const [smtp, setSmtp] = useState(null); + const [checking, setChecking] = useState(false); const [applying, setApplying] = useState(false); - const [msg, setMsg] = useState(""); + const [updateMsg, setUpdateMsg] = useState(""); + + const [serverOpen, setServerOpen] = useState(false); + const [smtpOpen, setSmtpOpen] = useState(false); + const [geoOpen, setGeoOpen] = useState(false); + const [notifyOpen, setNotifyOpen] = useState(false); async function load() { const [s, u, g, m] = await Promise.all([ @@ -63,73 +70,13 @@ export default function SettingsPage() { smtp_secure: m.smtp_secure || "false", }); } - - async function saveSmtp() { - setSmtpSaving(true); - try { - await fetch("/api/settings/smtp", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(smtp) }); - } finally { setSmtpSaving(false); } - } - - async function sendTestMail() { - setSmtpTestBusy(true); - setSmtpTestMsg(""); - try { - const r = await fetch("/api/settings/smtp", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ to: smtpTestTo || undefined }) }); - const d = await r.json(); - setSmtpTestMsg(d.ok ? "Test-Mail verschickt." : `Fehler: ${d.error}`); - } finally { setSmtpTestBusy(false); } - } 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(), - ...(accountId.trim() ? { account_id: accountId.trim() } : {}), - }), - }); - const d = await r.json(); - if (r.ok) { - setGeoMsg("GeoLite2-DB installiert."); - setLicenseKey(""); - setAccountId(""); - load(); - } else { - const parts = [`Fehler: ${d.error || "Download fehlgeschlagen"}`]; - if (d.status) parts.push(`HTTP ${d.status}`); - if (d.detail) parts.push(d.detail); - if (d.hint) parts.push(`→ ${d.hint}`); - setGeoMsg(parts.join(" — ")); - } - } finally { - setInstallingGeo(false); - } - } - - async function removeGeo() { - if (!confirm("GeoIP-DB entfernen? Geo-Lookup wird deaktiviert.")) return; - await fetch("/api/settings/geo", { method: "DELETE" }); + async function saveSettings(patch: Partial) { + await fetch("/api/settings", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(patch) }); load(); } - async function save(patch: Partial) { - setSaving(true); - await fetch("/api/settings", { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(patch), - }); - setSettings((s) => s ? { ...s, ...patch } : s); - setSaving(false); - } - async function check() { setChecking(true); const r = await fetch("/api/update/check?force=1"); @@ -139,287 +86,368 @@ export default function SettingsPage() { async function waitForServerBack() { for (let i = 0; i < 60; i++) { - try { - const r = await fetch("/api/v1/health", { cache: "no-store" }); - if (r.ok) return true; - } catch {} + try { const r = await fetch("/api/v1/health", { cache: "no-store" }); if (r.ok) return true; } catch {} await new Promise((res) => setTimeout(res, 1000)); } return 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; + if (!confirm(`Update auf ${status?.latest} jetzt installieren?\n\nServer wird neu gestartet (kurze Downtime). Redirects bleiben über Caddy aktiv.`)) return; setApplying(true); - setMsg("Update läuft — bitte warten..."); - - // Fallback: reload after 90s no matter what (in case fetch/poll get stuck) + setUpdateMsg("Update läuft — bitte warten..."); const fallbackReload = setTimeout(() => window.location.reload(), 90_000); - try { let d: { ok?: boolean; from?: string; to?: string; error?: string } = {}; try { const r = await fetch("/api/update/apply", { method: "POST" }); try { d = await r.json(); } catch {} if (!r.ok && !d.error) d.error = r.statusText; - } catch (e) { - // Connection drop = server probably restarted mid-response (legacy update.sh) - d.error = e instanceof Error ? e.message : "connection_lost"; - } + } catch (e) { d.error = e instanceof Error ? e.message : "connection_lost"; } - if (d.error === "no_update") { - setMsg("Bereits auf aktueller Version."); - clearTimeout(fallbackReload); - await fetch("/api/update/check?force=1").catch(() => {}); - load(); - setApplying(false); - return; - } + if (d.error === "no_update") { setUpdateMsg("Bereits aktuell."); clearTimeout(fallbackReload); load(); setApplying(false); return; } + if (d.error && !d.to) { setUpdateMsg(`Fehler: ${d.error}`); clearTimeout(fallbackReload); setApplying(false); return; } - if (d.error && !d.to) { - setMsg(`Fehler: ${d.error}`); - clearTimeout(fallbackReload); - setApplying(false); - return; - } - - setMsg(d.to ? `Update gezogen (${d.from} → ${d.to}). Server startet neu...` : "Server startet neu..."); + setUpdateMsg(d.to ? `Update gezogen (${d.from} → ${d.to}). Server startet neu...` : "Server startet neu..."); await new Promise((res) => setTimeout(res, 3000)); - const back = await waitForServerBack(); - setMsg(back ? "Server zurück. Lade Seite neu..." : "Restart dauert ungewöhnlich lang — lade trotzdem neu."); + await waitForServerBack(); clearTimeout(fallbackReload); window.location.reload(); } catch (e) { - setMsg(`Fehler: ${e instanceof Error ? e.message : String(e)} — lade in 5s neu.`); + setUpdateMsg(`Fehler: ${e instanceof Error ? e.message : String(e)}`); setTimeout(() => window.location.reload(), 5000); } } - if (!settings || !status || !geo) { + if (!settings || !status || !geo || !smtp) { return
; } return (
- + -
+
+ {/* Updates */} - Updates {status.update_available && Verfügbar} + + Updates + {status.update_available && Verfügbar} + {!status.update_available && Aktuell} - Aktuelle Version {status.current} + Aktuell {status.current} {status.latest && <> • Neueste {status.latest}} - -
+ +
{status.update_available && !applying && ( - )} - {!status.update_available && !applying && ( - ✓ Aktuelle Version - )} - {status.release_url && Release-Notes →} + {status.release_url && Notes →}
{applying && (
- - {msg || "Update läuft..."} + {updateMsg}
)} -
-
+ + setServerOpen(false)} settings={settings} onSave={saveSettings} /> + setSmtpOpen(false)} initial={smtp} onSaved={load} /> + setGeoOpen(false)} status={geo} onChanged={load} /> + setNotifyOpen(false)} settings={settings} onSave={saveSettings} />
); } + +function Row({ k, children }: { k: string; children: React.ReactNode }) { + return ( +
+ {k} + {children} +
+ ); +} + +function ServerDialog({ open, onClose, settings, onSave }: { open: boolean; onClose: () => void; settings: Settings; onSave: (p: Partial) => Promise }) { + const [base, setBase] = useState(settings.base_domain ?? ""); + const [email, setEmail] = useState(settings.admin_email ?? ""); + const [busy, setBusy] = useState(false); + useEffect(() => { if (open) { setBase(settings.base_domain ?? ""); setEmail(settings.admin_email ?? ""); } }, [open, settings]); + return ( + !v && onClose()}> + + ServerAdmin-Domain für die UI und ACME-Email für Let's Encrypt. +
+
setBase(e.target.value)} placeholder="admin.beispiel.de" />
+
setEmail(e.target.value)} placeholder="admin@beispiel.de" />
+
+ + + + +
+
+ ); +} + +function SmtpDialog({ open, onClose, initial, onSaved }: { open: boolean; onClose: () => void; initial: Smtp; onSaved: () => void }) { + const [s, setS] = useState(initial); + const [busy, setBusy] = useState(false); + const [testTo, setTestTo] = useState(""); + const [testBusy, setTestBusy] = useState(false); + const [testMsg, setTestMsg] = useState(""); + useEffect(() => { if (open) { setS(initial); setTestMsg(""); } }, [open, initial]); + + async function save() { + setBusy(true); + try { + await fetch("/api/settings/smtp", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(s) }); + onSaved(); + onClose(); + } finally { setBusy(false); } + } + + async function test() { + setTestBusy(true); setTestMsg(""); + try { + // Save first, then test + await fetch("/api/settings/smtp", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(s) }); + const r = await fetch("/api/settings/smtp", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ to: testTo || undefined }) }); + const d = await r.json(); + setTestMsg(d.ok ? "✓ Test-Mail verschickt." : `Fehler: ${d.error}`); + } finally { setTestBusy(false); } + } + + return ( + !v && onClose()}> + + SMTP konfigurierenMail-Server für Passwort-Reset und Benachrichtigungen. +
+
+
setS({ ...s, smtp_host: e.target.value })} placeholder="smtp.example.com" />
+
setS({ ...s, smtp_port: e.target.value })} />
+
+
+
setS({ ...s, smtp_user: e.target.value })} autoComplete="off" />
+
setS({ ...s, smtp_password: e.target.value })} autoComplete="new-password" placeholder={s.smtp_password === "***" ? "(unverändert)" : ""} />
+
+
setS({ ...s, smtp_from: e.target.value })} placeholder='"NexRedirect" ' />
+ +
+

Test-Mail an:

+
+ setTestTo(e.target.value)} placeholder="(leer = eigene Email)" /> + +
+ {testMsg &&

{testMsg}

} +
+
+ + + + +
+
+ ); +} + +function GeoDialog({ open, onClose, status, onChanged }: { open: boolean; onClose: () => void; status: { available: boolean; path: string }; onChanged: () => void }) { + const [accountId, setAccountId] = useState(""); + const [licenseKey, setLicenseKey] = useState(""); + const [busy, setBusy] = useState(false); + const [msg, setMsg] = useState(""); + + async function install() { + if (!licenseKey.trim()) return; + setBusy(true); setMsg(""); + try { + const r = await fetch("/api/settings/geo", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ license_key: licenseKey.trim(), ...(accountId.trim() ? { account_id: accountId.trim() } : {}) }) }); + const d = await r.json(); + if (r.ok) { setMsg("✓ DB installiert."); setLicenseKey(""); setAccountId(""); onChanged(); setTimeout(onClose, 800); } + else { + const parts = [`Fehler: ${d.error}`]; + if (d.status) parts.push(`HTTP ${d.status}`); + if (d.hint) parts.push(`→ ${d.hint}`); + setMsg(parts.join(" — ")); + } + } finally { setBusy(false); } + } + + async function remove() { + if (!confirm("GeoIP-DB entfernen? Geo-Lookup wird deaktiviert.")) return; + await fetch("/api/settings/geo", { method: "DELETE" }); + onChanged(); + onClose(); + } + + return ( + !v && onClose()}> + + + GeoIP-Tracking + MaxMind GeoLite2-Country. Lizenz-Key kostenlos auf maxmind.com. + + {status.available ? ( +
+

DB installiert: {status.path}

+ +
+ ) : ( +
+
setAccountId(e.target.value)} placeholder="123456" />
+
setLicenseKey(e.target.value)} />
+ {msg &&

{msg}

} + +
+ )} + +
+
+ ); +} + +function NotifyDialog({ open, onClose, settings, onSave }: { open: boolean; onClose: () => void; settings: Settings; onSave: (p: Partial) => Promise }) { + const [retention, setRetention] = useState(settings.hits_retention_days ?? "365"); + const [webhook, setWebhook] = useState(settings.webhook_url ?? ""); + const [busy, setBusy] = useState(false); + useEffect(() => { if (open) { setRetention(settings.hits_retention_days ?? "365"); setWebhook(settings.webhook_url ?? ""); } }, [open, settings]); + return ( + !v && onClose()}> + + Benachrichtigungen & Retention +
+
+ + setRetention(e.target.value)} /> +

Hits älter als diese Anzahl Tage werden täglich gelöscht. 0 = nie.

+
+
+ + setWebhook(e.target.value)} placeholder="https://hooks.example.com/..." /> +

POST mit JSON bei Domain-Verify-Fail. Leer = aus.

+
+
+ + + + +
+
+ ); +} diff --git a/app/api/v1/health/route.ts b/app/api/v1/health/route.ts index da0dd50..7659b33 100644 --- a/app/api/v1/health/route.ts +++ b/app/api/v1/health/route.ts @@ -23,7 +23,10 @@ export async function GET() { `).get() as { total: number; active: number; pending: number; errored: number }; const hits24h = (db.prepare("SELECT COUNT(*) AS n FROM hits WHERE ts > ?").get(since24h) as { n: number }).n; - const blocklist = (db.prepare("SELECT COUNT(*) AS n FROM ip_blocklist WHERE expires_at > ?").get(Date.now()) as { n: number }).n; + let blocklist = 0; + try { + blocklist = (db.prepare("SELECT COUNT(*) AS n FROM ip_blocklist WHERE expires_at > ?").get(Date.now()) as { n: number }).n; + } catch {} return NextResponse.json({ ok: true, diff --git a/lib/blocklist.ts b/lib/blocklist.ts index bf530a1..6814707 100644 --- a/lib/blocklist.ts +++ b/lib/blocklist.ts @@ -1,21 +1,56 @@ import { getDb } from "./db"; +// Resilient: never throw — if table missing, treat as "not blocked / no-op" +// so a redirect path is never broken by blocklist plumbing. + +function ensureTable() { + try { + getDb().exec(` + CREATE TABLE IF NOT EXISTS ip_blocklist ( + ip_hash TEXT PRIMARY KEY, + blocked_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL, + reason TEXT + ); + CREATE INDEX IF NOT EXISTS idx_blocklist_expires ON ip_blocklist(expires_at); + `); + } catch {} +} + +let tableEnsured = false; +function ensureOnce() { + if (tableEnsured) return; + ensureTable(); + tableEnsured = true; +} + export function isBlocked(ipHash: string): boolean { - const row = getDb().prepare("SELECT 1 FROM ip_blocklist WHERE ip_hash = ? AND expires_at > ?").get(ipHash, Date.now()); - return !!row; + try { + ensureOnce(); + const row = getDb().prepare("SELECT 1 FROM ip_blocklist WHERE ip_hash = ? AND expires_at > ?").get(ipHash, Date.now()); + return !!row; + } catch { + return false; + } } export function block(ipHash: string, hours = 24, reason = "scanner") { - const expiresAt = Date.now() + hours * 60 * 60 * 1000; - getDb() - .prepare("INSERT INTO ip_blocklist (ip_hash, blocked_at, expires_at, reason) VALUES (?, ?, ?, ?) ON CONFLICT(ip_hash) DO UPDATE SET expires_at = excluded.expires_at, reason = excluded.reason") - .run(ipHash, Date.now(), expiresAt, reason); + try { + ensureOnce(); + const expiresAt = Date.now() + hours * 60 * 60 * 1000; + getDb() + .prepare("INSERT INTO ip_blocklist (ip_hash, blocked_at, expires_at, reason) VALUES (?, ?, ?, ?) ON CONFLICT(ip_hash) DO UPDATE SET expires_at = excluded.expires_at, reason = excluded.reason") + .run(ipHash, Date.now(), expiresAt, reason); + } catch {} } export function unblock(ipHash: string) { - getDb().prepare("DELETE FROM ip_blocklist WHERE ip_hash = ?").run(ipHash); + try { ensureOnce(); getDb().prepare("DELETE FROM ip_blocklist WHERE ip_hash = ?").run(ipHash); } catch {} } export function listBlocked(): { ip_hash: string; blocked_at: number; expires_at: number; reason: string }[] { - return getDb().prepare("SELECT ip_hash, blocked_at, expires_at, reason FROM ip_blocklist WHERE expires_at > ? ORDER BY blocked_at DESC").all(Date.now()) as { ip_hash: string; blocked_at: number; expires_at: number; reason: string }[]; + try { + ensureOnce(); + return getDb().prepare("SELECT ip_hash, blocked_at, expires_at, reason FROM ip_blocklist WHERE expires_at > ? ORDER BY blocked_at DESC").all(Date.now()) as { ip_hash: string; blocked_at: number; expires_at: number; reason: string }[]; + } catch { return []; } } diff --git a/package.json b/package.json index 111710b..f662362 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "corex-nexredirect", - "version": "0.1.26", + "version": "0.1.27", "license": "MIT", "overrides": { "postcss": "^8.5.13",