"use strict"; // ------------------------------------------------------------------ Grundlagen const $ = (sel, el = document) => el.querySelector(sel); const esc = (v) => String(v ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); const state = { token: null, me: null, users: [], timer: null }; try { state.token = localStorage.getItem("proxy_token"); } catch (_) { /* ignore */ } async function api(path, opts = {}) { const headers = { ...(opts.headers || {}) }; if (state.token) headers.Authorization = "Bearer " + state.token; let body = opts.body; if (body && typeof body !== "string") { body = JSON.stringify(body); headers["Content-Type"] = "application/json"; } const res = await fetch(path, { ...opts, headers, body }); if (res.status === 401 && !path.startsWith("/api/auth/login")) { logout(true); throw new Error("Sitzung abgelaufen"); } if (opts.raw) { if (!res.ok) throw new Error(res.statusText); return res; } const data = res.headers.get("content-type")?.includes("json") ? await res.json() : await res.text(); if (!res.ok) { let msg = data?.detail ?? res.statusText; if (Array.isArray(msg)) msg = msg.map((d) => `${(d.loc || []).slice(1).join(".")}: ${d.msg}`).join("; "); throw new Error(msg); } return data; } function toast(msg, err = false) { const t = $("#toast"); t.textContent = msg; t.className = "toast" + (err ? " err" : ""); t.hidden = false; clearTimeout(toast._t); toast._t = setTimeout(() => (t.hidden = true), err ? 6000 : 2800); } const fmtBytes = (n) => { n = Number(n) || 0; const u = ["B", "KB", "MB", "GB", "TB"]; let i = 0; while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; } return (i ? n.toFixed(n < 10 ? 1 : 0) : n) + " " + u[i]; }; const fmtNum = (n) => Number(n || 0).toLocaleString("de-DE"); const fmtTime = (ts) => ts ? new Date(ts * 1000).toLocaleString("de-DE") : "–"; const fmtDur = (s) => { s = Number(s) || 0; if (s < 60) return s.toFixed(1) + " s"; if (s < 3600) return Math.floor(s / 60) + " min"; return (s / 3600).toFixed(1) + " h"; }; const ago = (ts) => { if (!ts) return "nie"; const s = Date.now() / 1000 - ts; if (s < 60) return "gerade eben"; if (s < 3600) return `vor ${Math.floor(s / 60)} min`; if (s < 86400) return `vor ${Math.floor(s / 3600)} h`; return `vor ${Math.floor(s / 86400)} Tagen`; }; const STATUS = { ok: ["ok", "OK"], blocked: ["bad", "Gesperrt"], error: ["warn", "Fehler"], auth_failed: ["bad", "Login fehlgeschl."], }; const statusBadge = (s) => { const [c, l] = STATUS[s] || ["", s]; return `${esc(l)}`; }; const PROTO = { http: "HTTP", https: "HTTPS", connect: "CONNECT", socks: "SOCKS5", "socks-udp": "SOCKS5 UDP", "fwd-tcp": "Weiterl. TCP", "fwd-udp": "Weiterl. UDP" }; const protoBadge = (p) => `${esc(PROTO[p] || p || "–")}`; const policyLabel = { inherit: "Wie global", allow: "Alles erlauben (Blacklist)", deny: "Alles sperren (Whitelist)" }; // ------------------------------------------------------------------ Modal const KEEP_OPEN = Symbol("keep"); // onSubmit(data) darf werfen (Fehler wird angezeigt) oder KEEP_OPEN liefern (Dialog bleibt offen). function modal(title, fieldsHtml, { submit = "Speichern", danger = false, onSubmit = null } = {}) { const dlg = $("#modal"); const form = $("#modal-form"); form.innerHTML = `

${esc(title)}

