import { useCallback, useEffect, useState } from 'react'; /** * Verwaltung der Teilen-Links: sehen, wie oft ein Link aufgerufen wurde, * ihn ändern, abschalten oder löschen. * * Der Zähler ist bewusst schlicht: Aufrufe, Downloads und die Zahl der * unterschiedlichen Besucher. Namen oder Adressen stehen nirgends: Gespeichert * wird ein Hash der IP, gesalzen mit dem Server-Geheimnis **und** der Kennung * des Links. Damit lässt sich derselbe Besucher innerhalb eines Links * wiedererkennen, aber nicht über mehrere Links hinweg verfolgen. * Ehrlich benannt ist das Pseudonymisierung, nicht Anonymisierung — wer den * Server-Schlüssel hat, könnte den IPv4-Raum durchrechnen. Deshalb wird auch * nur 90 Tage aufgehoben. */ interface Share { id: string; slug: string; kind: string; title: string | null; note: string | null; expires_at: string | null; revoked_at: string | null; allow_download: boolean; password_hash: string | null; views: number; downloads: number; last_seen_at: string | null; created_at: string; anzahl: number; besucher: number; } const TAGE = [ { v: 7, t: '7 Tage ab jetzt' }, { v: 30, t: '30 Tage ab jetzt' }, { v: 90, t: '3 Monate ab jetzt' }, { v: 365, t: '1 Jahr ab jetzt' }, { v: 0, t: 'unbegrenzt' }, ]; const datum = (s: string | null) => s ? new Date(s).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }) : ''; const zeit = (s: string | null) => s ? new Date(s).toLocaleString('de-DE', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' }) : ''; type Zustand = 'aktiv' | 'abgelaufen' | 'aus'; const zustand = (s: Share): Zustand => s.revoked_at ? 'aus' : s.expires_at && new Date(s.expires_at).getTime() < Date.now() ? 'abgelaufen' : 'aktiv'; export default function SharesApp() { const [shares, setShares] = useState([]); const [laden, setLaden] = useState(true); const [ladefehler, setLadefehler] = useState(false); const [offen, setOffen] = useState(null); const [zugriffe, setZugriffe] = useState>({}); const [msg, setMsg] = useState(null); const [kopiert, setKopiert] = useState(null); const laden_ = useCallback(async () => { // Ein Netzfehler darf nicht als „nichts geteilt" erscheinen — sonst denkt // man, die eigenen Links seien weg. try { const r = await fetch('/api/shares'); if (!r.ok) throw new Error(String(r.status)); const d = await r.json(); setShares(d.shares || []); setLadefehler(false); } catch { setLadefehler(true); } finally { setLaden(false); } }, []); useEffect(() => { laden_(); }, [laden_]); const sagen = (t: string) => { setMsg(t); setTimeout(() => setMsg(null), 4000); }; const patch = async (id: string, body: unknown) => { const r = await fetch(`/api/shares/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }).then((x) => x.json()).catch(() => ({ error: 'Netzwerkfehler.' })); // `{ok:false}` heißt: Der Link gehört jemand anderem oder war schon in dem // Zustand. Ohne Rückmeldung wirkt der Knopf kaputt. if (r.error) sagen(r.error); else if (r.ok === false) { sagen('Nichts zu ändern — vielleicht schon geschehen?'); await laden_(); } else await laden_(); }; const loeschen = async (s: Share) => { if (!confirm(`Den Link „${s.title || s.slug}" endgültig löschen?\n\nEr lässt sich danach nicht mehr öffnen und die Zugriffszahlen sind weg. ` + `Nur abschalten reicht meistens.`)) return; const r = await fetch(`/api/shares/${s.id}`, { method: 'DELETE' }).then((x) => x.json()).catch(() => ({})); if (r.ok) { sagen('Link gelöscht.'); await laden_(); } else sagen(r.error || 'Hat nicht geklappt.'); }; const kopieren = async (s: Share) => { const url = `${location.origin}/s/${s.slug}`; try { await navigator.clipboard.writeText(url); } catch { prompt('Link kopieren:', url); } setKopiert(s.id); setTimeout(() => setKopiert(null), 2000); }; const details = async (s: Share) => { if (offen === s.id) { setOffen(null); return; } setOffen(s.id); // Bei jedem Aufklappen neu laden — sonst zeigt die Liste beim zweiten Mal // einen alten Stand, ohne dass man es merkt. const r = await fetch(`/api/shares/${s.id}`).then((x) => x.json()).catch(() => ({ zugriffe: [] })); setZugriffe((z) => ({ ...z, [s.id]: r.zugriffe || [] })); }; if (laden) return

Einen Moment …

; return (
{msg &&
{msg}
} {ladefehler ? (

Die Liste ließ sich nicht laden

Vielleicht war die Verbindung kurz weg. Deine Links sind davon nicht betroffen.

) : !shares.length ? (

Noch nichts geteilt

In der Bibliothek auf Teilen tippen — dann entsteht hier ein Eintrag, den du jederzeit wieder abschalten kannst.

) : (
{shares.map((s) => { const z = zustand(s); return (

{s.title || (s.anzahl > 1 ? `${s.anzahl} Bilder` : 'Ein Bild')}

{z === 'aktiv' ? 'aktiv' : z === 'aus' ? 'abgeschaltet' : 'abgelaufen'} {s.password_hash && geschützt}
{s.views} Aufrufe {s.downloads} Downloads {s.besucher} {s.besucher === 1 ? 'Besucher' : 'Besuchende'}
{s.anzahl} {s.anzahl === 1 ? 'Bild' : 'Bilder'} · {' '}{s.expires_at ? `gültig bis ${datum(s.expires_at)}` : 'ohne Ablauf'} · {' '}angelegt {datum(s.created_at)} {s.last_seen_at && <> · zuletzt gesehen {zeit(s.last_seen_at)}} {!s.allow_download && <> · nur ansehen}
e.currentTarget.select()} aria-label="Teilen-Link" /> Ansehen
{z === 'aus' ? : }
{offen === s.id && (
{!zugriffe[s.id]?.length ?

Noch keine Zugriffe.

: (
    {zugriffe[s.id].slice(0, 30).map((v, i) => (
  • {v.kind === 'download' ? 'Download' : 'Aufruf'} {zeit(v.at)} {(v.user_agent || '').slice(0, 60)}
  • ))}
)}

Festgehalten wird, wann und mit welcher Art Gerät geöffnet wurde — nicht wer. Von der IP-Adresse wird nur ein verschlüsselter Fingerabdruck gespeichert, der außerdem an diesen einen Link gebunden ist; über mehrere Links hinweg lässt sich damit niemand verfolgen. Nach 90 Tagen wird alles gelöscht.

)}
); })}
)}
); }