From 63d4b7d894d0b03727814f7653c6621708142789 Mon Sep 17 00:00:00 2001 From: Till Heidrich Date: Tue, 1 Sep 2026 03:02:33 +0000 Subject: [PATCH] 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/) 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 Claude-Session: https://claude.ai/code/session_01BNhLunz586g6DeYMa7Qsd1 --- migrations/014_mail_and_sharing.sql | 71 +++++++ package-lock.json | 10 + package.json | 1 + src/components/AdminApp.tsx | 57 ++++++ src/components/KontoApp.tsx | 148 +++++++++++++++ src/components/LibraryApp.tsx | 9 + src/components/ShareDialog.tsx | 278 ++++++++++++++++++++++++++++ src/components/SharesApp.tsx | 227 +++++++++++++++++++++++ src/layouts/Base.astro | 24 ++- src/lib/mail.ts | 131 +++++++++++++ src/lib/shares.ts | 229 +++++++++++++++++++++++ src/middleware.ts | 10 +- src/pages/anleitung.astro | 24 +++ src/pages/api/admin/settings.ts | 12 +- src/pages/api/admin/test-mail.ts | 34 ++++ src/pages/api/auth/reset.ts | 88 +++++++++ src/pages/api/me.ts | 42 ++++- src/pages/api/s/[slug]/file.ts | 81 ++++++++ src/pages/api/shares/[id].ts | 49 +++++ src/pages/api/shares/index.ts | 55 ++++++ src/pages/api/shares/send.ts | 75 ++++++++ src/pages/konto.astro | 20 ++ src/pages/login.astro | 3 + src/pages/passwort.astro | 97 ++++++++++ src/pages/s/[slug].astro | 125 +++++++++++++ src/pages/teilen.astro | 21 +++ src/worker.ts | 38 ++++ 27 files changed, 1946 insertions(+), 13 deletions(-) create mode 100644 migrations/014_mail_and_sharing.sql create mode 100644 src/components/KontoApp.tsx create mode 100644 src/components/ShareDialog.tsx create mode 100644 src/components/SharesApp.tsx create mode 100644 src/lib/mail.ts create mode 100644 src/lib/shares.ts create mode 100644 src/pages/api/admin/test-mail.ts create mode 100644 src/pages/api/auth/reset.ts create mode 100644 src/pages/api/s/[slug]/file.ts create mode 100644 src/pages/api/shares/[id].ts create mode 100644 src/pages/api/shares/index.ts create mode 100644 src/pages/api/shares/send.ts create mode 100644 src/pages/konto.astro create mode 100644 src/pages/passwort.astro create mode 100644 src/pages/s/[slug].astro create mode 100644 src/pages/teilen.astro diff --git a/migrations/014_mail_and_sharing.sql b/migrations/014_mail_and_sharing.sql new file mode 100644 index 0000000..43aee18 --- /dev/null +++ b/migrations/014_mail_and_sharing.sql @@ -0,0 +1,71 @@ +-- E-Mail-Versand, Passwort-Rücksetzung und Teilen-Links ------------------- +-- Alles vorwärts und idempotent, wie die übrigen Migrationen. + +-- SMTP-Zugang. Das Passwort wird wie die übrigen Geheimnisse AES-256-GCM +-- verschlüsselt abgelegt (src/lib/crypto.ts) — nie im Klartext. +ALTER TABLE settings ADD COLUMN IF NOT EXISTS smtp_host text; +ALTER TABLE settings ADD COLUMN IF NOT EXISTS smtp_port integer NOT NULL DEFAULT 465; +ALTER TABLE settings ADD COLUMN IF NOT EXISTS smtp_secure boolean NOT NULL DEFAULT true; +ALTER TABLE settings ADD COLUMN IF NOT EXISTS smtp_user text; +ALTER TABLE settings ADD COLUMN IF NOT EXISTS smtp_password text; +ALTER TABLE settings ADD COLUMN IF NOT EXISTS smtp_from text; + +-- Wie lange ein neuer Teilen-Link standardmäßig gilt (0 = unbegrenzt). +ALTER TABLE settings ADD COLUMN IF NOT EXISTS share_default_days integer NOT NULL DEFAULT 30; + +-- E-Mail am Nutzerkonto: für „Passwort vergessen" und optionale Hinweise. +ALTER TABLE users ADD COLUMN IF NOT EXISTS email text; +ALTER TABLE users ADD COLUMN IF NOT EXISTS notify_jobs boolean NOT NULL DEFAULT false; +CREATE UNIQUE INDEX IF NOT EXISTS users_email_idx ON users (lower(email)) WHERE email IS NOT NULL; + +-- Passwort zurücksetzen. Gespeichert wird nur der **Hash** des Tokens — wer die +-- Datenbank liest, kann damit kein Konto übernehmen. +CREATE TABLE IF NOT EXISTS password_resets ( + token_hash text PRIMARY KEY, + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + expires_at timestamptz NOT NULL, + used_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS password_resets_user_idx ON password_resets(user_id); + +-- Teilen-Links: ein Bild oder eine Sammlung, öffentlich erreichbar unter /s/. +CREATE TABLE IF NOT EXISTS shares ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + slug text UNIQUE NOT NULL, + kind text NOT NULL DEFAULT 'item' CHECK (kind IN ('item','collection')), + title text, + note text, + created_by uuid REFERENCES users(id) ON DELETE SET NULL, + expires_at timestamptz, + revoked_at timestamptz, + allow_download boolean NOT NULL DEFAULT true, + -- Optionales Zugangswort (argon2), damit ein weitergeleiteter Link allein nicht reicht. + password_hash text, + views integer NOT NULL DEFAULT 0, + downloads integer NOT NULL DEFAULT 0, + last_seen_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS shares_created_by_idx ON shares(created_by); + +CREATE TABLE IF NOT EXISTS share_items ( + share_id uuid NOT NULL REFERENCES shares(id) ON DELETE CASCADE, + item_id uuid NOT NULL REFERENCES items(id) ON DELETE CASCADE, + position integer NOT NULL DEFAULT 0, + PRIMARY KEY (share_id, item_id) +); +CREATE INDEX IF NOT EXISTS share_items_item_idx ON share_items(item_id); + +-- Zugriffe je Link. Die IP wird nur **gehasht** abgelegt (mit dem Server-Geheimnis +-- gesalzen): Man kann damit wiederkehrende Besucher zählen, aber niemanden +-- identifizieren oder die Adresse zurückrechnen. +CREATE TABLE IF NOT EXISTS share_views ( + id bigserial PRIMARY KEY, + share_id uuid NOT NULL REFERENCES shares(id) ON DELETE CASCADE, + kind text NOT NULL DEFAULT 'view' CHECK (kind IN ('view','download')), + ip_hash text, + user_agent text, + at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS share_views_share_idx ON share_views(share_id, at DESC); diff --git a/package-lock.json b/package-lock.json index 559a8a5..db79215 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "basic-ftp": "^5.0.5", "grammy": "^1.30.0", "heic-convert": "^2.1.0", + "nodemailer": "^9.1.0", "pdf-lib": "^1.17.1", "pg": "^8.13.1", "pg-boss": "^10.1.5", @@ -6032,6 +6033,15 @@ "node": ">=18" } }, + "node_modules/nodemailer": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.1.0.tgz", + "integrity": "sha512-xj1Ri5Sau3qpPffHJwi2bY0oWVVWYq62Ph17l+2v1xXpxjqTls3YPc3L8dub9mcJpxj+1ucNnuYVqLlgh4pmDA==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", diff --git a/package.json b/package.json index 1f50fe4..63d84ed 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "basic-ftp": "^5.0.5", "grammy": "^1.30.0", "heic-convert": "^2.1.0", + "nodemailer": "^9.1.0", "pdf-lib": "^1.17.1", "pg": "^8.13.1", "pg-boss": "^10.1.5", diff --git a/src/components/AdminApp.tsx b/src/components/AdminApp.tsx index 4a1df68..30d6b27 100644 --- a/src/components/AdminApp.tsx +++ b/src/components/AdminApp.tsx @@ -11,6 +11,7 @@ export default function AdminApp() { const [orKey, setOrKey] = useState(''); const [pdPw, setPdPw] = useState(''); const [nasPw, setNasPw] = useState(''); const [storage, setStorage] = useState(null); const [rename, setRename] = useState(null); // Probelauf/Ergebnis der Umbenennung + const [mailAn, setMailAn] = useState(''); // Adresse für die Testnachricht const [targets, setTargets] = useState([]); const [nt, setNt] = useState({ protocol: 'sftp' }); const notify = (t: string) => { setToast(t); setTimeout(() => setToast(null), 2600); }; @@ -130,6 +131,14 @@ export default function AdminApp() { notify(r.deleted != null ? `${r.deleted} alte Positionen entfernt.` : r.purged != null ? `${r.purged} Quelldateien gelöscht.` : r.made != null ? `${r.made} Vorschaubilder erzeugt.` : (r.error || 'OK')); load(); }; + const testeMail = async (mitVersand: boolean) => { + if (mitVersand && !mailAn.trim()) { notify('Bitte eine Adresse für die Testnachricht eintragen.'); return; } + notify(mitVersand ? 'Verschicke Testnachricht …' : 'Prüfe Verbindung …'); + const r = await fetch('/api/admin/test-mail', { method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(mitVersand ? { to: mailAn.trim() } : {}) }).then((x) => x.json()); + notify(r.message || (r.ok ? 'OK' : 'Fehler')); + }; + // Eindeutige Dateinamen — erst zeigen, was passieren würde, dann anwenden. const renamePreview = async () => { notify('Prüfe Dateinamen …'); @@ -305,6 +314,54 @@ export default function AdminApp() { +
+
E-Mail +
+ + +
+
+
Klarbild verschickt über dein eigenes Postfach — dadurch stimmen SPF und DKIM + von selbst und es kommt kein weiterer Anbieter ins Spiel. Gebraucht wird es für + Passwort zurücksetzen, Hinweise zu fertigen Aufträgen und Teilen-Links per Mail. + Das Passwort wird verschlüsselt gespeichert und nie wieder angezeigt.
+
+
+ field('smtp_host', e.target.value)} />
+
+ field('smtp_port', e.target.value)} /> +
465 = SSL · 587 = STARTTLS
+
+ +
+
+ field('smtp_user', e.target.value)} />
+
+ field('smtp_password', e.target.value)} />
+
+
+ field('smtp_from', e.target.value)} /> +
Leer = Benutzername. Muss zum Postfach gehören, sonst lehnen Empfänger die Mail ab.
+
+ setMailAn(e.target.value)} /> +
„Verbindung testen" prüft nur die Anmeldung. Erst die Testnachricht beweist, + dass die Mail wirklich ankommt — schau auch im Spam-Ordner nach.
+
+
+ +
+
Teilen-Links
+
+
+ field('share_default_days', e.target.value)} /> +
Voreinstellung für neue Links; beim Erzeugen jederzeit änderbar. 0 = unbegrenzt. + Auf den Bildern sind Kinder — ein Link, der von selbst abläuft, ist die sichere Voreinstellung.
+
+
+
Dateinamen JJJJ-MM-TT_HHMMSS_motiv_format_kürzel
diff --git a/src/components/KontoApp.tsx b/src/components/KontoApp.tsx new file mode 100644 index 0000000..ccb09f6 --- /dev/null +++ b/src/components/KontoApp.tsx @@ -0,0 +1,148 @@ +import { useEffect, useState } from 'react'; + +/** + * Eigenes Konto: E-Mail hinterlegen und Benachrichtigungen schalten. + * + * Die Adresse ist zugleich der Weg, ein vergessenes Passwort zurückzusetzen — + * das steht auch so dabei, damit niemand sie für reine Werbepost hält. + */ +interface Me { + username: string | null; display_name: string | null; + email: string | null; notify_jobs: boolean; mail_ready: boolean; role: string; +} + +export default function KontoApp() { + const [me, setMe] = useState(null); + const [email, setEmail] = useState(''); + const [msg, setMsg] = useState<{ t: string; ok: boolean } | null>(null); + const [laeuft, setLaeuft] = useState(false); + + useEffect(() => { + fetch('/api/me').then((r) => r.json()).then((m) => { setMe(m); setEmail(m.email || ''); }).catch(() => {}); + }, []); + + const sagen = (t: string, ok: boolean) => { setMsg({ t, ok }); setTimeout(() => setMsg(null), 5000); }; + + const patch = async (body: unknown, erfolg: string) => { + setLaeuft(true); + const r = await fetch('/api/me', { + method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), + }).then((x) => x.json()).catch(() => ({ error: 'Netzwerkfehler.' })); + setLaeuft(false); + if (r.error) { sagen(r.error, false); return false; } + sagen(erfolg, true); + const m = await fetch('/api/me').then((x) => x.json()).catch(() => null); + if (m) setMe(m); + return true; + }; + + if (!me) return

