feat: e-mail, password reset and shareable links for images and collections

Mail goes out through the account's own mailbox (SMTP) rather than a sending
service: the domain lives there anyway, so SPF and DKIM line up by themselves
and no third party gets to see the messages. Credentials are AES-256-GCM
encrypted in settings like every other secret, never in the repo. nodemailer
has zero dependencies and no native postinstall -- checked before adding it,
given the history.

Password reset: single-use token, only its hash is stored, one hour, and the
request endpoint answers identically whether or not the address exists so it
cannot be used to enumerate accounts. Rate limited per address.

Share links (/s/<slug>) let someone without an account see the pictures and
download them at full resolution. These are photos of children, so the
defaults lean safe: every link expires by itself (30 days), can be revoked,
optionally carries a passphrase, and the page and files are noindex. Access is
bound to the link, not the image -- swapping in another item id on a valid link
returns 404. Private-session results can never be shared at all.

Tracking records when and with what, never who: IP addresses are only stored
salted-and-hashed, so repeat visitors can be counted but nobody identified.

Sharing goes out by mail (several recipients at once, no attachment -- a link
can be withdrawn later, an attachment cannot) or via wa.me, which opens
WhatsApp with the text prepared. The WhatsApp Business API would need a Meta
account, approved templates and per-conversation fees; this works today for
free and the API can slot in behind it later.

Verified end to end against a local SMTP server: reset issues and consumes its
token exactly once, the old password stops working, an unknown address gives
the same answer, share mail reaches several recipients. Link behaviour checked
for expiry (410), revocation (410), wrong passphrase (401) and download lock
(403). Outbound SMTP is blocked in the sandbox, so the real mailbox still needs
one test from the live server.

Also fixes a regression this introduced: the seventh nav item pushed the logout
button off screen at 820px. The bar now shrinks and scrolls, logout never does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BNhLunz586g6DeYMa7Qsd1
This commit is contained in:
tillandClaude Opus 5 committed 2026-09-01 03:02:33 +00:00
1 parent 6ae6e0348c
commit 63d4b7d894
27 files changed
+1946 -13

No files matched your search