${fieldsHtml} `; dlg.showModal(); return new Promise((resolve) => { let busy = false; form.onsubmit = async (e) => { if (e.submitter?.value !== "ok") return; // Abbrechen schließt per method=dialog e.preventDefault(); if (busy) return; const data = {}; for (const el of form.elements) if (el.name) data[el.name] = el.type === "checkbox" ? el.checked : el.value; if (!onSubmit) { resolve(data); dlg.close(); return; } busy = true; $("#modal-error").textContent = ""; try { const out = await onSubmit(data); if (out !== KEEP_OPEN) { resolve(out ?? true); dlg.close(); } } catch (err) { $("#modal-error").textContent = err.message; } busy = false; }; dlg.onclose = () => resolve(null); }); } const modalLoop = (title, html, action, opts = {}) => modal(title, html, { ...opts, onSubmit: action }); const confirmBox = (text, submit = "Löschen") => modal("Bitte bestätigen", `

${esc(text)}

`, { submit, danger: true }); // ------------------------------------------------------------------ Navigation const PAGES_ADMIN = [ ["dashboard", "◧", "Übersicht"], ["users", "☺", "Benutzer"], ["rules", "⛨", "Regeln"], ["forwards", "⇆", "Weiterleitungen"], ["history", "☰", "Verlauf"], ["connections", "⚡", "Live-Verbindungen"], ["settings", "⚙", "Einstellungen"], ["api", "⌘", "API"], ]; const PAGES_USER = [["myhistory", "☰", "Mein Verlauf"], ["help", "?", "Einrichtung"]]; function renderNav() { const pages = state.me.is_admin ? PAGES_ADMIN : PAGES_USER; const current = location.hash.slice(1).split("?")[0] || pages[0][0]; $("#nav").innerHTML = pages.map(([id, ico, label]) => `${ico}${label}`).join(""); } async function route() { clearInterval(state.timer); const pages = state.me.is_admin ? PAGES_ADMIN : PAGES_USER; const [page, query] = location.hash.slice(1).split("?"); const id = pages.some((p) => p[0] === page) ? page : pages[0][0]; renderNav(); const params = new URLSearchParams(query || ""); try { await VIEWS[id]($("#main"), params); } catch (e) { $("#main").innerHTML = `
${esc(e.message)}
`; } } const head = (title, sub = "", actions = "") => `

${esc(title)}