Einen Moment …

; + + return ( +
+
+
Anmeldung
+
+
+ Benutzername + {me.username} +
+ {me.display_name && ( +
+ Angezeigt als + {me.display_name} +
+ )} +
+ Passwort + Neues Passwort setzen +
+
+
+ +
+
E-Mail-Adresse
+
+

+ Damit kannst du dir ein neues Passwort schicken lassen, wenn du deines vergessen hast. + Ohne hinterlegte Adresse geht das nicht — dann muss ein Admin ran. +

+
+ setEmail(e.target.value)} + placeholder="du@beispiel.de" aria-label="E-Mail-Adresse" autoComplete="email" /> + +
+ {!me.mail_ready && ( +

+ Auf diesem Server ist noch kein E-Mail-Versand eingerichtet — die Adresse wird zwar + gespeichert, es kommt aber noch nichts an. Ein Admin richtet das unter + Admin → E-Mail ein. +

+ )} +
+
+ +
+
Benachrichtigungen
+
+ +

+ Aufträge aus einer privaten Sitzung lösen bewusst nie eine Mail aus — die sollen + ja gerade keine Spur hinterlassen. +

+
+
+ + {msg &&

{msg.t}

} + + +
+ ); +} diff --git a/src/components/LibraryApp.tsx b/src/components/LibraryApp.tsx index 6878a45..dba8e29 100644 --- a/src/components/LibraryApp.tsx +++ b/src/components/LibraryApp.tsx @@ -1,4 +1,5 @@ import React, { useState, useEffect, useCallback } from 'react'; +import ShareDialog, { type ShareZiel } from './ShareDialog'; interface Item { id: string; filename: string; output_px: string; has_alpha: boolean; folder_id: string | null; by_name: string; tasks: string[]; delivery_status: string; nas_status: string; @@ -24,6 +25,7 @@ export default function LibraryApp() { const [promptOf, setPromptOf] = useState(null); const [saveView, setSaveView] = useState(null); const [tagMenu, setTagMenu] = useState(null); + const [teilen, setTeilen] = useState(null); const [tagFilter, setTagFilter] = useState(null); const [toast, setToast] = useState(null); const notify = (t: string) => { setToast(t); setTimeout(() => setToast(null), 2400); }; @@ -129,6 +131,7 @@ export default function LibraryApp() { {pick.size > 0 && <> {pick.size} gewählt + setFolder(v.id, e.target.value || null)}> {folders.map((o) => )} @@ -236,6 +241,10 @@ export default function LibraryApp() { )} + {teilen && ( + setTeilen(null)} /> + )} + {compare && (
setCompare(null)}>
e.stopPropagation()}> diff --git a/src/components/ShareDialog.tsx b/src/components/ShareDialog.tsx new file mode 100644 index 0000000..7710700 --- /dev/null +++ b/src/components/ShareDialog.tsx @@ -0,0 +1,278 @@ +import { useEffect, useRef, useState } from 'react'; + +/** + * Teilen-Dialog: aus einer Bildauswahl einen Link machen und ihn weitergeben. + * + * Bewusst zweistufig — erst Einstellungen, dann Weitergabe. Der Link entsteht + * beim Klick auf „Link erzeugen"; bis dahin ist nichts öffentlich. Danach zeigt + * derselbe Dialog die Wege: kopieren, WhatsApp, E-Mail. + */ +export interface ShareZiel { itemIds: string[]; titel?: string } + +const TAGE = [ + { v: 7, t: '7 Tage' }, + { v: 30, t: '30 Tage' }, + { v: 90, t: '3 Monate' }, + { v: 365, t: '1 Jahr' }, + { v: 0, t: 'unbegrenzt' }, +]; + +export default function ShareDialog( + { ziel, onClose, onFertig }: { ziel: ShareZiel; onClose: () => void; onFertig?: () => void }, +) { + const mehrere = ziel.itemIds.length > 1; + const [titel, setTitel] = useState(ziel.titel || ''); + const [notiz, setNotiz] = useState(''); + const [tage, setTage] = useState(30); + const [download, setDownload] = useState(true); + const [wort, setWort] = useState(''); + const [link, setLink] = useState<{ slug: string; id: string } | null>(null); + const [laeuft, setLaeuft] = useState(false); + const [msg, setMsg] = useState(null); + const [mailAn, setMailAn] = useState(''); + const [mailText, setMailText] = useState(''); + const [mailBereit, setMailBereit] = useState(false); + const [kopiert, setKopiert] = useState(false); + const box = useRef(null); + + const url = link ? `${location.origin}/s/${link.slug}` : ''; + + useEffect(() => { + fetch('/api/me').then((r) => r.json()).then((m) => setMailBereit(!!m.mail_ready)).catch(() => {}); + }, []); + + // Fokus einfangen und Hintergrund festhalten, solange der Dialog offen ist. + useEffect(() => { + const vorher = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + box.current?.querySelector('input,select,button')?.focus(); + const key = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + if (e.key !== 'Tab') return; + const f = box.current?.querySelectorAll('a[href],button:not([disabled]),input,select,textarea'); + if (!f?.length) return; + const [erst, letzt] = [f[0], f[f.length - 1]]; + if (e.shiftKey && document.activeElement === erst) { e.preventDefault(); letzt.focus(); } + else if (!e.shiftKey && document.activeElement === letzt) { e.preventDefault(); erst.focus(); } + }; + document.addEventListener('keydown', key); + return () => { document.removeEventListener('keydown', key); document.body.style.overflow = vorher; }; + }, [onClose]); + + const erzeugen = async () => { + setLaeuft(true); setMsg(null); + const r = await fetch('/api/shares', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + itemIds: ziel.itemIds, title: titel || null, note: notiz || null, + days: tage, allowDownload: download, password: wort || null, + }), + }).then((x) => x.json()).catch(() => ({ error: 'Netzwerkfehler.' })); + setLaeuft(false); + if (r.error) { setMsg(r.error); return; } + setLink({ slug: r.share.slug, id: r.share.id }); + onFertig?.(); + }; + + const kopieren = async () => { + try { await navigator.clipboard.writeText(url); } + catch { + // Ohne Zwischenablage-Recht: Feld markieren, damit man von Hand kopieren kann. + box.current?.querySelector('.linkfeld')?.select(); + } + setKopiert(true); setTimeout(() => setKopiert(false), 2000); + }; + + const waText = `${titel || 'Bilder'}${wort ? '' : ''}\n${url}`; + const waHref = `https://wa.me/?text=${encodeURIComponent(waText)}`; + + const mailen = async () => { + if (!mailAn.trim()) { setMsg('Bitte eine Empfängeradresse eintragen.'); return; } + setLaeuft(true); setMsg(null); + const r = await fetch('/api/shares/send', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ shareId: link!.id, to: mailAn, message: mailText }), + }).then((x) => x.json()).catch(() => ({ error: 'Netzwerkfehler.' })); + setLaeuft(false); + if (r.error) { setMsg(r.error); return; } + setMsg(r.fehler?.length + ? `${r.ok} verschickt, bei ${r.fehler.length} hat es nicht geklappt.` + : `Verschickt an ${r.ok === 1 ? 'die Adresse' : `${r.ok} Adressen`}.`); + setMailAn(''); + }; + + return ( +
e.target === e.currentTarget && onClose()}> +
+
+

{mehrere ? `${ziel.itemIds.length} Bilder teilen` : 'Bild teilen'}

+ +
+ + {!link ? ( +
+

+ Es entsteht ein Link, den auch jemand ohne Klarbild-Konto öffnen kann. + Er läuft von selbst ab und lässt sich jederzeit wieder abschalten. +

+ + + +