+227
View File
@@ -0,0 +1,227 @@
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. Wer geschaut hat, steht nirgends — die
* IP-Adressen liegen nur gehasht vor und lassen sich nicht zurückrechnen.
*/
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<Share[]>([]);
const [laden, setLaden] = useState(true);
const [offen, setOffen] = useState<string | null>(null);
const [zugriffe, setZugriffe] = useState<Record<string, any[]>>({});
const [msg, setMsg] = useState<string | null>(null);
const [kopiert, setKopiert] = useState<string | null>(null);
const laden_ = useCallback(async () => {
const r = await fetch('/api/shares').then((x) => x.json()).catch(() => ({ shares: [] }));
setShares(r.shares || []); 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.' }));
if (r.error) sagen(r.error); 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);
if (!zugriffe[s.id]) {
const r = await fetch(`/api/shares/${s.id}`).then((x) => x.json()).catch(() => ({ zugriffe: [] }));
setZugriffe((z) => ({ ...z, [s.id]: r.zugriffe || [] }));
}
};
if (laden) return <p className="tw-leer">Einen Moment …</p>;
return (
<div className="tw">
{msg && <div className="tw-toast" role="status">{msg}</div>}
{!shares.length ? (
<div className="tw-leer-box">
<span className="regmark" />
<h2>Noch nichts geteilt</h2>
<p>In der <a href="/bibliothek">Bibliothek</a> auf <b>Teilen</b> tippen — dann entsteht hier
ein Eintrag, den du jederzeit wieder abschalten kannst.</p>
</div>
) : (
<div className="tw-liste">
{shares.map((s) => {
const z = zustand(s);
return (
<article key={s.id} className={`tw-karte ${z}`}>
<div className="tw-kopf">
<div className="tw-titel">
<h3>{s.title || (s.anzahl > 1 ? `${s.anzahl} Bilder` : 'Ein Bild')}</h3>
<span className={`tw-status ${z}`}>
{z === 'aktiv' ? 'aktiv' : z === 'aus' ? 'abgeschaltet' : 'abgelaufen'}
</span>
{s.password_hash && <span className="tw-schutz" title="Mit Zugangswort geschützt">geschützt</span>}
</div>
<div className="tw-zahlen">
<span><b>{s.views}</b> Aufrufe</span>
<span><b>{s.downloads}</b> Downloads</span>
<span><b>{s.besucher}</b> {s.besucher === 1 ? 'Besucher' : 'Besucher'}</span>
</div>
</div>
<div className="tw-meta">
{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</>}
</div>
<div className="tw-zeile">
<input className="tw-link" readOnly value={`${location.origin}/s/${s.slug}`}
onFocus={(e) => e.currentTarget.select()} aria-label="Teilen-Link" />
<button className="tw-btn" onClick={() => kopieren(s)}>{kopiert === s.id ? 'Kopiert ✓' : 'Kopieren'}</button>
<a className="tw-btn" href={`/s/${s.slug}`} target="_blank" rel="noopener">Ansehen</a>
</div>
<div className="tw-aktionen">
{z === 'aus'
? <button className="tw-btn stark" onClick={() => patch(s.id, { action: 'unrevoke' })}>Wieder freigeben</button>
: <button className="tw-btn" onClick={() => patch(s.id, { action: 'revoke' })}>Abschalten</button>}
<select className="tw-btn" value="" onChange={(e) => e.target.value !== '' && patch(s.id, { days: Number(e.target.value) })}
aria-label="Gültigkeit ändern">
<option value="" disabled>Gültigkeit ändern …</option>
{TAGE.map((t) => <option key={t.v} value={t.v}>{t.t}</option>)}
</select>
<button className="tw-btn" onClick={() => patch(s.id, { allowDownload: !s.allow_download })}>
{s.allow_download ? 'Download sperren' : 'Download erlauben'}
</button>
<button className="tw-btn" onClick={() => {
const w = prompt(s.password_hash
? 'Neues Zugangswort (leer lassen = Schutz entfernen):'
: 'Zugangswort setzen:');
if (w !== null) patch(s.id, { password: w });
}}>{s.password_hash ? 'Wort ändern' : 'Wort setzen'}</button>
<button className="tw-btn" onClick={() => details(s)}>{offen === s.id ? 'Zugriffe zu' : 'Zugriffe'}</button>
<button className="tw-btn gefahr" onClick={() => loeschen(s)}>Löschen</button>
</div>
{offen === s.id && (
<div className="tw-zugriffe">
{!zugriffe[s.id]?.length ? <p className="tw-fein">Noch keine Zugriffe.</p> : (
<ul>
{zugriffe[s.id].slice(0, 30).map((v, i) => (
<li key={i}>
<span className="tw-art">{v.kind === 'download' ? 'Download' : 'Aufruf'}</span>
<span className="tw-wann">{zeit(v.at)}</span>
<span className="tw-ua">{(v.user_agent || '').slice(0, 60)}</span>
</li>
))}
</ul>
)}
<p className="tw-fein">Es wird festgehalten, <b>wann</b> und <b>womit</b> geöffnet wurde —
nicht <b>wer</b>. Die IP-Adresse liegt nur gehasht vor und lässt sich nicht zurückrechnen.</p>
</div>
)}
</article>
);
})}
</div>
)}
<style>{`
.tw{display:grid;gap:14px;}
.tw-leer{color:var(--soft);}
.tw-leer-box{background:var(--card);border:1px solid var(--line);border-radius:var(--radius);
padding:44px 24px;text-align:center;}
.tw-leer-box h2{margin:14px 0 8px;font-size:1.15rem;}
.tw-leer-box p{margin:0;color:var(--ink-soft);line-height:1.6;}
.tw-toast{position:sticky;top:70px;z-index:5;background:var(--ink);color:var(--card);
padding:10px 14px;border-radius:var(--radius-sm);font-size:.9rem;}
.tw-liste{display:grid;gap:12px;}
.tw-karte{background:var(--card);border:1px solid var(--line);border-radius:var(--radius);
padding:16px;display:grid;gap:11px;}
.tw-karte.aus,.tw-karte.abgelaufen{background:var(--paper);}
.tw-kopf{display:flex;flex-wrap:wrap;gap:10px;align-items:flex-start;justify-content:space-between;}
.tw-titel{display:flex;flex-wrap:wrap;gap:8px;align-items:center;min-width:0;}
.tw-titel h3{margin:0;font-size:1rem;letter-spacing:-.01em;overflow-wrap:anywhere;}
.tw-status{font-family:var(--font-mono);font-size:9px;letter-spacing:.12em;text-transform:uppercase;
padding:3px 8px;border-radius:20px;}
.tw-status.aktiv{background:oklch(60% .15 150 / .14);color:oklch(42% .13 150);}
.tw-status.aus{background:oklch(55% .19 25 / .12);color:var(--err);}
.tw-status.abgelaufen{background:var(--paper);color:var(--soft);border:1px solid var(--line);}
.tw-schutz{font-family:var(--font-mono);font-size:9px;letter-spacing:.12em;text-transform:uppercase;
padding:3px 8px;border-radius:20px;background:oklch(72% .15 75 / .16);color:oklch(48% .13 75);}
.tw-zahlen{display:flex;gap:14px;font-size:.78rem;color:var(--soft);white-space:nowrap;}
.tw-zahlen b{color:var(--ink);font-size:.95rem;}
.tw-meta{font-size:.8rem;color:var(--soft);line-height:1.5;}
.tw-zeile{display:flex;gap:7px;}
.tw-link{flex:1;min-width:0;padding:9px 10px;border:1px solid var(--line);border-radius:var(--radius-sm);
background:var(--paper);font-family:var(--font-mono);font-size:12px;color:var(--ink);}
.tw-aktionen{display:flex;flex-wrap:wrap;gap:6px;}
.tw-btn{background:var(--card);border:1px solid var(--line);border-radius:var(--radius-sm);
padding:8px 11px;font-size:.82rem;cursor:pointer;font-family:inherit;color:var(--ink);
text-decoration:none;display:inline-block;white-space:nowrap;}
.tw-btn:hover{border-color:var(--accent);}
.tw-btn.stark{background:var(--accent);color:#fff;border-color:var(--accent);font-weight:600;}
.tw-btn.gefahr{color:var(--err);}
.tw-zugriffe{border-top:1px solid var(--line);padding-top:11px;}
.tw-zugriffe ul{list-style:none;margin:0 0 10px;padding:0;display:grid;gap:5px;max-height:260px;overflow:auto;}
.tw-zugriffe li{display:grid;grid-template-columns:82px 118px minmax(0,1fr);gap:8px;
font-size:.76rem;align-items:baseline;}
.tw-art{font-weight:600;}
.tw-wann{font-family:var(--font-mono);font-size:10.5px;color:var(--soft);}
.tw-ua{color:var(--soft);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.tw-fein{font-size:.76rem;color:var(--soft);line-height:1.5;margin:0;}
@media(max-width:640px){
.tw-kopf{flex-direction:column;gap:8px;}
.tw-zahlen{gap:12px;}
.tw-zeile{flex-wrap:wrap;}
.tw-link{flex-basis:100%;}
.tw-btn{flex:1;text-align:center;padding:11px 10px;}
.tw-zugriffe li{grid-template-columns:74px 1fr;}
.tw-ua{display:none;}
}
`}</style>
</div>
);
}