${sub ? `

${sub}

` : ""}
${actions}
`; async function loadUsers() { state.users = await api("/api/users"); return state.users; } const userOptions = (selected, withGlobal = true) => (withGlobal ? `` : "") + state.users.map((u) => ``).join(""); // ------------------------------------------------------------------ Diagramm function barChart(el, points, bucket) { if (!points.length) { el.innerHTML = `
Noch keine Daten im Zeitraum
`; return; } const W = Math.max(el.clientWidth, 300), H = 180, padL = 40, padB = 22, padT = 8; const max = Math.max(...points.map((p) => p.n), 1); const step = (W - padL) / points.length; const bw = Math.max(Math.min(step - 2, 28), 2); const y = (v) => padT + (H - padT - padB) * (1 - v / max); const ticks = [0, Math.round(max / 2), max]; const fmtLabel = (ts) => bucket >= 86400 ? new Date(ts * 1000).toLocaleDateString("de-DE", { day: "2-digit", month: "2-digit" }) : new Date(ts * 1000).toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" }); const every = Math.ceil(points.length / Math.floor((W - padL) / 60)); let svg = ``; for (const t of ticks) svg += `${fmtNum(t)}`; points.forEach((p, i) => { const x = padL + i * step + (step - bw) / 2; const top = y(p.n), h = Math.max(H - padB - top, p.n ? 1 : 0); const r = Math.min(4, bw / 2, h); // oben abgerundet, unten an der Grundlinie gerade const path = h > 0 ? `M${x},${top + h} V${top + r} Q${x},${top} ${x + r},${top} H${x + bw - r} Q${x + bw},${top} ${x + bw},${top + r} V${top + h} Z` : ""; svg += ``; svg += ``; if (i % every === 0) svg += `${fmtLabel(p.b)}`; }); svg += ""; el.innerHTML = svg + ``; const tip = $(".chart-tip", el); el.querySelector("svg").addEventListener("mousemove", (e) => { const i = e.target.dataset?.i; el.querySelectorAll(".bar.hover").forEach((b) => b.classList.remove("hover")); if (i === undefined) { tip.hidden = true; return; } const p = points[i]; el.querySelector(`.bar[data-i="${i}"]`)?.classList.add("hover"); const rect = el.getBoundingClientRect(); tip.hidden = false; tip.style.left = (e.clientX - rect.left) + "px"; tip.style.top = (e.clientY - rect.top) + "px"; tip.innerHTML = `${esc(bucket >= 86400 ? new Date(p.b * 1000).toLocaleDateString("de-DE") : fmtTime(p.b))}
${fmtNum(p.n)} Anfragen · ${fmtNum(p.blocked)} gesperrt`; }); el.querySelector("svg").addEventListener("mouseleave", () => { tip.hidden = true; el.querySelectorAll(".bar.hover").forEach((b) => b.classList.remove("hover")); }); } function fillBuckets(timeline, hours, bucket) { const map = new Map(timeline.map((t) => [t.b, t])); const end = Math.floor(Date.now() / 1000 / bucket) * bucket; const n = Math.ceil(hours * 3600 / bucket); const out = []; for (let i = n - 1; i >= 0; i--) { const b = end - i * bucket; out.push(map.get(b) || { b, n: 0, blocked: 0 }); } return out; } // ------------------------------------------------------------------ Seiten const VIEWS = {}; VIEWS.dashboard = async (main, params) => { const hours = Number(params.get("h") || 24); const s = await api(`/api/stats?hours=${hours}`); const t = s.totals; const rangeSel = ``; const blockedPct = t.total ? ((t.blocked / t.total) * 100).toFixed(1) : "0"; const list = (rows, cols) => rows.length ? `
${rows.map(cols).join("")}
` : `
Keine Daten
`; main.innerHTML = head("Übersicht", "Aktivität des Proxys", rangeSel) + `
Anfragen
${fmtNum(t.total)}
${fmtNum(t.users)} aktive Benutzer
Gesperrt
${fmtNum(t.blocked)}
${blockedPct} % aller Anfragen
Datenvolumen
${fmtBytes(t.up + t.down)}
↑ ${fmtBytes(t.up)} · ↓ ${fmtBytes(t.down)}
Live-Verbindungen
${fmtNum(s.active_connections)}
${fmtNum(s.user_count)} Benutzer angelegt
Fehler / Fehllogins
${fmtNum(t.errors)} / ${fmtNum(t.auth_failed)}
Verbindungsfehler / Anmeldung

Anfragen pro ${s.bucket >= 86400 ? "Tag" : "Stunde"}

Meistbesuchte Ziele

${list(s.top_hosts, (r) => `${esc(r.host)}${fmtNum(r.n)}${fmtBytes(r.bytes)}`)}

Meistgesperrte Ziele

${list(s.top_blocked, (r) => `${esc(r.host)}${fmtNum(r.n)}`)}

Benutzer nach Datenvolumen

