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. +

+ + + +