${list(s.top_users, (r) => `${esc(r.username)}${fmtNum(r.n)}${fmtBytes(r.bytes)}`)}
`; $("#range").onchange = (e) => (location.hash = `dashboard?h=${e.target.value}`); barChart($("#chart"), fillBuckets(s.timeline, hours, s.bucket), s.bucket); }; // ---- Benutzer function userForm(u = {}) { const pol = u.policy || "inherit"; return `
`; } VIEWS.users = async (main) => { const users = await loadUsers(); main.innerHTML = head("Benutzer", "Wer den Proxy benutzen darf", ``) + `
${users.map((u) => ``).join("") || ``}
BenutzerStatusRichtlinieEigene RegelnZuletzt aktivKommentar
${esc(u.username)} ${u.is_admin ? 'Admin' : ""} ${u.enabled ? 'Aktiv' : 'Gesperrt'} ${esc(policyLabel[u.policy])} ${u.rule_count} ${ago(u.last_seen)} ${esc(u.comment)} Verlauf
Keine Benutzer
`; $("#add").onclick = async () => { const ok = await modalLoop("Benutzer anlegen", userForm(), (d) => api("/api/users", { method: "POST", body: d })); if (ok) { toast("Benutzer angelegt"); route(); } }; main.onclick = async (e) => { const edit = e.target.dataset.edit, del = e.target.dataset.del; if (edit) { const u = users.find((x) => x.id == edit); const ok = await modalLoop(`Benutzer „${u.username}“ bearbeiten`, userForm(u), (d) => { if (!d.password) delete d.password; return api(`/api/users/${edit}`, { method: "PUT", body: d }); }); if (ok) { toast("Gespeichert"); route(); } } if (del) { const u = users.find((x) => x.id == del); if (await confirmBox(`Benutzer „${u.username}“ inkl. seiner Regeln löschen?`)) { try { await api(`/api/users/${del}`, { method: "DELETE" }); toast("Gelöscht"); route(); } catch (err) { toast(err.message, true); } } } }; }; // ---- Regeln function ruleForm(r = {}) { return `
`; } const ruleBody = (d) => ({ ...d, user_id: d.user_id ? Number(d.user_id) : null }); VIEWS.rules = async (main, params) => { await loadUsers(); const userFilter = params.get("user") || ""; const rules = await api(userFilter ? `/api/rules?user_id=${userFilter}` : "/api/rules"); const filterSel = ``; main.innerHTML = head("Regeln", "Black- und Whitelist für Domains, IPs und Ports", `${filterSel}`) + `
So wird ausgewertet: Benutzer-Sperren → Benutzer-Erlaubnisse → globale Sperren → globale Erlaubnisse → Standardrichtlinie. Benutzerregeln haben also Vorrang vor globalen Regeln. Für eine reine Whitelist die Standardrichtlinie (global oder pro Benutzer) auf „Alles sperren“ stellen und Erlaubt-Regeln anlegen.
${rules.map((r) => ``).join("") || ``}
Gilt fürAktionHost / NetzPortsKommentarAktiv
${r.user_id ? `${esc(r.username)}` : 'Global'} ${r.action === "deny" ? 'Sperren' : 'Erlauben'} ${esc(r.host || "* (alle)")} ${esc(r.ports || "alle")} ${esc(r.comment)} ${r.enabled ? "✓" : "–"}
Keine Regeln – alles wird nach der Standardrichtlinie behandelt.
`; $("#rule-filter").onchange = (e) => (location.hash = e.target.value ? `rules?user=${e.target.value}` : "rules"); $("#add").onclick = async () => { const ok = await modalLoop("Regel anlegen", ruleForm({ user_id: userFilter || null }), (d) => api("/api/rules", { method: "POST", body: ruleBody(d) })); if (ok) { toast("Regel angelegt"); route(); } }; $("#bulk").onclick = async () => { const html = ` `; const out = await modalLoop("Liste importieren", html, (d) => api("/api/rules/bulk", { method: "POST", body: { ...ruleBody(d), hosts: d.hosts.split(/\r?\n/) } }), { submit: "Importieren" }); if (out) { toast(`${out.added} Regeln importiert, ${out.skipped} übersprungen`); route(); } }; $("#test").onclick = async () => { const html = `
`; await modal("Regel testen", html, { submit: "Prüfen", onSubmit: async (d) => { const res = await api("/api/rules/test", { method: "POST", body: { username: d.username || null, host: d.host, port: Number(d.port) } }); $("#test-result").innerHTML = `
${res.allowed ? 'Erlaubt' : 'Gesperrt'} ${esc(res.reason)} ${res.addresses.length ? `
${res.addresses.map((a) => `${esc(a.ip)}: ${a.allowed ? "erlaubt" : "gesperrt – " + esc(a.reason)}`).join("
")}
` : ""} ${res.dns_error ? `
DNS: ${esc(res.dns_error)}
` : ""}
`; return KEEP_OPEN; } }); }; main.onclick = async (e) => { const edit = e.target.dataset.edit, del = e.target.dataset.del; if (edit) { const rule = rules.find((x) => x.id == edit); const ok = await modalLoop("Regel bearbeiten", ruleForm(rule), (d) => api(`/api/rules/${edit}`, { method: "PUT", body: ruleBody(d) })); if (ok) { toast("Gespeichert"); route(); } } if (del && await confirmBox("Regel löschen?")) { await api(`/api/rules/${del}`, { method: "DELETE" }); toast("Gelöscht"); route(); } }; }; // ---- Weiterleitungen function forwardForm(f = {}) { return `
`; } const fwdBody = (d) => ({ ...d, listen_port: Number(d.listen_port), target_port: Number(d.target_port) }); VIEWS.forwards = async (main) => { const { items, ports_hint } = await api("/api/forwards"); main.innerHTML = head("Weiterleitungen", `Für Programme ohne Proxy-Unterstützung (WireGuard, Outlook-Mailkonten, Geräte …): Port am Proxy → festes Ziel. Im Docker-Setup freigegebene Ports: ${esc(ports_hint)} (TCP & UDP).`, ``) + `
${items.map((f) => ``).join("") || ``}
NameProtoProxy-PortZielErlaubte QuellenStatus
${esc(f.name)}${f.comment ? `
${esc(f.comment)}
` : ""}
${f.protocol.toUpperCase()} ${f.listen_port} ${esc(f.target_host)}:${f.target_port} ${esc(f.allowed_sources || "alle")} ${f.status === "aktiv" ? 'aktiv' : f.status === "deaktiviert" ? 'deaktiviert' : `${esc(f.status)}`}
Keine Weiterleitungen. Beispiel: WireGuard → UDP, Port 40000 → vpn.firma.de:51820
`; $("#add").onclick = async () => { const ok = await modalLoop("Weiterleitung anlegen", forwardForm(), (d) => api("/api/forwards", { method: "POST", body: fwdBody(d) })); if (ok) { toast("Weiterleitung angelegt"); route(); } }; main.onclick = async (e) => { const edit = e.target.dataset.edit, del = e.target.dataset.del; if (edit) { const f = items.find((x) => x.id == edit); const ok = await modalLoop("Weiterleitung bearbeiten", forwardForm(f), (d) => api(`/api/forwards/${edit}`, { method: "PUT", body: fwdBody(d) })); if (ok) { toast("Gespeichert"); route(); } } if (del && await confirmBox("Weiterleitung löschen?")) { await api(`/api/forwards/${del}`, { method: "DELETE" }); toast("Gelöscht"); route(); } }; }; // ---- Verlauf function historyView(admin) { return async (main, params) => { if (admin) await loadUsers(); const f = Object.fromEntries(params.entries()); const limit = 100, offset = Number(f.offset || 0); const q = new URLSearchParams(); for (const k of ["username", "q", "status", "proto"]) if (f[k]) q.set(k, f[k]); if (f.from) q.set("since", new Date(f.from).getTime() / 1000); if (f.to) q.set("until", new Date(f.to).getTime() / 1000); const base = admin ? "/api/history" : "/api/me/history"; const data = await api(`${base}?${q}&limit=${limit}&offset=${offset}`); const sel = (name, opts) => ``; main.innerHTML = head(admin ? "Verlauf" : "Mein Verlauf", "Wer hat wann was aufgerufen", admin ? `` : "") + `
${admin ? sel("username", state.users.map((u) => [u.username, u.username])) : ""} ${sel("status", Object.entries(STATUS).map(([k, v]) => [k, v[1]]))} ${admin ? sel("proto", Object.entries(PROTO)) : ""} Zurücksetzen
${admin ? "" : ""} ${data.items.map((r) => ` ${admin ? `` : ""} `).join("") || ``}
ZeitBenutzerClientProtokollZielStatus↑↓Dauer
${fmtTime(r.ts)}${r.username ? `${esc(r.username)}` : '–'}${esc(r.client_ip)} ${protoBadge(r.proto)} ${esc(r.method && r.proto === "http" ? r.method + " " : "")}${esc(r.url || (r.host ? `${r.host}:${r.port}` : ""))}${r.detail ? `
${esc(r.detail)}
` : ""}
${statusBadge(r.status)} ${fmtBytes(r.bytes_up)}${fmtBytes(r.bytes_down)}${fmtDur(r.duration)}
Keine Einträge
${fmtNum(data.total)} Einträge · Seite ${Math.floor(offset / limit) + 1} von ${Math.max(1, Math.ceil(data.total / limit))}
`; const go = (extra = {}) => { const p = new URLSearchParams(); const form = new FormData($("#filter")); for (const [k, v] of form.entries()) if (v) p.set(k, v); for (const [k, v] of Object.entries(extra)) p.set(k, v); location.hash = `${admin ? "history" : "myhistory"}?${p}`; }; $("#filter").onsubmit = (e) => { e.preventDefault(); go(); }; $("#prev").onclick = () => go({ offset: Math.max(0, offset - limit) }); $("#next").onclick = () => go({ offset: offset + limit }); if (admin) { $("#csv").onclick = async () => { try { const res = await api(`/api/history/export.csv?${q}`, { raw: true }); const url = URL.createObjectURL(await res.blob()); const a = Object.assign(document.createElement("a"), { href: url, download: "proxy-verlauf.csv" }); document.body.append(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(url), 5000); } catch (e) { toast(e.message, true); } }; $("#clear").onclick = async () => { if (await confirmBox("Den kompletten Verlauf unwiderruflich löschen?", "Alles löschen")) { const r = await api("/api/history", { method: "DELETE" }); toast(`${fmtNum(r.deleted)} Einträge gelöscht`); route(); } }; } }; } VIEWS.history = historyView(true); VIEWS.myhistory = historyView(false); // ---- Live-Verbindungen VIEWS.connections = async (main) => { main.innerHTML = head("Live-Verbindungen", "Aktualisiert sich alle 3 Sekunden") + `
`; const draw = async () => { const list = await api("/api/connections"); const el = $("#conns"); if (!el) return; el.innerHTML = `
${list.map((c) => ``).join("") || ``}
SeitBenutzerClientProtokollZiel↑↓
${fmtDur(Date.now() / 1000 - c.started)}${esc(c.username || "–")}${esc(c.client_ip)} ${protoBadge(c.proto)}${esc(c.url || `${c.host}:${c.port}`)} ${fmtBytes(c.bytes_up)}${fmtBytes(c.bytes_down)}
Keine aktiven Verbindungen
`; }; await draw(); state.timer = setInterval(() => draw().catch(() => {}), 3000); main.onclick = async (e) => { if (e.target.dataset.kill) { await api(`/api/connections/${e.target.dataset.kill}`, { method: "DELETE" }).catch((x) => toast(x.message, true)); draw(); } }; }; // ---- Einstellungen function setupHelp(s) { const host = s.public_host || location.hostname; return `

Clients einrichten

HTTP/HTTPS-Proxy
${esc(host)}:${s.ports.http}
SOCKS5-Proxy
${esc(host)}:${s.ports.socks}
Auto-Konfiguration (PAC)
http://${esc(host)}:${s.ports.web}/proxy.pac
SOCKS5 UDP-Ports
${esc(s.ports.socks_udp)}
Weiterleitungs-Ports
${esc(s.ports.forwards)}

Beide Proxy-Ports verstehen HTTP und SOCKS5. Browser: HTTP-Proxy oder PAC-URL eintragen. Thunderbird & Co. (SMTP, IMAP, POP3): SOCKS5 eintragen. Outlook/Exchange: läuft über HTTPS (Systemproxy); für SMTP/IMAP in Outlook oder WireGuard eine Weiterleitung anlegen. Kommandozeile: export https_proxy=http://user:pass@${esc(host)}:${s.ports.http}

`; } VIEWS.settings = async (main) => { const s = await api("/api/settings"); main.innerHTML = head("Einstellungen") + `
${setupHelp(s)}
`; $("#settings").onsubmit = async (e) => { e.preventDefault(); const el = e.target.elements; try { await api("/api/settings", { method: "PUT", body: { default_policy: el.default_policy.value, log_retention_days: Number(el.log_retention_days.value), public_host: el.public_host.value, auth_realm: el.auth_realm.value, anonymous_networks: el.anonymous_networks.value, log_enabled: el.log_enabled.checked, block_loopback: el.block_loopback.checked } }); toast("Einstellungen gespeichert"); } catch (err) { toast(err.message, true); } }; }; VIEWS.help = async (main) => { const host = location.hostname; main.innerHTML = head("Einrichtung") + `
HTTP/HTTPS-Proxy
${esc(host)}:3128
SOCKS5
${esc(host)}:1080
PAC-Datei
http://${esc(host)}:${esc(location.port || 80)}/proxy.pac
Anmeldung
Dein Benutzername und Passwort
`; }; // ---- API VIEWS.api = async (main) => { const tokens = await api("/api/tokens"); main.innerHTML = head("API", `Alle Funktionen der Oberfläche sind per REST-API nutzbar. Interaktive API-Doku öffnen ↗`, ``) + `
${tokens.map((t) => ``).join("") || ``}
NameBesitzerErstelltLäuft abZuletzt benutzt
${esc(t.name)}${esc(t.username)}${fmtTime(t.created_at)}${t.expires_at ? fmtTime(t.expires_at) : "nie"}${ago(t.last_used)}
Noch keine API-Tokens

Beispiele

# Benutzer anlegen
curl -X POST ${esc(location.origin)}/api/users \\
  -H "Authorization: Bearer <TOKEN>" -H "Content-Type: application/json" \\
  -d '{"username":"max","password":"geheim123"}'

# Domain global sperren
curl -X POST ${esc(location.origin)}/api/rules \\
  -H "Authorization: Bearer <TOKEN>" -H "Content-Type: application/json" \\
  -d '{"action":"deny","host":"facebook.com"}'

# Verlauf eines Benutzers
curl "${esc(location.origin)}/api/history?username=max&limit=50" -H "Authorization: Bearer <TOKEN>"
`; $("#add").onclick = async () => { const out = await modalLoop("API-Token erstellen", ` `, (d) => api("/api/tokens", { method: "POST", body: { name: d.name, days: d.days ? Number(d.days) : null } }), { submit: "Erstellen" }); if (out) { await modal("Token erstellt", `

Bitte jetzt kopieren – er wird nicht noch einmal angezeigt:

${esc(out.token)}

Verwendung: Header Authorization: Bearer <Token> oder X-API-Key: <Token>

`, { submit: "" }); route(); } }; main.onclick = async (e) => { if (e.target.dataset.del && await confirmBox("Token widerrufen?", "Widerrufen")) { await api(`/api/tokens/${e.target.dataset.del}`, { method: "DELETE" }); toast("Widerrufen"); route(); } }; }; // ------------------------------------------------------------------ Start / Login function logout(silent) { if (!silent && state.token) api("/api/auth/logout", { method: "POST" }).catch(() => {}); state.token = null; state.me = null; try { localStorage.removeItem("proxy_token"); } catch (_) { /* ignore */ } clearInterval(state.timer); $("#app").hidden = true; $("#login").hidden = false; } async function start() { if (!state.token) { logout(true); return; } try { state.me = await api("/api/me"); } catch (_) { return; } $("#login").hidden = true; $("#app").hidden = false; $("#whoami").innerHTML = `Angemeldet als ${esc(state.me.username)}`; route(); } $("#login-form").onsubmit = async (e) => { e.preventDefault(); const f = e.target.elements; $("#login-error").textContent = ""; try { const r = await api("/api/auth/login", { method: "POST", body: { username: f.username.value, password: f.password.value } }); state.token = r.token; try { localStorage.setItem("proxy_token", r.token); } catch (_) { /* ignore */ } f.password.value = ""; start(); } catch (err) { $("#login-error").textContent = err.message; } }; $("#btn-logout").onclick = () => logout(false); $("#btn-password").onclick = async () => { const ok = await modalLoop("Passwort ändern", ` `, (d) => api("/api/me/password", { method: "POST", body: d })); if (ok) toast("Passwort geändert"); }; window.addEventListener("hashchange", () => state.me && route()); start();