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

+71
View File
@@ -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/<slug>.
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);
+10
View File
@@ -20,6 +20,7 @@
"basic-ftp": "^5.0.5", "basic-ftp": "^5.0.5",
"grammy": "^1.30.0", "grammy": "^1.30.0",
"heic-convert": "^2.1.0", "heic-convert": "^2.1.0",
"nodemailer": "^9.1.0",
"pdf-lib": "^1.17.1", "pdf-lib": "^1.17.1",
"pg": "^8.13.1", "pg": "^8.13.1",
"pg-boss": "^10.1.5", "pg-boss": "^10.1.5",
@@ -6032,6 +6033,15 @@
"node": ">=18" "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": { "node_modules/normalize-path": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
+1
View File
@@ -24,6 +24,7 @@
"basic-ftp": "^5.0.5", "basic-ftp": "^5.0.5",
"grammy": "^1.30.0", "grammy": "^1.30.0",
"heic-convert": "^2.1.0", "heic-convert": "^2.1.0",
"nodemailer": "^9.1.0",
"pdf-lib": "^1.17.1", "pdf-lib": "^1.17.1",
"pg": "^8.13.1", "pg": "^8.13.1",
"pg-boss": "^10.1.5", "pg-boss": "^10.1.5",
+57
View File
@@ -11,6 +11,7 @@ export default function AdminApp() {
const [orKey, setOrKey] = useState(''); const [pdPw, setPdPw] = useState(''); const [nasPw, setNasPw] = useState(''); const [orKey, setOrKey] = useState(''); const [pdPw, setPdPw] = useState(''); const [nasPw, setNasPw] = useState('');
const [storage, setStorage] = useState<any>(null); const [storage, setStorage] = useState<any>(null);
const [rename, setRename] = useState<any>(null); // Probelauf/Ergebnis der Umbenennung const [rename, setRename] = useState<any>(null); // Probelauf/Ergebnis der Umbenennung
const [mailAn, setMailAn] = useState(''); // Adresse für die Testnachricht
const [targets, setTargets] = useState<any[]>([]); const [targets, setTargets] = useState<any[]>([]);
const [nt, setNt] = useState<any>({ protocol: 'sftp' }); const [nt, setNt] = useState<any>({ protocol: 'sftp' });
const notify = (t: string) => { setToast(t); setTimeout(() => setToast(null), 2600); }; 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')); 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(); 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. // Eindeutige Dateinamen — erst zeigen, was passieren würde, dann anwenden.
const renamePreview = async () => { const renamePreview = async () => {
notify('Prüfe Dateinamen …'); notify('Prüfe Dateinamen …');
@@ -305,6 +314,54 @@ export default function AdminApp() {
</div> </div>
</section> </section>
<section className="karte">
<div className="kopfzeile"><span className="mono-label">E-Mail</span>
<div className="reihe">
<button className="mini" onClick={() => testeMail(false)}>Verbindung testen</button>
<button className="mini" onClick={() => testeMail(true)}>Testnachricht</button>
</div></div>
<div className="steuer">
<div className="fein">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
<b> Passwort zurücksetzen</b>, <b>Hinweise zu fertigen Aufträgen</b> und <b>Teilen-Links per Mail</b>.
Das Passwort wird verschlüsselt gespeichert und nie wieder angezeigt.</div>
<div className="zwei">
<div className="feld"><label>Postausgangsserver</label>
<input className="input" placeholder="z. B. w020d97a.kasserver.com" value={s.smtp_host ?? ''} onChange={(e) => field('smtp_host', e.target.value)} /></div>
<div className="feld"><label>Port</label>
<input className="input" type="number" placeholder="465" value={s.smtp_port ?? 465} onChange={(e) => field('smtp_port', e.target.value)} />
<div className="fein">465 = SSL · 587 = STARTTLS</div></div>
</div>
<label className="schalt"><input type="checkbox" checked={s.smtp_secure !== false} onChange={(e) => field('smtp_secure', e.target.checked)} />
<span><b>Verschlüsselt verbinden (SSL)</b><em>Für Port 465 an, für 587 aus.</em></span></label>
<div className="zwei">
<div className="feld"><label>Benutzername</label>
<input className="input" placeholder="mail@deine-domain.de" value={s.smtp_user ?? ''} onChange={(e) => field('smtp_user', e.target.value)} /></div>
<div className="feld"><label>Passwort {s.smtp_password_set ? <span className="fein">· gesetzt</span> : null}</label>
<input className="input" type="password" placeholder={s.smtp_password_set ? 'unverändert lassen' : 'Postfach-Passwort'}
onChange={(e) => field('smtp_password', e.target.value)} /></div>
</div>
<div className="feld"><label>Absender</label>
<input className="input" placeholder="Klarbild &lt;mail@deine-domain.de&gt;" value={s.smtp_from ?? ''} onChange={(e) => field('smtp_from', e.target.value)} />
<div className="fein">Leer = Benutzername. Muss zum Postfach gehören, sonst lehnen Empfänger die Mail ab.</div></div>
<div className="feld"><label>Testnachricht an</label>
<input className="input" type="email" placeholder="du@beispiel.de" value={mailAn} onChange={(e) => setMailAn(e.target.value)} />
<div className="fein">„Verbindung testen" prüft nur die Anmeldung. Erst die Testnachricht beweist,
dass die Mail wirklich ankommt — schau auch im Spam-Ordner nach.</div></div>
</div>
</section>
<section className="karte">
<div className="kopfzeile"><span className="mono-label">Teilen-Links</span></div>
<div className="steuer">
<div className="feld"><label>Standard-Gültigkeit (Tage)</label>
<input className="input" type="number" min={0} max={3650} value={s.share_default_days ?? 30}
onChange={(e) => field('share_default_days', e.target.value)} />
<div className="fein">Voreinstellung für neue Links; beim Erzeugen jederzeit änderbar. <b>0 = unbegrenzt.</b>
Auf den Bildern sind Kinder — ein Link, der von selbst abläuft, ist die sichere Voreinstellung.</div></div>
</div>
</section>
<section className="karte"> <section className="karte">
<div className="kopfzeile"><span className="mono-label">Dateinamen</span> <div className="kopfzeile"><span className="mono-label">Dateinamen</span>
<span className="fein">JJJJ-MM-TT_HHMMSS_motiv_format_kürzel</span></div> <span className="fein">JJJJ-MM-TT_HHMMSS_motiv_format_kürzel</span></div>
+148
View File
@@ -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<Me | null>(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 <p className="ko-fein">Einen Moment …</p>;
return (
<div className="ko">
<section className="ko-karte">
<div className="ko-kopf"><span className="mono-label">Anmeldung</span></div>
<div className="ko-inhalt">
<div className="ko-zeile">
<span className="ko-label">Benutzername</span>
<span className="ko-wert">{me.username}</span>
</div>
{me.display_name && (
<div className="ko-zeile">
<span className="ko-label">Angezeigt als</span>
<span className="ko-wert">{me.display_name}</span>
</div>
)}
<div className="ko-zeile">
<span className="ko-label">Passwort</span>
<span className="ko-wert"><a href="/passwort">Neues Passwort setzen</a></span>
</div>
</div>
</section>
<section className="ko-karte">
<div className="ko-kopf"><span className="mono-label">E-Mail-Adresse</span></div>
<div className="ko-inhalt">
<p className="ko-fein">
Damit kannst du dir ein <b>neues Passwort schicken lassen</b>, wenn du deines vergessen hast.
Ohne hinterlegte Adresse geht das nicht — dann muss ein Admin ran.
</p>
<div className="ko-eingabe">
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)}
placeholder="du@beispiel.de" aria-label="E-Mail-Adresse" autoComplete="email" />
<button className="ko-btn stark" disabled={laeuft || email === (me.email || '')}
onClick={() => patch({ email }, email ? 'Adresse gespeichert.' : 'Adresse entfernt.')}>
Speichern
</button>
</div>
{!me.mail_ready && (
<p className="ko-warn">
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
<b> Admin → E-Mail</b> ein.
</p>
)}
</div>
</section>
<section className="ko-karte">
<div className="ko-kopf"><span className="mono-label">Benachrichtigungen</span></div>
<div className="ko-inhalt">
<label className="ko-schalt">
<input type="checkbox" checked={me.notify_jobs} disabled={!me.email}
onChange={(e) => patch({ notify_jobs: e.target.checked },
e.target.checked ? 'Hinweise eingeschaltet.' : 'Hinweise ausgeschaltet.')} />
<span>
<b>Sag mir Bescheid, wenn ein Auftrag fertig ist</b>
<em>Eine kurze Mail je Auftrag — kein Newsletter, keine Werbung.
{!me.email && ' Dafür brauchst du zuerst eine E-Mail-Adresse (oben).'}</em>
</span>
</label>
<p className="ko-fein">
Aufträge aus einer <b>privaten Sitzung</b> lösen bewusst nie eine Mail aus — die sollen
ja gerade keine Spur hinterlassen.
</p>
</div>
</section>
{msg && <p className={`ko-msg ${msg.ok ? 'ok' : 'err'}`} role="status">{msg.t}</p>}
<style>{`
.ko{display:grid;gap:14px;max-width:620px;}
.ko-karte{background:var(--card);border:1px solid var(--line);border-radius:var(--radius);}
.ko-kopf{padding:13px 16px;border-bottom:1px solid var(--line);}
.ko-inhalt{padding:16px;display:grid;gap:12px;}
.ko-zeile{display:flex;gap:12px;justify-content:space-between;align-items:baseline;
padding-bottom:9px;border-bottom:1px solid var(--line);font-size:.92rem;}
.ko-zeile:last-child{border-bottom:none;padding-bottom:0;}
.ko-label{color:var(--soft);font-size:.85rem;}
.ko-wert{font-weight:500;overflow-wrap:anywhere;text-align:right;}
.ko-wert a{color:var(--accent);}
.ko-fein{margin:0;font-size:.85rem;color:var(--ink-soft);line-height:1.6;}
.ko-eingabe{display:flex;gap:8px;}
.ko-eingabe input{flex:1;min-width:0;padding:11px 12px;border:1px solid var(--line);
border-radius:var(--radius-sm);background:var(--paper);color:var(--ink);font-size:16px;font-family:inherit;}
.ko-btn{border:1px solid var(--line);border-radius:var(--radius-sm);background:var(--card);
color:var(--ink);padding:11px 16px;font-size:.9rem;cursor:pointer;font-family:inherit;white-space:nowrap;}
.ko-btn.stark{background:var(--accent);color:#fff;border-color:var(--accent);font-weight:600;}
.ko-btn:disabled{background:var(--paper);color:var(--soft);border-color:var(--line);cursor:not-allowed;}
.ko-schalt{display:flex;gap:11px;align-items:flex-start;cursor:pointer;font-size:.92rem;}
.ko-schalt input{width:19px;height:19px;margin-top:2px;flex:none;accent-color:var(--accent);}
.ko-schalt input:disabled{cursor:not-allowed;}
.ko-schalt b{display:block;}
.ko-schalt em{display:block;font-style:normal;color:var(--soft);font-size:.82rem;margin-top:3px;line-height:1.5;}
.ko-warn{margin:0;padding:10px 12px;border:1px solid var(--warn);border-radius:var(--radius-sm);
background:oklch(72% .15 75 / .08);font-size:.82rem;line-height:1.55;}
.ko-msg{margin:0;font-size:.9rem;}
.ko-msg.ok{color:var(--ok);}
.ko-msg.err{color:var(--err);}
@media(max-width:560px){
.ko-eingabe{flex-direction:column;}
.ko-btn{width:100%;padding:13px;}
.ko-zeile{flex-direction:column;gap:3px;}
.ko-wert{text-align:left;}
}
`}</style>
</div>
);
}
+9
View File
@@ -1,4 +1,5 @@
import React, { useState, useEffect, useCallback } from 'react'; 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; 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; 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<Item | null>(null); const [promptOf, setPromptOf] = useState<Item | null>(null);
const [saveView, setSaveView] = useState<Item | null>(null); const [saveView, setSaveView] = useState<Item | null>(null);
const [tagMenu, setTagMenu] = useState<string | null>(null); const [tagMenu, setTagMenu] = useState<string | null>(null);
const [teilen, setTeilen] = useState<ShareZiel | null>(null);
const [tagFilter, setTagFilter] = useState<string | null>(null); const [tagFilter, setTagFilter] = useState<string | null>(null);
const [toast, setToast] = useState<string | null>(null); const [toast, setToast] = useState<string | null>(null);
const notify = (t: string) => { setToast(t); setTimeout(() => setToast(null), 2400); }; const notify = (t: string) => { setToast(t); setTimeout(() => setToast(null), 2400); };
@@ -129,6 +131,7 @@ export default function LibraryApp() {
{pick.size > 0 && <> {pick.size > 0 && <>
<span className="fein">{pick.size} gewählt</span> <span className="fein">{pick.size} gewählt</span>
<button className="mini stark" onClick={deliverSelected}>Nach Picdrop</button> <button className="mini stark" onClick={deliverSelected}>Nach Picdrop</button>
<button className="mini" onClick={() => setTeilen({ itemIds: [...pick] })}>Teilen</button>
<button className="mini" onClick={zipSelected}>ZIP laden</button> <button className="mini" onClick={zipSelected}>ZIP laden</button>
<select className="mini-select" value="" onChange={(e) => { if (e.target.value) moveSelected(e.target.value === '__none' ? null : e.target.value); }}> <select className="mini-select" value="" onChange={(e) => { if (e.target.value) moveSelected(e.target.value === '__none' ? null : e.target.value); }}>
<option value="" disabled>In Ordner …</option> <option value="" disabled>In Ordner …</option>
@@ -224,6 +227,8 @@ export default function LibraryApp() {
<button className="mini stark2" onClick={() => setSaveView(v)} title="Groß öffnen, dann sichern (iPhone: In Fotos sichern)">Laden</button> <button className="mini stark2" onClick={() => setSaveView(v)} title="Groß öffnen, dann sichern (iPhone: In Fotos sichern)">Laden</button>
<button className="mini" onClick={() => reuse(v.id)} title="Ergebnis im Studio weiterbearbeiten">Weiter</button> <button className="mini" onClick={() => reuse(v.id)} title="Ergebnis im Studio weiterbearbeiten">Weiter</button>
<button className="mini" onClick={() => alternative(v.id)} title="Weitere Fassung erzeugen">+ Alt.</button> <button className="mini" onClick={() => alternative(v.id)} title="Weitere Fassung erzeugen">+ Alt.</button>
<button className="mini" onClick={() => setTeilen({ itemIds: [v.id] })}
title="Link erzeugen — auch ohne Klarbild-Konto zu öffnen">Teilen</button>
<select className="mini-select" value={v.folder_id || ''} onChange={(e) => setFolder(v.id, e.target.value || null)}> <select className="mini-select" value={v.folder_id || ''} onChange={(e) => setFolder(v.id, e.target.value || null)}>
<option value="">Kein Ordner</option> <option value="">Kein Ordner</option>
{folders.map((o) => <option key={o.id} value={o.id}>{o.name}</option>)} {folders.map((o) => <option key={o.id} value={o.id}>{o.name}</option>)}
@@ -236,6 +241,10 @@ export default function LibraryApp() {
</div> </div>
)} )}
{teilen && (
<ShareDialog ziel={teilen} onClose={() => setTeilen(null)} />
)}
{compare && ( {compare && (
<div className="lupe" onClick={() => setCompare(null)}> <div className="lupe" onClick={() => setCompare(null)}>
<div className="vergleich" onClick={(e) => e.stopPropagation()}> <div className="vergleich" onClick={(e) => e.stopPropagation()}>
+278
View File
@@ -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<string | null>(null);
const [mailAn, setMailAn] = useState('');
const [mailText, setMailText] = useState('');
const [mailBereit, setMailBereit] = useState(false);
const [kopiert, setKopiert] = useState(false);
const box = useRef<HTMLDivElement>(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<HTMLElement>('input,select,button')?.focus();
const key = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
if (e.key !== 'Tab') return;
const f = box.current?.querySelectorAll<HTMLElement>('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<HTMLInputElement>('.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 (
<div className="tl-hinter" onMouseDown={(e) => e.target === e.currentTarget && onClose()}>
<div className="tl-box" ref={box} role="dialog" aria-modal="true" aria-label="Teilen">
<div className="tl-kopf">
<h2>{mehrere ? `${ziel.itemIds.length} Bilder teilen` : 'Bild teilen'}</h2>
<button className="tl-x" onClick={onClose} aria-label="Schließen">✕</button>
</div>
{!link ? (
<div className="tl-inhalt">
<p className="tl-lead">
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.
</p>
<label className="tl-feld">Überschrift
<input value={titel} onChange={(e) => setTitel(e.target.value)}
placeholder={mehrere ? 'z. B. Kita-Fotos August' : 'z. B. Phoenix im Garten'} maxLength={120} />
</label>
<label className="tl-feld"><span className="tl-kopfzeile">Nachricht auf der Seite <em>optional</em></span>
<textarea value={notiz} onChange={(e) => setNotiz(e.target.value)} rows={2}
placeholder="Steht über den Bildern" maxLength={500} />
</label>
<label className="tl-feld">Gültig
<select value={tage} onChange={(e) => setTage(Number(e.target.value))}>
{TAGE.map((t) => <option key={t.v} value={t.v}>{t.t}</option>)}
</select>
</label>
<label className="tl-check">
<input type="checkbox" checked={download} onChange={(e) => setDownload(e.target.checked)} />
<span><b>Herunterladen erlauben</b><em>Ohne Haken kann man die Bilder nur ansehen.</em></span>
</label>
<label className="tl-feld"><span className="tl-kopfzeile">Zugangswort <em>optional</em></span>
<input type="text" value={wort} onChange={(e) => setWort(e.target.value)}
placeholder="Leer = jeder mit dem Link kommt rein" maxLength={100} />
<span className="tl-fein">Schick das Wort auf einem anderen Weg als den Link.</span>
</label>
{msg && <p className="tl-msg err">{msg}</p>}
<div className="tl-fuss">
<button className="tl-zweit" onClick={onClose}>Abbrechen</button>
<button className="tl-haupt" onClick={erzeugen} disabled={laeuft}>
{laeuft ? 'Einen Moment …' : 'Link erzeugen'}
</button>
</div>
</div>
) : (
<div className="tl-inhalt">
<p className="tl-lead">Der Link ist da. So kommt er zum Empfänger:</p>
<div className="tl-linkzeile">
<input className="linkfeld" value={url} readOnly onFocus={(e) => e.currentTarget.select()} aria-label="Teilen-Link" />
<button className="tl-haupt" onClick={kopieren}>{kopiert ? 'Kopiert ✓' : 'Kopieren'}</button>
</div>
<div className="tl-wege">
<a className="tl-weg wa" href={waHref} target="_blank" rel="noopener">
<span className="tl-weg-t">WhatsApp</span>
<span className="tl-weg-f">Öffnet WhatsApp mit fertigem Text</span>
</a>
<a className="tl-weg" href={`mailto:?subject=${encodeURIComponent(titel || 'Bilder')}&body=${encodeURIComponent(waText)}`}>
<span className="tl-weg-t">Eigenes Mailprogramm</span>
<span className="tl-weg-f">Öffnet deine Mail-App</span>
</a>
</div>
{wort && (
<p className="tl-warn">
Der Link ist mit <b>{wort}</b> geschützt. Schick das Wort getrennt vom Link —
sonst nützt der Schutz nichts.
</p>
)}
<div className="tl-trenn"><span>oder direkt von hier verschicken</span></div>
{mailBereit ? (
<>
<label className="tl-feld">An
<input value={mailAn} onChange={(e) => setMailAn(e.target.value)} type="email"
placeholder="oma@beispiel.de, opa@beispiel.de" />
<span className="tl-fein">Mehrere Adressen mit Komma trennen.</span>
</label>
<label className="tl-feld"><span className="tl-kopfzeile">Dazu schreiben <em>optional</em></span>
<textarea value={mailText} onChange={(e) => setMailText(e.target.value)} rows={2}
placeholder="Ein paar Zeilen an den Empfänger" maxLength={1000} />
</label>
<button className="tl-haupt breit" onClick={mailen} disabled={laeuft}>
{laeuft ? 'Wird verschickt …' : 'Per E-Mail schicken'}
</button>
</>
) : (
<p className="tl-fein">
Direkter E-Mail-Versand ist noch nicht eingerichtet — ein Admin kann das
unter <b>Admin → E-Mail</b> nachholen. Kopieren und WhatsApp gehen trotzdem.
</p>
)}
{msg && <p className={`tl-msg ${/nicht|Fehler|klappt/.test(msg) ? 'err' : 'ok'}`}>{msg}</p>}
<div className="tl-fuss">
<a className="tl-zweit" href="/teilen">Alle Links verwalten</a>
<button className="tl-zweit" onClick={onClose}>Fertig</button>
</div>
</div>
)}
</div>
<style>{`
.tl-hinter{position:fixed;inset:0;z-index:80;background:rgba(22,21,15,.55);
display:flex;align-items:center;justify-content:center;padding:16px;backdrop-filter:blur(2px);}
.tl-box{background:var(--card);border:1px solid var(--line);border-radius:var(--radius);
width:min(520px,100%);max-height:min(88vh,760px);display:flex;flex-direction:column;box-shadow:0 12px 40px rgba(22,21,15,.25);}
.tl-kopf{display:flex;align-items:center;justify-content:space-between;gap:10px;
padding:16px 18px;border-bottom:1px solid var(--line);}
.tl-kopf h2{margin:0;font-size:1.05rem;letter-spacing:-.01em;}
.tl-x{border:none;background:none;font-size:17px;cursor:pointer;color:var(--soft);
width:34px;height:34px;border-radius:50%;line-height:1;}
.tl-x:hover{background:var(--paper);color:var(--ink);}
.tl-inhalt{padding:18px;overflow:auto;display:grid;gap:14px;}
.tl-lead{margin:0;font-size:.9rem;color:var(--ink-soft);line-height:1.6;}
.tl-feld{display:grid;gap:6px;font-size:.86rem;color:var(--ink-soft);}
.tl-feld input,.tl-feld select,.tl-feld textarea{padding:10px 11px;border:1px solid var(--line);
border-radius:var(--radius-sm);background:var(--paper);color:var(--ink);font-size:16px;
font-family:inherit;width:100%;box-sizing:border-box;}
.tl-feld textarea{resize:vertical;line-height:1.5;}
.tl-kopfzeile{display:flex;gap:6px;align-items:baseline;}
.tl-kopfzeile em{font-style:normal;color:var(--soft);font-size:.78rem;}
.tl-fein{font-size:.78rem;color:var(--soft);line-height:1.5;}
.tl-check{display:flex;gap:10px;align-items:flex-start;font-size:.88rem;cursor:pointer;}
.tl-check input{width:18px;height:18px;margin-top:2px;flex:none;accent-color:var(--accent);}
.tl-check b{display:block;font-weight:600;color:var(--ink);}
.tl-check em{display:block;font-style:normal;color:var(--soft);font-size:.8rem;margin-top:2px;}
.tl-linkzeile{display:flex;gap:8px;}
.tl-linkzeile input{flex:1;min-width:0;padding:10px 11px;border:1px solid var(--line);
border-radius:var(--radius-sm);background:var(--paper);font-family:var(--font-mono);
font-size:12.5px;color:var(--ink);}
.tl-haupt{border:none;border-radius:var(--radius-sm);background:var(--accent);color:#fff;
padding:11px 16px;font-weight:600;font-size:.92rem;cursor:pointer;font-family:inherit;white-space:nowrap;}
.tl-haupt.breit{width:100%;}
.tl-haupt:disabled{background:var(--paper);color:var(--soft);border:1px solid var(--line);cursor:not-allowed;}
.tl-zweit{border:1px solid var(--line);border-radius:var(--radius-sm);background:var(--card);
color:var(--ink);padding:11px 16px;font-size:.92rem;cursor:pointer;font-family:inherit;
text-decoration:none;display:inline-block;text-align:center;}
.tl-wege{display:grid;grid-template-columns:1fr 1fr;gap:8px;}
.tl-weg{display:grid;gap:2px;padding:11px 13px;border:1px solid var(--line);
border-radius:var(--radius-sm);background:var(--paper);text-decoration:none;color:var(--ink);}
.tl-weg:hover{border-color:var(--accent);}
.tl-weg.wa{border-color:#25D366;}
.tl-weg-t{font-weight:600;font-size:.88rem;}
.tl-weg-f{font-size:.75rem;color:var(--soft);line-height:1.4;}
.tl-trenn{display:flex;align-items:center;gap:10px;color:var(--soft);
font-family:var(--font-mono);font-size:9.5px;letter-spacing:.14em;text-transform:uppercase;}
.tl-trenn::before,.tl-trenn::after{content:'';flex:1;height:1px;background:var(--line);}
.tl-warn{margin:0;padding:10px 12px;border:1px solid var(--warn);border-radius:var(--radius-sm);
background:oklch(72% .15 75 / .08);font-size:.82rem;line-height:1.55;}
.tl-msg{margin:0;font-size:.86rem;line-height:1.5;}
.tl-msg.err{color:var(--err);}
.tl-msg.ok{color:var(--ok);}
.tl-fuss{display:flex;gap:8px;justify-content:flex-end;padding-top:4px;}
@media(max-width:560px){
.tl-hinter{padding:0;align-items:flex-end;}
.tl-box{width:100%;max-height:92vh;border-radius:var(--radius) var(--radius) 0 0;}
.tl-wege{grid-template-columns:1fr;}
.tl-fuss{flex-direction:column-reverse;}
.tl-fuss>*{width:100%;}
.tl-haupt,.tl-zweit{padding:13px 16px;}
}
`}</style>
</div>
);
}
+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>
);
}
+18 -6
View File
@@ -16,6 +16,7 @@ const nav = [
{ href: '/bibliothek', label: 'Bibliothek', icon: 'library' }, { href: '/bibliothek', label: 'Bibliothek', icon: 'library' },
{ href: '/drucken', label: 'Drucken', icon: 'print' }, { href: '/drucken', label: 'Drucken', icon: 'print' },
{ href: '/warteschlange', label: 'Warteschlange', icon: 'queue' }, { href: '/warteschlange', label: 'Warteschlange', icon: 'queue' },
{ href: '/teilen', label: 'Geteilt', icon: 'share' },
{ href: '/anleitung', label: 'Hilfe', icon: 'help' }, { href: '/anleitung', label: 'Hilfe', icon: 'help' },
...(user?.role === 'admin' ? [{ href: '/admin', label: 'Admin', icon: 'admin' }] : []), ...(user?.role === 'admin' ? [{ href: '/admin', label: 'Admin', icon: 'admin' }] : []),
]; ];
@@ -27,6 +28,7 @@ const ICONS: Record<string, string> = {
queue: `<svg viewBox="0 0 24 24" ${S}><path d="M9 6h11M9 12h11M9 18h7"/><circle cx="4.5" cy="6" r="1.15" fill="currentColor" stroke="none"/><circle cx="4.5" cy="12" r="1.15" fill="currentColor" stroke="none"/><circle cx="4.5" cy="18" r="1.15" fill="currentColor" stroke="none"/></svg>`, queue: `<svg viewBox="0 0 24 24" ${S}><path d="M9 6h11M9 12h11M9 18h7"/><circle cx="4.5" cy="6" r="1.15" fill="currentColor" stroke="none"/><circle cx="4.5" cy="12" r="1.15" fill="currentColor" stroke="none"/><circle cx="4.5" cy="18" r="1.15" fill="currentColor" stroke="none"/></svg>`,
admin: `<svg viewBox="0 0 24 24" ${S}><path d="M4 8h9M17 8h3M4 16h3M11 16h9"/><circle cx="15" cy="8" r="2.2"/><circle cx="9" cy="16" r="2.2"/></svg>`, admin: `<svg viewBox="0 0 24 24" ${S}><path d="M4 8h9M17 8h3M4 16h3M11 16h9"/><circle cx="15" cy="8" r="2.2"/><circle cx="9" cy="16" r="2.2"/></svg>`,
print: `<svg viewBox="0 0 24 24" ${S}><path d="M7 9V4.5h10V9"/><rect x="3.5" y="9" width="17" height="7" rx="2"/><path d="M7 14h10v5.5H7z"/></svg>`, print: `<svg viewBox="0 0 24 24" ${S}><path d="M7 9V4.5h10V9"/><rect x="3.5" y="9" width="17" height="7" rx="2"/><path d="M7 14h10v5.5H7z"/></svg>`,
share: `<svg viewBox="0 0 24 24" ${S}><circle cx="18" cy="5.5" r="2.6"/><circle cx="6" cy="12" r="2.6"/><circle cx="18" cy="18.5" r="2.6"/><path d="M8.4 10.8 15.6 6.9M8.4 13.2l7.2 3.9"/></svg>`,
help: `<svg viewBox="0 0 24 24" ${S}><circle cx="12" cy="12" r="8.5"/><path d="M9.6 9.4a2.5 2.5 0 1 1 3.4 2.3c-.7.3-1 .8-1 1.6v.3"/><circle cx="12" cy="16.7" r="0.6" fill="currentColor" stroke="none"/></svg>`, help: `<svg viewBox="0 0 24 24" ${S}><circle cx="12" cy="12" r="8.5"/><path d="M9.6 9.4a2.5 2.5 0 1 1 3.4 2.3c-.7.3-1 .8-1 1.6v.3"/><circle cx="12" cy="16.7" r="0.6" fill="currentColor" stroke="none"/></svg>`,
}; };
--- ---
@@ -48,7 +50,7 @@ const ICONS: Record<string, string> = {
))} ))}
</nav> </nav>
<form method="post" action="/api/auth/logout" class="logout"> <form method="post" action="/api/auth/logout" class="logout">
<span class="uname">{user.name}</span> <a class="uname" href="/konto" title="Mein Konto">{user.name}</a>
<button type="submit" aria-label="Abmelden">Abmelden</button> <button type="submit" aria-label="Abmelden">Abmelden</button>
</form> </form>
</header> </header>
@@ -86,14 +88,24 @@ const ICONS: Record<string, string> = {
.brand .regmark { transform: translateY(1px); } .brand .regmark { transform: translateY(1px); }
.topnav { display: none; } .topnav { display: none; }
@media (min-width: 768px) { @media (min-width: 768px) {
.topnav { display: flex; gap: 4px; margin-left: 8px; } /* Darf schrumpfen und notfalls scrollen: Mit sieben Einträgen passt die
.tnav { padding: 7px 12px; border-radius: 999px; color: var(--ink-soft); Leiste bei 820 px sonst nicht mehr und schiebt „Abmelden" aus dem Bild. */
text-decoration: none; font-size: 0.92rem; transition: background .15s, color .15s; } .topnav { display: flex; gap: 2px; margin-left: 8px; min-width: 0;
overflow-x: auto; scrollbar-width: none; -webkit-overflow-scrolling: touch; }
.topnav::-webkit-scrollbar { display: none; }
.tnav { padding: 7px 10px; border-radius: 999px; color: var(--ink-soft); white-space: nowrap;
text-decoration: none; font-size: 0.88rem; transition: background .15s, color .15s; }
.tnav:hover { color: var(--ink); background: var(--surface-2, var(--paper)); } .tnav:hover { color: var(--ink); background: var(--surface-2, var(--paper)); }
.tnav.on { color: var(--accent); background: var(--accent-bg); font-weight: 600; } .tnav.on { color: var(--accent); background: var(--accent-bg); font-weight: 600; }
} }
.logout { margin-left: auto; display: flex; align-items: center; gap: 10px; } @media (min-width: 1040px) {
.uname { color: var(--ink-mute, var(--soft)); font-size: 0.9rem; display: none; } .topnav { gap: 4px; }
.tnav { padding: 7px 12px; font-size: 0.92rem; }
}
/* Nicht schrumpfen — der Abmelden-Knopf muss immer erreichbar bleiben. */
.logout { margin-left: auto; display: flex; align-items: center; gap: 10px; flex: none; }
.uname { color: var(--ink-mute, var(--soft)); font-size: 0.9rem; display: none; text-decoration: none; }
.uname:hover { color: var(--accent); }
@media (min-width: 480px) { .uname { display: inline; } } @media (min-width: 480px) { .uname { display: inline; } }
.logout button { background: none; border: 1px solid var(--line); border-radius: 999px; .logout button { background: none; border: 1px solid var(--line); border-radius: 999px;
padding: 7px 14px; color: var(--ink-soft); cursor: pointer; font-family: inherit; font-size: 0.85rem; padding: 7px 14px; color: var(--ink-soft); cursor: pointer; font-family: inherit; font-size: 0.85rem;
+131
View File
@@ -0,0 +1,131 @@
// E-Mail-Versand über den eigenen Postfach-Anbieter (SMTP).
//
// Bewusst über den vorhandenen Postausgangsserver statt über einen Versanddienst:
// Die Domain liegt ohnehin dort, SPF und DKIM stimmen dadurch von selbst, und es
// kommt kein weiterer Anbieter ins Spiel, der die Mails zu sehen bekäme.
//
// Der Zugang steht verschlüsselt in `settings` (AES-256-GCM wie OpenRouter-Key
// und Picdrop-Passwort) und **nie** im Repository.
import nodemailer from 'nodemailer';
import { one } from './db.ts';
import { decrypt } from './crypto.ts';
export interface MailCfg {
host: string; port: number; secure: boolean;
user: string; password: string; from: string;
}
/** SMTP-Zugang aus den Einstellungen — `null`, wenn nichts konfiguriert ist. */
export async function loadMailConfig(): Promise<MailCfg | null> {
const s = await one<any>(
`SELECT smtp_host, smtp_port, smtp_secure, smtp_user, smtp_password, smtp_from FROM settings WHERE id=1`);
if (!s?.smtp_host || !s?.smtp_user || !s?.smtp_password) return null;
let password = '';
try { password = decrypt(s.smtp_password); } catch { return null; }
return {
host: s.smtp_host,
port: Number(s.smtp_port) || 465,
secure: s.smtp_secure !== false,
user: s.smtp_user,
password,
from: s.smtp_from || s.smtp_user,
};
}
export const mailReady = async (): Promise<boolean> => !!(await loadMailConfig());
export interface MailInput {
to: string;
subject: string;
/** Fließtext. Wird auch als Textfassung verschickt. */
text: string;
/** Optionaler HTML-Körper; ohne Angabe wird der Text schlicht gerahmt. */
html?: string;
}
const esc = (s: string) =>
s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
/**
* Schlichter, gut lesbarer HTML-Rahmen. Keine externen Bilder, keine
* Zählpixel, keine Schriftarten von fremden Servern — das würde in vielen
* Postfächern ohnehin blockiert und wäre gegenüber dem Empfänger unhöflich.
*/
export function mailLayout(titel: string, absaetze: string[], knopf?: { text: string; url: string }): string {
const p = absaetze.map((a) => `<p style="margin:0 0 14px;line-height:1.6">${a}</p>`).join('');
const btn = knopf
? `<p style="margin:22px 0"><a href="${esc(knopf.url)}" style="display:inline-block;background:#1B3BE0;color:#fff;
text-decoration:none;padding:12px 20px;border-radius:4px;font-weight:600">${esc(knopf.text)}</a></p>
<p style="margin:0 0 14px;font-size:13px;color:#6A685D">Falls der Knopf nicht funktioniert:<br>
<a href="${esc(knopf.url)}" style="color:#1B3BE0;word-break:break-all">${esc(knopf.url)}</a></p>`
: '';
return `<!doctype html><html lang="de"><body style="margin:0;background:#EAEAE3;padding:24px;
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;color:#16150F;font-size:15px">
<div style="max-width:560px;margin:0 auto;background:#FBFBF7;border:1px solid #D7D6CC;border-radius:4px;padding:28px">
<div style="font-size:11px;letter-spacing:.18em;text-transform:uppercase;color:#6A685D;margin-bottom:18px">Klarbild</div>
<h1 style="font-size:20px;margin:0 0 16px;line-height:1.3">${esc(titel)}</h1>
${p}${btn}
</div>
<div style="max-width:560px;margin:14px auto 0;font-size:12px;color:#6A685D;text-align:center">
Diese Nachricht kommt von deiner eigenen Klarbild-Installation.
</div></body></html>`;
}
/**
* Verschickt eine Mail. Wirft, wenn kein Zugang konfiguriert ist — die
* Aufrufer entscheiden, ob das ein Fehler oder nur ein Hinweis ist.
*/
export async function sendMail(m: MailInput): Promise<void> {
const cfg = await loadMailConfig();
if (!cfg) throw new Error('Kein E-Mail-Zugang konfiguriert.');
const tx = nodemailer.createTransport({
host: cfg.host, port: cfg.port, secure: cfg.secure,
auth: { user: cfg.user, pass: cfg.password },
// Kein endloses Hängen, wenn der Server nicht antwortet.
connectionTimeout: 15000, greetingTimeout: 10000, socketTimeout: 20000,
});
try {
await tx.sendMail({
from: cfg.from, to: m.to, subject: m.subject,
text: m.text,
html: m.html || mailLayout(m.subject, m.text.split('\n\n').map(esc)),
});
} finally {
tx.close();
}
}
/** Wie `sendMail`, meldet Fehler aber nur ins Log. Für Benachrichtigungen. */
export async function trySendMail(m: MailInput): Promise<boolean> {
try { await sendMail(m); return true; }
catch (e: any) { console.error('[mail] Versand fehlgeschlagen:', e?.message || e); return false; }
}
/** Verbindung prüfen, ohne etwas zu verschicken — für den Testknopf im Admin. */
export async function testMail(): Promise<{ ok: boolean; message: string }> {
const cfg = await loadMailConfig();
if (!cfg) return { ok: false, message: 'Kein E-Mail-Zugang konfiguriert.' };
const tx = nodemailer.createTransport({
host: cfg.host, port: cfg.port, secure: cfg.secure,
auth: { user: cfg.user, pass: cfg.password },
connectionTimeout: 15000, greetingTimeout: 10000,
});
try {
await tx.verify();
return { ok: true, message: `Verbindung zu ${cfg.host}:${cfg.port} erfolgreich.` };
} catch (e: any) {
// Anbieter-Meldungen können den Benutzernamen enthalten — kurz halten.
const roh = String(e?.message || e);
const kurz = /auth/i.test(roh) ? 'Anmeldung abgelehnt — Benutzername oder Passwort prüfen.'
: /timeout|ETIMEDOUT|ECONNREFUSED/i.test(roh) ? 'Server nicht erreichbar — Host und Port prüfen.'
: /certificate|self.signed/i.test(roh) ? 'Zertifikatsfehler — Verschlüsselung prüfen (Port 465 = SSL, 587 = STARTTLS).'
: 'Verbindung fehlgeschlagen.';
return { ok: false, message: kurz };
} finally {
tx.close();
}
}
/** Grobe Plausibilitätsprüfung einer Adresse. */
export const istMail = (v: string): boolean =>
/^[^\s@]+@[^\s@.]+(\.[^\s@.]+)+$/.test(String(v || '').trim()) && String(v).length <= 254;
+229
View File
@@ -0,0 +1,229 @@
// Teilen-Links: ein Bild oder eine Sammlung öffentlich zugänglich machen,
// ohne dass der Empfänger ein Konto braucht.
//
// Grundhaltung: Auf diesen Bildern sind Kinder. Ein Link, der für immer gilt und
// den jeder weiterleiten kann, ist deshalb die falsche Voreinstellung. Jeder Link
// bekommt darum von sich aus ein **Ablaufdatum**, lässt sich jederzeit
// **widerrufen**, kann mit einem **Zugangswort** versehen werden, und die Seite
// verbietet Suchmaschinen die Aufnahme.
import { randomBytes, createHash, timingSafeEqual } from 'node:crypto';
import argon2 from 'argon2';
import { one, query } from './db.ts';
/** Ohne i, l, 0, O, 1 — damit ein vorgelesener Link nicht falsch ankommt. */
const ALPHABET = 'abcdefghjkmnpqrstuvwxyz23456789';
/** 12 Zeichen aus 31 = rund 59 Bit. Nicht zu erraten, noch vorlesbar. */
export function neuerSlug(laenge = 12): string {
const bytes = randomBytes(laenge * 2);
let s = '';
for (let i = 0; s.length < laenge && i < bytes.length; i++) {
const v = bytes[i];
// Rest-Werte verwerfen, sonst wären die vorderen Zeichen häufiger.
if (v < 256 - (256 % ALPHABET.length)) s += ALPHABET[v % ALPHABET.length];
}
return s.length === laenge ? s : neuerSlug(laenge);
}
export type ShareKind = 'item' | 'collection';
export interface Share {
id: string; slug: string; kind: ShareKind;
title: string | null; note: string | null;
created_by: 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;
}
export interface ShareErstellen {
kind?: ShareKind;
itemIds: string[];
title?: string | null;
note?: string | null;
/** Tage bis zum Ablauf; 0 oder null = unbegrenzt. */
days?: number | null;
allowDownload?: boolean;
password?: string | null;
userId: string | null;
}
export const MAX_ITEMS = 500;
/**
* Link anlegen. Ohne ausdrückliche Angabe gilt die Voreinstellung aus den
* Einstellungen (30 Tage) — nicht „unbegrenzt".
*/
export async function createShare(input: ShareErstellen): Promise<Share> {
const ids = [...new Set(input.itemIds.filter(Boolean))].slice(0, MAX_ITEMS);
if (!ids.length) throw new Error('Keine Bilder ausgewählt.');
const s = await one<{ share_default_days: number }>('SELECT share_default_days FROM settings WHERE id=1');
const tage = input.days === undefined || input.days === null ? (s?.share_default_days ?? 30) : Number(input.days);
const expires = tage > 0 ? new Date(Date.now() + tage * 86400_000) : null;
const kind: ShareKind = input.kind === 'collection' || ids.length > 1 ? 'collection' : 'item';
const pw = input.password?.trim() ? await argon2.hash(input.password.trim()) : null;
const share = await one<Share>(
`INSERT INTO shares (slug, kind, title, note, created_by, expires_at, allow_download, password_hash)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8) RETURNING *`,
[neuerSlug(), kind, input.title?.trim() || null, input.note?.trim() || null,
input.userId, expires, input.allowDownload !== false, pw]);
for (const [i, id] of ids.entries()) {
await query('INSERT INTO share_items (share_id, item_id, position) VALUES ($1,$2,$3) ON CONFLICT DO NOTHING',
[share!.id, id, i]);
}
return share!;
}
export type ShareStatus = 'ok' | 'nicht-gefunden' | 'widerrufen' | 'abgelaufen' | 'passwort';
/**
* Link auflösen und prüfen, ob er gerade gilt.
* Widerrufen und abgelaufen werden **unterschieden** — der Empfänger soll
* verstehen, warum nichts kommt, statt einen Tippfehler zu vermuten.
*/
export async function resolveShare(slug: string, passwort?: string | null): Promise<{
status: ShareStatus; share?: Share;
}> {
if (!/^[a-z0-9]{6,32}$/.test(String(slug || ''))) return { status: 'nicht-gefunden' };
const share = await one<Share>('SELECT * FROM shares WHERE slug=$1', [slug]);
if (!share) return { status: 'nicht-gefunden' };
if (share.revoked_at) return { status: 'widerrufen', share };
if (share.expires_at && new Date(share.expires_at).getTime() < Date.now()) return { status: 'abgelaufen', share };
if (share.password_hash) {
if (!passwort) return { status: 'passwort', share };
let ok = false;
try { ok = await argon2.verify(share.password_hash, passwort); } catch { ok = false; }
if (!ok) return { status: 'passwort', share };
}
return { status: 'ok', share };
}
export interface ShareBild {
id: string; filename: string | null; output_px: string | null;
mode: string | null; created_at: string;
}
/** Die Bilder hinter einem Link, in der gespeicherten Reihenfolge. */
export async function shareItems(shareId: string): Promise<ShareBild[]> {
return query<ShareBild>(
// `mode` hängt am Auftrag, nicht am Bild. Und es muss maskiert werden:
// Postgres liest `i.mode` sonst als Funktionsaufruf `mode(i)` — das ist die
// Aggregatfunktion, daher die Meldung „WITHIN GROUP is required".
`SELECT i.id, i.filename, i.output_px, j."mode" AS mode, i.created_at
FROM share_items si
JOIN items i ON i.id = si.item_id
JOIN jobs j ON j.id = i.job_id
WHERE si.share_id = $1 AND i.status = 'done' AND i.result_path IS NOT NULL
ORDER BY si.position, i.created_at`, [shareId]);
}
/** Gehört dieses Bild zu diesem Link? Gegenprobe vor jeder Auslieferung. */
export async function shareHasItem(shareId: string, itemId: string): Promise<boolean> {
const r = await one<{ item_id: string }>(
'SELECT item_id FROM share_items WHERE share_id=$1 AND item_id=$2', [shareId, itemId]);
return !!r;
}
/**
* Zugriff festhalten. Die IP wird **nur gehasht** gespeichert, gesalzen mit dem
* Server-Geheimnis: Damit lassen sich wiederkehrende Besucher zählen, aber die
* Adresse ist nicht zurückzurechnen und niemand ist identifizierbar.
*/
export async function trackShare(
shareId: string, kind: 'view' | 'download', ip?: string | null, ua?: string | null,
): Promise<void> {
const salz = process.env.SESSION_SECRET || 'klarbild';
const ipHash = ip ? createHash('sha256').update(salz + '|' + ip).digest('base64url').slice(0, 22) : null;
try {
await query('INSERT INTO share_views (share_id, kind, ip_hash, user_agent) VALUES ($1,$2,$3,$4)',
[shareId, kind, ipHash, (ua || '').slice(0, 200) || null]);
await query(
`UPDATE shares SET last_seen_at = now(),
views = views + CASE WHEN $2='view' THEN 1 ELSE 0 END,
downloads = downloads + CASE WHEN $2='download' THEN 1 ELSE 0 END
WHERE id = $1`, [shareId, kind]);
} catch (e: any) {
// Zählen ist Beiwerk — es darf die Auslieferung nie verhindern.
console.error('[share] Zugriff nicht gezählt:', e?.message || e);
}
}
export interface ShareStat extends Share {
anzahl: number; besucher: number;
}
/** Übersicht für den Eigentümer (Admins sehen alle). */
export async function listShares(userId: string | null, istAdmin: boolean): Promise<ShareStat[]> {
return query<ShareStat>(
`SELECT s.*,
(SELECT count(*)::int FROM share_items si WHERE si.share_id = s.id) AS anzahl,
(SELECT count(DISTINCT ip_hash)::int FROM share_views v WHERE v.share_id = s.id) AS besucher
FROM shares s
WHERE $2::boolean OR s.created_by = $1
ORDER BY s.created_at DESC LIMIT 200`, [userId, istAdmin]);
}
/** Widerrufen — der Link bleibt bestehen, liefert aber nichts mehr aus. */
export async function revokeShare(id: string, userId: string | null, istAdmin: boolean): Promise<boolean> {
const r = await query<{ id: string }>(
`UPDATE shares SET revoked_at = now()
WHERE id = $1 AND ($3::boolean OR created_by = $2) AND revoked_at IS NULL RETURNING id`,
[id, userId, istAdmin]);
return r.length > 0;
}
/** Wieder freigeben — praktisch, wenn man sich verklickt hat. */
export async function unrevokeShare(id: string, userId: string | null, istAdmin: boolean): Promise<boolean> {
const r = await query<{ id: string }>(
`UPDATE shares SET revoked_at = NULL
WHERE id = $1 AND ($3::boolean OR created_by = $2) RETURNING id`, [id, userId, istAdmin]);
return r.length > 0;
}
export interface ShareAendern {
title?: string | null; note?: string | null;
days?: number | null; allowDownload?: boolean;
/** Leerer Text entfernt das Zugangswort. */
password?: string | null;
}
export async function updateShare(
id: string, userId: string | null, istAdmin: boolean, a: ShareAendern,
): Promise<boolean> {
const eigen = await one<{ id: string }>(
'SELECT id FROM shares WHERE id=$1 AND ($3::boolean OR created_by=$2)', [id, userId, istAdmin]);
if (!eigen) return false;
const sets: string[] = []; const args: any[] = [id];
const set = (col: string, val: any) => { args.push(val); sets.push(`${col}=$${args.length}`); };
if (a.title !== undefined) set('title', a.title?.trim() || null);
if (a.note !== undefined) set('note', a.note?.trim() || null);
if (a.allowDownload !== undefined) set('allow_download', !!a.allowDownload);
if (a.days !== undefined) {
const t = Number(a.days) || 0;
set('expires_at', t > 0 ? new Date(Date.now() + t * 86400_000) : null);
}
if (a.password !== undefined) {
set('password_hash', a.password?.trim() ? await argon2.hash(a.password.trim()) : null);
}
if (!sets.length) return true;
await query(`UPDATE shares SET ${sets.join(',')} WHERE id=$1`, args);
return true;
}
export async function deleteShare(id: string, userId: string | null, istAdmin: boolean): Promise<boolean> {
const r = await query<{ id: string }>(
'DELETE FROM shares WHERE id=$1 AND ($3::boolean OR created_by=$2) RETURNING id', [id, userId, istAdmin]);
return r.length > 0;
}
/** Zeitkonstanter Vergleich — für Stellen, an denen ein Zeitunterschied verriete, wie weit man richtig lag. */
export function gleich(a: string, b: string): boolean {
const x = Buffer.from(String(a)); const y = Buffer.from(String(b));
if (x.length !== y.length) return false;
return timingSafeEqual(x, y);
}
+9 -1
View File
@@ -3,7 +3,15 @@ import { ensureInit } from './lib/init';
import { readSession } from './lib/auth'; import { readSession } from './lib/auth';
import { one } from './lib/db'; import { one } from './lib/db';
const PUBLIC_PATHS = [/^\/login/, /^\/vorstellung/, /^\/api\/auth\/login/, /^\/api\/health/, /^\/g\//, /^\/api\/telegram\/webhook/, /^\/llms\.txt/]; const PUBLIC_PATHS = [
/^\/login/, /^\/vorstellung/, /^\/api\/auth\/login/, /^\/api\/health/, /^\/g\//,
/^\/api\/telegram\/webhook/, /^\/llms\.txt/,
// Passwort vergessen — muss ohne Anmeldung erreichbar sein, sonst ist es sinnlos.
/^\/passwort/, /^\/api\/auth\/reset/,
// Teilen-Links: Wer den Link hat, sieht die Bilder — dafür sind sie gedacht.
// Die Prüfung steckt im Link selbst (Ablauf, Widerruf, Zugangswort).
/^\/s\//, /^\/api\/s\//,
];
/** API-Token (Authorization: Bearer …) → synthetischer Admin-Nutzer für /api/*. */ /** API-Token (Authorization: Bearer …) → synthetischer Admin-Nutzer für /api/*. */
async function tokenUser(header: string | null): Promise<any | null> { async function tokenUser(header: string | null): Promise<any | null> {
+24
View File
@@ -174,6 +174,30 @@ import Base from '../layouts/Base.astro';
aufs NAS sichern.</p> aufs NAS sichern.</p>
</section> </section>
<section>
<h2>Mein Konto, Teilen &amp; E-Mail</h2>
<ul>
<li><b><a href="/konto">Mein Konto</a></b>: E-Mail-Adresse hinterlegen — damit kannst du dir
ein neues Passwort schicken lassen, wenn du deines vergisst. Dort lässt sich auch einschalten,
dass du eine kurze Mail bekommst, sobald ein Auftrag fertig ist.</li>
<li><b>Teilen</b>: In der Bibliothek auf <b>Teilen</b> tippen — einzeln oder für mehrere markierte
Bilder auf einmal. Es entsteht ein Link, den auch jemand <b>ohne Klarbild-Konto</b> öffnen kann;
von dort lässt sich das Bild in voller Auflösung herunterladen.</li>
<li><b>Weitergeben</b> per <b>WhatsApp</b> (öffnet WhatsApp mit fertigem Text), über dein eigenes
Mailprogramm, oder direkt aus Klarbild heraus per E-Mail an mehrere Empfänger.</li>
<li><b>Sicher bleiben</b>: Jeder Link läuft von selbst ab (Standard 30 Tage), lässt sich jederzeit
<b>abschalten</b> und auf Wunsch mit einem <b>Zugangswort</b> schützen. Das Wort schickst du dann
getrennt vom Link — sonst nützt es nichts.</li>
<li><b><a href="/teilen">Geteilte Links</a></b>: Übersicht über alles, was gerade offen ist —
mit Aufrufen und Downloads. Festgehalten wird <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.</li>
<li><b>Passwort vergessen?</b> Auf der Anmeldeseite unten auf „Passwort vergessen?" — der Link
aus der Mail gilt eine Stunde und lässt sich nur einmal verwenden.</li>
</ul>
<p class="mini">Bilder aus einer <b>privaten Sitzung</b> lassen sich bewusst nicht teilen und lösen
auch keine Benachrichtigung aus.</p>
</section>
<section> <section>
<h2>Tipps</h2> <h2>Tipps</h2>
<ul> <ul>
+10 -2
View File
@@ -32,6 +32,11 @@ export const GET: APIRoute = async () => {
// NAS-Sicherung // NAS-Sicherung
nas_enabled: s.nas_enabled, nas_host: s.nas_host, nas_protocol: s.nas_protocol, nas_port: s.nas_port, nas_enabled: s.nas_enabled, nas_host: s.nas_host, nas_protocol: s.nas_protocol, nas_port: s.nas_port,
nas_user: s.nas_user, nas_base_path: s.nas_base_path, nas_password_set: !!s.nas_password_enc, nas_user: s.nas_user, nas_base_path: s.nas_base_path, nas_password_set: !!s.nas_password_enc,
// E-Mail-Versand (Passwort wird nie zurückgegeben, nur ob es gesetzt ist)
smtp_host: s.smtp_host, smtp_port: s.smtp_port, smtp_secure: s.smtp_secure,
smtp_user: s.smtp_user, smtp_from: s.smtp_from, smtp_password_set: !!s.smtp_password,
// Teilen
share_default_days: s.share_default_days,
} }); } });
}; };
@@ -43,15 +48,18 @@ export const PATCH: APIRoute = async ({ request }) => {
if (b.openrouter_key) set('openrouter_key_enc', encrypt(String(b.openrouter_key))); if (b.openrouter_key) set('openrouter_key_enc', encrypt(String(b.openrouter_key)));
if (b.picdrop_password) set('picdrop_password_enc', encrypt(String(b.picdrop_password))); if (b.picdrop_password) set('picdrop_password_enc', encrypt(String(b.picdrop_password)));
if (b.nas_password) set('nas_password_enc', encrypt(String(b.nas_password))); if (b.nas_password) set('nas_password_enc', encrypt(String(b.nas_password)));
if (b.smtp_password) set('smtp_password', encrypt(String(b.smtp_password)));
if (b.generate_api_token) set('api_token', randomToken()); if (b.generate_api_token) set('api_token', randomToken());
const boolCols = ['keep_sources', 'make_thumbnails', 'nas_enabled', 'metadata_sidecar', const boolCols = ['keep_sources', 'make_thumbnails', 'nas_enabled', 'metadata_sidecar',
'picdrop_metadata_sidecar', 'nas_metadata_sidecar', 'anonymous_generations', 'private_allowed', 'allow_nsfw']; 'picdrop_metadata_sidecar', 'nas_metadata_sidecar', 'anonymous_generations', 'private_allowed', 'allow_nsfw',
'smtp_secure'];
for (const col of ['picdrop_host', 'picdrop_protocol', 'picdrop_port', 'picdrop_user', 'picdrop_base_path', for (const col of ['picdrop_host', 'picdrop_protocol', 'picdrop_port', 'picdrop_user', 'picdrop_base_path',
'picdrop_default_gallery', 'default_dpi', 'default_crop_mode', 'concurrency', 'cricut_sheet_cm', 'monthly_budget', 'n8n_webhook_url', 'picdrop_default_gallery', 'default_dpi', 'default_crop_mode', 'concurrency', 'cricut_sheet_cm', 'monthly_budget', 'n8n_webhook_url',
'keep_sources', 'make_thumbnails', 'retention_days', 'metadata_sidecar', 'api_token', 'keep_sources', 'make_thumbnails', 'retention_days', 'metadata_sidecar', 'api_token',
'picdrop_metadata_sidecar', 'nas_metadata_sidecar', 'output_ext', 'picdrop_metadata_sidecar', 'nas_metadata_sidecar', 'output_ext',
'library_visibility', 'anonymous_generations', 'private_allowed', 'allow_nsfw', 'library_visibility', 'anonymous_generations', 'private_allowed', 'allow_nsfw',
'nas_enabled', 'nas_host', 'nas_protocol', 'nas_port', 'nas_user', 'nas_base_path']) { 'nas_enabled', 'nas_host', 'nas_protocol', 'nas_port', 'nas_user', 'nas_base_path',
'smtp_host', 'smtp_port', 'smtp_secure', 'smtp_user', 'smtp_from', 'share_default_days']) {
if (col in b) { if (col in b) {
const raw = b[col]; const raw = b[col];
const val = boolCols.includes(col) ? !!raw : (raw === '' ? null : raw); const val = boolCols.includes(col) ? !!raw : (raw === '' ? null : raw);
+34
View File
@@ -0,0 +1,34 @@
import type { APIRoute } from 'astro';
import { testMail, sendMail, mailLayout, istMail } from '../../../lib/mail';
export const prerender = false;
const json = (b: unknown, s = 200) =>
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
/**
* Ohne `to`: nur die Verbindung prüfen (verschickt nichts).
* Mit `to`: eine echte Testnachricht schicken — erst das beweist, dass die
* Mail auch ankommt und nicht im Spam landet.
*/
export const POST: APIRoute = async ({ request }) => {
const b = await request.json().catch(() => ({}));
const to = String(b.to || '').trim();
if (!to) return json(await testMail());
if (!istMail(to)) return json({ ok: false, message: 'Das ist keine gültige Adresse.' }, 400);
try {
await sendMail({
to,
subject: 'Klarbild — Testnachricht',
text: 'Wenn du das liest, funktioniert der E-Mail-Versand aus Klarbild.\n\n'
+ 'Damit können jetzt: Passwort-Rücksetzungen, Hinweise zu fertigen Aufträgen und Teilen-Links per Mail.',
html: mailLayout('Der Versand funktioniert', [
'Wenn du das liest, kommen E-Mails aus deiner Klarbild-Installation an.',
'Damit funktionieren jetzt: Passwort zurücksetzen, Hinweise zu fertigen Aufträgen und Teilen-Links per Mail.',
]),
});
return json({ ok: true, message: `Testnachricht an ${to} verschickt. Schau auch im Spam-Ordner nach.` });
} catch (e: any) {
console.error('[test-mail]', e?.message || e);
return json({ ok: false, message: 'Versand fehlgeschlagen — Zugangsdaten und Port prüfen.' });
}
};
+88
View File
@@ -0,0 +1,88 @@
import type { APIRoute } from 'astro';
import { randomBytes, createHash } from 'node:crypto';
import { one, query } from '../../../lib/db';
import { hashPassword } from '../../../lib/auth';
import { sendMail, mailLayout, mailReady, istMail } from '../../../lib/mail';
export const prerender = false;
const json = (b: unknown, s = 200) =>
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
const GUELTIG_MINUTEN = 60;
const hashToken = (t: string) => createHash('sha256').update(t).digest('hex');
const basis = () => (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
// Ein Zähler je Adresse, damit niemand über diese Route Mails verschicken kann.
const versuche = new Map<string, { n: number; bis: number }>();
function zuOft(schluessel: string, max = 5, fensterMs = 15 * 60_000): boolean {
const jetzt = Date.now();
const e = versuche.get(schluessel);
if (!e || e.bis < jetzt) { versuche.set(schluessel, { n: 1, bis: jetzt + fensterMs }); return false; }
e.n++;
if (versuche.size > 5000) versuche.clear(); // simpler Schutz gegen Zuwachs
return e.n > max;
}
/**
* Rücksetz-Link anfordern (`{ email }`) oder neues Passwort setzen
* (`{ token, password }`).
*
* Wichtig: Beim Anfordern antwortet die Route **immer gleich**, egal ob es die
* Adresse gibt. Sonst ließe sich hier ablesen, wer ein Konto hat.
*/
export const POST: APIRoute = async ({ request, clientAddress }) => {
const b = await request.json().catch(() => ({}));
// --- Neues Passwort setzen -------------------------------------------------
if (b.token) {
const token = String(b.token);
const pw = String(b.password || '');
if (pw.length < 10) return json({ error: 'Das Passwort muss mindestens 10 Zeichen haben.' }, 400);
if (zuOft('einloesen:' + clientAddress, 20)) return json({ error: 'Zu viele Versuche. Bitte später erneut.' }, 429);
const r = await one<{ user_id: string }>(
`SELECT user_id FROM password_resets
WHERE token_hash = $1 AND used_at IS NULL AND expires_at > now()`, [hashToken(token)]);
if (!r) return json({ error: 'Dieser Link ist abgelaufen oder wurde schon benutzt.' }, 400);
await query('UPDATE users SET password_hash=$2 WHERE id=$1', [r.user_id, await hashPassword(pw)]);
// Einmalnutzung, und alle anderen offenen Links desselben Kontos verfallen mit.
await query('UPDATE password_resets SET used_at = now() WHERE user_id=$1 AND used_at IS NULL', [r.user_id]);
return json({ ok: true });
}
// --- Rücksetz-Link anfordern ----------------------------------------------
const email = String(b.email || '').trim().toLowerCase();
if (!istMail(email)) return json({ error: 'Bitte eine gültige E-Mail-Adresse eingeben.' }, 400);
if (zuOft('anfordern:' + clientAddress)) return json({ ok: true }); // still schlucken
if (!(await mailReady())) return json({ error: 'Auf diesem Server ist kein E-Mail-Versand eingerichtet.' }, 503);
const u = await one<{ id: string; display_name: string | null; username: string }>(
'SELECT id, display_name, username FROM users WHERE lower(email) = $1', [email]);
if (u) {
const token = randomBytes(32).toString('base64url');
await query(
`INSERT INTO password_resets (token_hash, user_id, expires_at)
VALUES ($1, $2, now() + ($3 || ' minutes')::interval)`,
[hashToken(token), u.id, String(GUELTIG_MINUTEN)]);
const url = `${basis()}/passwort?token=${token}`;
const name = u.display_name || u.username;
await sendMail({
to: email,
subject: 'Passwort für Klarbild zurücksetzen',
text: `Hallo ${name},\n\nüber den folgenden Link kannst du dir ein neues Passwort für Klarbild setzen. `
+ `Der Link gilt ${GUELTIG_MINUTEN} Minuten und lässt sich nur einmal verwenden.\n\n${url}\n\n`
+ `Wenn du das nicht angefordert hast, kannst du diese Nachricht ignorieren — es ändert sich dann nichts.`,
html: mailLayout('Neues Passwort setzen', [
`Hallo ${name},`,
`über den Knopf unten setzt du dir ein neues Passwort für Klarbild. Der Link gilt <b>${GUELTIG_MINUTEN} Minuten</b> und lässt sich nur <b>einmal</b> verwenden.`,
`Wenn du das nicht angefordert hast, kannst du diese Nachricht ignorieren — es ändert sich dann nichts.`,
], { text: 'Neues Passwort setzen', url }),
}).catch((e) => console.error('[reset] Mail fehlgeschlagen:', e?.message || e));
}
// Immer dieselbe Antwort — sonst verrät sie, welche Adressen ein Konto haben.
return json({ ok: true });
};
+38 -4
View File
@@ -1,15 +1,49 @@
import type { APIRoute } from 'astro'; import type { APIRoute } from 'astro';
import { one } from '../../lib/db'; import { one, query } from '../../lib/db';
import { istMail, mailReady } from '../../lib/mail';
export const prerender = false; export const prerender = false;
const json = (b: unknown, s = 200) =>
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
export const GET: APIRoute = async ({ locals }) => { export const GET: APIRoute = async ({ locals }) => {
if (!locals.user) return new Response('Unauthorized', { status: 401 }); if (!locals.user) return new Response('Unauthorized', { status: 401 });
const s = await one<{ private_allowed: boolean }>('SELECT private_allowed FROM settings WHERE id=1'); const s = await one<{ private_allowed: boolean }>('SELECT private_allowed FROM settings WHERE id=1');
const u = await one<{ private_forced: boolean }>('SELECT private_forced FROM users WHERE id=$1', [locals.user.uid]); const u = await one<{ private_forced: boolean; email: string | null; notify_jobs: boolean; display_name: string | null; username: string }>(
return new Response(JSON.stringify({ 'SELECT private_forced, email, notify_jobs, display_name, username FROM users WHERE id=$1', [locals.user.uid]);
return json({
role: locals.user.role, role: locals.user.role,
private_allowed: !!s?.private_allowed, private_allowed: !!s?.private_allowed,
private_forced: !!u?.private_forced, private_forced: !!u?.private_forced,
}), { headers: { 'Content-Type': 'application/json' } }); // Eigenes Konto: E-Mail und Benachrichtigungen
username: u?.username ?? null,
display_name: u?.display_name ?? null,
email: u?.email ?? null,
notify_jobs: !!u?.notify_jobs,
mail_ready: await mailReady(),
});
};
/** Eigenes Konto pflegen: E-Mail hinterlegen und Benachrichtigungen schalten. */
export const PATCH: APIRoute = async ({ request, locals }) => {
if (!locals.user?.uid) return new Response('Unauthorized', { status: 401 });
const b = await request.json().catch(() => ({}));
if ('email' in b) {
const roh = String(b.email ?? '').trim().toLowerCase();
if (roh && !istMail(roh)) return json({ error: 'Das sieht nicht nach einer gültigen Adresse aus.' }, 400);
// Die Adresse ist der Weg, ein Passwort zurückzusetzen — sie darf deshalb
// nicht zweimal vergeben sein.
if (roh) {
const belegt = await one<{ id: string }>(
'SELECT id FROM users WHERE lower(email)=$1 AND id<>$2', [roh, locals.user.uid]);
if (belegt) return json({ error: 'Diese Adresse gehört schon zu einem anderen Konto.' }, 409);
}
await query('UPDATE users SET email=$2 WHERE id=$1', [locals.user.uid, roh || null]);
}
if ('notify_jobs' in b) {
await query('UPDATE users SET notify_jobs=$2 WHERE id=$1', [locals.user.uid, !!b.notify_jobs]);
}
return json({ ok: true });
}; };
+81
View File
@@ -0,0 +1,81 @@
import type { APIRoute } from 'astro';
import sharp from 'sharp';
import { one } from '../../../../lib/db';
import { getObject } from '../../../../lib/storage';
import { resolveShare, shareHasItem, trackShare } from '../../../../lib/shares';
export const prerender = false;
/**
* Bild hinter einem Teilen-Link ausliefern — **ohne Anmeldung**.
*
* Die Zugriffsprüfung hängt allein am Link, nicht am Bild: Nur was in
* `share_items` steht, kommt heraus. Damit kann niemand über einen gültigen Link
* fremde Bilder abrufen, indem er eine andere Bild-Kennung einsetzt.
*/
export const GET: APIRoute = async ({ params, url, request, clientAddress }) => {
const q = url.searchParams;
const { status, share } = await resolveShare(params.slug!, q.get('pw'));
if (status !== 'ok' || !share) {
return new Response(
status === 'abgelaufen' ? 'Dieser Link ist abgelaufen.'
: status === 'widerrufen' ? 'Dieser Link wurde deaktiviert.'
: status === 'passwort' ? 'Zugangswort nötig.'
: 'Nicht gefunden.',
{ status: status === 'passwort' ? 401 : status === 'nicht-gefunden' ? 404 : 410 });
}
const itemId = q.get('item');
if (!itemId || !/^[0-9a-f-]{36}$/i.test(itemId)) return new Response('Nicht gefunden.', { status: 404 });
if (!(await shareHasItem(share.id, itemId))) return new Response('Nicht gefunden.', { status: 404 });
const item = await one<any>(
'SELECT result_path, thumb_path, filename, has_alpha FROM items WHERE id=$1', [itemId]);
if (!item?.result_path) return new Response('Nicht gefunden.', { status: 404 });
const download = q.get('download') === '1';
const wantThumb = q.get('thumb') === '1';
// Wer den Download abgeschaltet hat, gibt auch die Originaldatei nicht heraus —
// sonst wäre die Einstellung nur Zierde.
if (download && !share.allow_download) return new Response('Herunterladen ist für diesen Link aus.', { status: 403 });
const path = wantThumb ? (item.thumb_path || item.result_path) : item.result_path;
try {
let buf = await getObject(path);
const sniff = (b: Buffer): string =>
b.length > 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff ? 'image/jpeg'
: b.length > 12 && b.toString('ascii', 8, 12) === 'WEBP' ? 'image/webp'
: 'image/png';
let contentType = path.endsWith('.webp') ? 'image/webp' : sniff(buf);
// Ansichtsfassung: kleiner und schneller, vor allem am Handy. Der Download
// liefert weiterhin die unveränderte Originaldatei in voller Auflösung.
if (!download && !wantThumb && !item.has_alpha) {
try {
buf = await sharp(buf, { failOn: 'none' })
.resize({ width: 2000, height: 2000, fit: 'inside', withoutEnlargement: true })
.jpeg({ quality: 88, mozjpeg: true }).toBuffer();
contentType = 'image/jpeg';
} catch { /* dann eben das Original */ }
}
if (download) {
await trackShare(share.id, 'download', clientAddress, request.headers.get('user-agent'));
}
const headers: Record<string, string> = {
'Content-Type': contentType,
'Content-Length': String(buf.length),
// Nicht in Suchmaschinen und nicht in fremde Zwischenspeicher.
'X-Robots-Tag': 'noindex, nofollow, noarchive, noimageindex',
'Cache-Control': 'private, max-age=300',
};
if (download) {
const name = (item.filename || 'klarbild.jpg').replace(/[^\w.\-]/g, '_');
headers['Content-Disposition'] = `attachment; filename="${name}"`;
}
return new Response(new Uint8Array(buf), { headers });
} catch {
return new Response('Bild nicht mehr vorhanden.', { status: 404 });
}
};
+49
View File
@@ -0,0 +1,49 @@
import type { APIRoute } from 'astro';
import { query } from '../../../lib/db';
import { updateShare, revokeShare, unrevokeShare, deleteShare } from '../../../lib/shares';
export const prerender = false;
const json = (b: unknown, s = 200) =>
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
/** Die letzten Zugriffe auf einen Link — für die Auswertung im Frontend. */
export const GET: APIRoute = async ({ params, locals }) => {
if (!locals.user) return new Response('Unauthorized', { status: 401 });
if (!UUID.test(params.id || '')) return json({ error: 'Unbekannter Link.' }, 404);
const istAdmin = locals.user.role === 'admin';
const rows = await query<any>(
`SELECT v.kind, v.at, v.user_agent
FROM share_views v JOIN shares s ON s.id = v.share_id
WHERE v.share_id = $1 AND ($3::boolean OR s.created_by = $2)
ORDER BY v.at DESC LIMIT 100`, [params.id, locals.user.uid, istAdmin]);
return json({ zugriffe: rows });
};
/** Titel, Ablauf, Zugangswort oder Download-Erlaubnis ändern; widerrufen/freigeben. */
export const PATCH: APIRoute = async ({ params, request, locals }) => {
if (!locals.user) return new Response('Unauthorized', { status: 401 });
if (!UUID.test(params.id || '')) return json({ error: 'Unbekannter Link.' }, 404);
const b = await request.json().catch(() => ({}));
const istAdmin = locals.user.role === 'admin';
if (b.action === 'revoke') {
return json({ ok: await revokeShare(params.id!, locals.user.uid, istAdmin) });
}
if (b.action === 'unrevoke') {
return json({ ok: await unrevokeShare(params.id!, locals.user.uid, istAdmin) });
}
const ok = await updateShare(params.id!, locals.user.uid, istAdmin, {
title: b.title, note: b.note, days: b.days,
allowDownload: b.allowDownload, password: b.password,
});
return ok ? json({ ok: true }) : json({ error: 'Unbekannter Link.' }, 404);
};
export const DELETE: APIRoute = async ({ params, locals }) => {
if (!locals.user) return new Response('Unauthorized', { status: 401 });
if (!UUID.test(params.id || '')) return json({ error: 'Unbekannter Link.' }, 404);
const ok = await deleteShare(params.id!, locals.user.uid, locals.user.role === 'admin');
return ok ? json({ ok: true }) : json({ error: 'Unbekannter Link.' }, 404);
};
+55
View File
@@ -0,0 +1,55 @@
import type { APIRoute } from 'astro';
import { one } from '../../../lib/db';
import { createShare, listShares, MAX_ITEMS } from '../../../lib/shares';
export const prerender = false;
const json = (b: unknown, s = 200) =>
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
/** Eigene Teilen-Links auflisten (Admins sehen alle). */
export const GET: APIRoute = async ({ locals }) => {
if (!locals.user) return new Response('Unauthorized', { status: 401 });
const shares = await listShares(locals.user.uid, locals.user.role === 'admin');
return json({ shares });
};
/**
* Neuen Link anlegen.
* Es dürfen nur Bilder geteilt werden, die man auch sehen darf — sonst wäre der
* Teilen-Knopf ein bequemer Weg an der Sichtbarkeitsregel vorbei.
*/
export const POST: APIRoute = async ({ request, locals }) => {
if (!locals.user) return new Response('Unauthorized', { status: 401 });
const b = await request.json().catch(() => ({}));
const ids: string[] = Array.isArray(b.itemIds) ? b.itemIds.filter((x: any) => typeof x === 'string') : [];
if (!ids.length) return json({ error: 'Keine Bilder ausgewählt.' }, 400);
if (ids.length > MAX_ITEMS) return json({ error: `Höchstens ${MAX_ITEMS} Bilder je Link.` }, 422);
const istAdmin = locals.user.role === 'admin';
const s = await one<{ library_visibility: string }>('SELECT library_visibility FROM settings WHERE id=1');
for (const id of ids) {
const it = await one<any>(
`SELECT i.id, i.status, i.result_path, j.created_by, j.private
FROM items i JOIN jobs j ON j.id = i.job_id WHERE i.id = $1`, [id]);
if (!it?.result_path || it.status !== 'done') return json({ error: 'Ein Bild ist nicht (mehr) verfügbar.' }, 404);
const eigen = it.created_by === locals.user.uid;
if (!istAdmin && !eigen && (it.private || s?.library_visibility !== 'shared'))
return json({ error: 'Kein Zugriff auf eines der Bilder.' }, 403);
// Private Aufträge werden bewusst nie geteilt — sie sollen ja gerade
// keine Spur hinterlassen.
if (it.private) return json({ error: 'Bilder aus privaten Sitzungen lassen sich nicht teilen.' }, 403);
}
try {
const share = await createShare({
itemIds: ids,
kind: b.kind === 'collection' ? 'collection' : undefined,
title: b.title, note: b.note,
days: b.days, allowDownload: b.allowDownload, password: b.password,
userId: locals.user.uid,
});
return json({ share });
} catch (e: any) {
return json({ error: e?.message || 'Link konnte nicht angelegt werden.' }, 400);
}
};
+75
View File
@@ -0,0 +1,75 @@
import type { APIRoute } from 'astro';
import { one } from '../../../lib/db';
import { sendMail, mailLayout, mailReady, istMail } from '../../../lib/mail';
export const prerender = false;
const json = (b: unknown, s = 200) =>
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
const basis = () => (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
const MAX_EMPFAENGER = 10;
/**
* Einen Teilen-Link per E-Mail verschicken.
*
* Bewusst **ohne Bildanhang**: Ein Poster in voller Auflösung sprengt jedes
* Postfach, und ein Anhang lässt sich später nicht mehr zurückziehen. Der Link
* dagegen schon — er hat ein Ablaufdatum und kann jederzeit deaktiviert werden.
*/
export const POST: APIRoute = async ({ request, locals }) => {
if (!locals.user) return new Response('Unauthorized', { status: 401 });
const b = await request.json().catch(() => ({}));
const empfaenger: string[] = (Array.isArray(b.to) ? b.to : String(b.to || '').split(/[,;\s]+/))
.map((x: string) => String(x).trim().toLowerCase()).filter(Boolean);
if (!empfaenger.length) return json({ error: 'Keine Empfängeradresse angegeben.' }, 400);
if (empfaenger.length > MAX_EMPFAENGER) return json({ error: `Höchstens ${MAX_EMPFAENGER} Empfänger auf einmal.` }, 422);
const schlecht = empfaenger.filter((e) => !istMail(e));
if (schlecht.length) return json({ error: `Keine gültige Adresse: ${schlecht[0]}` }, 400);
if (!(await mailReady())) return json({ error: 'Es ist kein E-Mail-Versand eingerichtet (Admin → E-Mail).' }, 503);
const istAdmin = locals.user.role === 'admin';
const share = await one<any>(
`SELECT s.*, (SELECT count(*)::int FROM share_items si WHERE si.share_id = s.id) AS anzahl
FROM shares s WHERE s.id = $1 AND ($3::boolean OR s.created_by = $2)`,
[b.shareId, locals.user.uid, istAdmin]);
if (!share) return json({ error: 'Unbekannter Link.' }, 404);
if (share.revoked_at) return json({ error: 'Dieser Link ist deaktiviert — erst wieder freigeben.' }, 409);
const url = `${basis()}/s/${share.slug}`;
const absender = locals.user.name || locals.user.username || 'Jemand';
const titel = share.title || (share.anzahl > 1 ? `${share.anzahl} Bilder` : 'Ein Bild');
const nachricht = String(b.message || '').trim().slice(0, 1000);
const gueltig = share.expires_at
? `Der Link gilt bis zum ${new Date(share.expires_at).toLocaleDateString('de-DE')}.`
: 'Der Link gilt unbegrenzt.';
const wort = share.password_hash
? 'Zum Öffnen brauchst du außerdem das Zugangswort — das bekommst du getrennt.'
: '';
const absaetze = [
`${absender} hat dir etwas über Klarbild geschickt: <b>${titel}</b>.`,
...(nachricht ? [nachricht.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/\n/g, '<br>')] : []),
[gueltig, wort].filter(Boolean).join(' '),
];
let ok = 0; const fehler: string[] = [];
for (const to of empfaenger) {
try {
await sendMail({
to,
subject: `${absender} teilt: ${titel}`,
text: `${absender} hat dir etwas über Klarbild geschickt: ${titel}.\n\n`
+ (nachricht ? `${nachricht}\n\n` : '')
+ `${url}\n\n${gueltig}${wort ? ' ' + wort : ''}`,
html: mailLayout(titel, absaetze, { text: 'Bilder ansehen', url }),
});
ok++;
} catch (e: any) {
console.error('[share/send] an', to, 'fehlgeschlagen:', e?.message || e);
fehler.push(to);
}
}
return json({ ok, fehler, url });
};
+20
View File
@@ -0,0 +1,20 @@
---
import Base from '../layouts/Base.astro';
import KontoApp from '../components/KontoApp';
---
<Base title="Mein Konto · Klarbild">
<section class="seite">
<header class="seitenkopf">
<h1>Mein Konto</h1>
<p class="lead">E-Mail-Adresse und Benachrichtigungen.</p>
</header>
<KontoApp client:load />
</section>
<style>
.seite { max-width: var(--maxw); margin: 0 auto; padding: 26px 20px 80px; }
.seitenkopf { margin-bottom: 18px; }
.seitenkopf h1 { font-size: clamp(1.4rem, 4vw, 1.9rem); margin: 0 0 6px; letter-spacing: -0.02em; }
.lead { margin: 0; color: var(--ink-soft); line-height: 1.6; font-size: 0.95rem; }
@media (max-width: 640px) { .seite { padding: 18px 14px 90px; } }
</style>
</Base>
+3
View File
@@ -11,6 +11,7 @@ if (Astro.locals.user) return Astro.redirect('/');
<label>Passwort<input name="password" type="password" autocomplete="current-password" required /></label> <label>Passwort<input name="password" type="password" autocomplete="current-password" required /></label>
<button type="submit">Anmelden</button> <button type="submit">Anmelden</button>
<p id="err" class="err" hidden></p> <p id="err" class="err" hidden></p>
<a class="vergessen" href="/passwort">Passwort vergessen?</a>
</form> </form>
</section> </section>
<style> <style>
@@ -25,6 +26,8 @@ if (Astro.locals.user) return Astro.redirect('/');
button { margin-top: 4px; padding: 11px; border: none; border-radius: var(--radius-sm); button { margin-top: 4px; padding: 11px; border: none; border-radius: var(--radius-sm);
background: var(--accent); color: white; font-size: 1rem; font-weight: 600; cursor: pointer; } background: var(--accent); color: white; font-size: 1rem; font-weight: 600; cursor: pointer; }
.err { color: var(--err); font-size: 0.9rem; margin: 4px 0 0; } .err { color: var(--err); font-size: 0.9rem; margin: 4px 0 0; }
.vergessen { font-size: 0.85rem; color: var(--ink-soft); text-align: center; text-decoration: none; padding: 4px; }
.vergessen:hover { color: var(--accent); }
</style> </style>
<script> <script>
const f = document.getElementById('f'); const err = document.getElementById('err'); const f = document.getElementById('f'); const err = document.getElementById('err');
+97
View File
@@ -0,0 +1,97 @@
---
// „Passwort vergessen" — ohne Anmeldung erreichbar.
// Zwei Zustände in einer Seite: ohne Token die Adresse abfragen, mit Token das
// neue Passwort setzen.
import Base from '../layouts/Base.astro';
const token = Astro.url.searchParams.get('token') || '';
---
<Base title="Passwort · Klarbild">
<main class="pw">
<h1 class="display">Klarbild</h1>
{token ? (
<form id="setzen" class="karte" data-token={token}>
<h2>Neues Passwort setzen</h2>
<label>Neues Passwort
<input name="password" type="password" autocomplete="new-password" required minlength="10"
placeholder="mindestens 10 Zeichen" autofocus />
</label>
<label>Noch einmal
<input name="password2" type="password" autocomplete="new-password" required minlength="10" />
</label>
<button type="submit">Passwort speichern</button>
<p class="hinweis">Ein langer Satz ist sicherer und leichter zu merken als ein kurzes Kauderwelsch.</p>
<p id="msg" class="msg" hidden></p>
</form>
) : (
<form id="anfordern" class="karte">
<h2>Passwort vergessen</h2>
<p class="lead">Trag deine E-Mail-Adresse ein. Wenn es dazu ein Konto gibt, kommt gleich ein Link,
mit dem du dir ein neues Passwort setzen kannst.</p>
<label>E-Mail-Adresse
<input name="email" type="email" autocomplete="email" required autofocus placeholder="du@beispiel.de" />
</label>
<button type="submit">Link schicken</button>
<p id="msg" class="msg" hidden></p>
</form>
)}
<p class="zurueck"><a href="/login">← Zur Anmeldung</a></p>
</main>
<style>
.pw { max-width: 400px; margin: 8vh auto 0; padding: 0 18px 60px; text-align: center; }
.display { font-family: var(--font-display); font-size: 2.2rem; margin: 0 0 26px; letter-spacing: -0.02em; }
.karte { display: grid; gap: 14px; text-align: left; background: var(--surface);
border: 1px solid var(--line); border-radius: var(--radius); padding: 22px; box-shadow: var(--shadow); }
.karte h2 { font-size: 1.1rem; margin: 0; }
.lead { margin: 0; font-size: 0.9rem; color: var(--ink-soft); line-height: 1.6; }
label { display: grid; gap: 6px; font-size: 0.9rem; color: var(--ink-soft); }
input { padding: 11px 12px; border: 1px solid var(--line); border-radius: var(--radius-sm);
background: var(--paper); color: var(--ink); font-size: 16px; font-family: inherit; }
button { margin-top: 4px; padding: 12px; border: none; border-radius: var(--radius-sm);
background: var(--accent); color: #fff; font-size: 1rem; font-weight: 600; cursor: pointer; font-family: inherit; }
button:disabled { background: var(--paper); color: var(--soft); border: 1px solid var(--line); cursor: not-allowed; }
.hinweis { margin: 0; font-size: 0.8rem; color: var(--soft); line-height: 1.5; }
.msg { margin: 4px 0 0; font-size: 0.9rem; line-height: 1.5; }
.msg.ok { color: var(--ok); }
.msg.err { color: var(--err); }
.zurueck { margin-top: 22px; font-size: 0.9rem; }
</style>
<script>
const zeig = (el: HTMLElement, text: string, ok: boolean) => {
el.textContent = text; el.className = 'msg ' + (ok ? 'ok' : 'err'); (el as HTMLElement).hidden = false;
};
const post = (body: unknown) =>
fetch('/api/auth/reset', { method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body) }).then((r) => r.json().catch(() => ({})));
const anfordern = document.getElementById('anfordern') as HTMLFormElement | null;
anfordern?.addEventListener('submit', async (e) => {
e.preventDefault();
const msg = document.getElementById('msg')!;
const btn = anfordern.querySelector('button')!;
btn.disabled = true;
const email = (new FormData(anfordern).get('email') || '').toString();
const r = await post({ email });
btn.disabled = false;
if (r.error) zeig(msg, r.error, false);
else zeig(msg, 'Wenn es zu dieser Adresse ein Konto gibt, ist die Nachricht unterwegs. Schau auch im Spam nach.', true);
});
const setzen = document.getElementById('setzen') as HTMLFormElement | null;
setzen?.addEventListener('submit', async (e) => {
e.preventDefault();
const msg = document.getElementById('msg')!;
const f = new FormData(setzen);
const pw = (f.get('password') || '').toString();
if (pw !== (f.get('password2') || '').toString()) { zeig(msg, 'Die beiden Eingaben stimmen nicht überein.', false); return; }
const btn = setzen.querySelector('button')!;
btn.disabled = true;
const r = await post({ token: setzen.dataset.token, password: pw });
if (r.ok) { zeig(msg, 'Passwort gespeichert. Du wirst zur Anmeldung geleitet …', true); setTimeout(() => (location.href = '/login'), 1400); }
else { btn.disabled = false; zeig(msg, r.error || 'Das hat nicht geklappt.', false); }
});
</script>
</Base>
+125
View File
@@ -0,0 +1,125 @@
---
// Öffentliche Ansicht eines Teilen-Links — ohne Anmeldung erreichbar.
//
// Bewusst eine eigene, schlanke Seite statt der App: Wer den Link bekommt, soll
// die Bilder sehen und herunterladen können, sonst nichts. Keine Navigation,
// kein Zugang zur Bibliothek, kein Hinweis darauf, was es sonst noch gibt.
import Base from '../../layouts/Base.astro';
import { resolveShare, shareItems, trackShare } from '../../lib/shares';
const slug = Astro.params.slug!;
const pw = Astro.url.searchParams.get('pw');
const { status, share } = await resolveShare(slug, pw);
let bilder: Awaited<ReturnType<typeof shareItems>> = [];
if (status === 'ok' && share) {
bilder = await shareItems(share.id);
await trackShare(share.id, 'view', Astro.clientAddress, Astro.request.headers.get('user-agent'));
}
// Diese Seite gehört nie in einen Suchindex.
Astro.response.headers.set('X-Robots-Tag', 'noindex, nofollow, noarchive, noimageindex');
if (status !== 'ok') Astro.response.status = status === 'nicht-gefunden' ? 404 : status === 'passwort' ? 401 : 410;
const titel = share?.title || (bilder.length > 1 ? 'Bilder' : 'Bild');
const dl = (id: string) => `/api/s/${slug}/file?item=${id}&download=1${pw ? `&pw=${encodeURIComponent(pw)}` : ''}`;
const gross = (id: string) => `/api/s/${slug}/file?item=${id}${pw ? `&pw=${encodeURIComponent(pw)}` : ''}`;
const meldung: Record<string, { h: string; t: string }> = {
'nicht-gefunden': { h: 'Diesen Link gibt es nicht', t: 'Vielleicht hat sich beim Kopieren ein Zeichen verloren. Frag am besten noch einmal nach.' },
'abgelaufen': { h: 'Dieser Link ist abgelaufen', t: 'Teilen-Links gelten nur eine begrenzte Zeit. Wer ihn dir geschickt hat, kann einen neuen erzeugen.' },
'widerrufen': { h: 'Dieser Link wurde deaktiviert', t: 'Er wurde bewusst abgeschaltet. Frag noch einmal nach, wenn du die Bilder brauchst.' },
};
---
<Base title={`${titel} · Klarbild`}>
<main class="teilen">
{status === 'ok' && share ? (
<>
<header class="kopf">
<h1>{titel}</h1>
{share.note && <p class="notiz">{share.note}</p>}
<p class="mono-label">
{bilder.length === 1 ? '1 Bild' : `${bilder.length} Bilder`}
{share.expires_at && <> · gültig bis {new Date(share.expires_at).toLocaleDateString('de-DE')}</>}
</p>
</header>
{bilder.length === 0 ? (
<p class="leer">Zu diesem Link gibt es gerade keine Bilder.</p>
) : (
<div class={bilder.length === 1 ? 'raster einzeln' : 'raster'}>
{bilder.map((b) => (
<figure class="bild">
<a href={gross(b.id)} target="_blank" rel="noopener">
<img src={gross(b.id)} alt={b.filename || 'Bild'} loading="lazy" />
</a>
<figcaption>
<span class="mass">{b.output_px?.replace('x', ' × ') || ''}</span>
{share.allow_download
? <a class="knopf" href={dl(b.id)}>Herunterladen</a>
: <span class="fein">Nur ansehen</span>}
</figcaption>
</figure>
))}
</div>
)}
<p class="hinweis">
Am Handy: Bild antippen, dann lange darauf drücken und „Zu Fotos hinzufügen" wählen.
{share.allow_download && <> Über <b>Herunterladen</b> kommt die Datei in voller Auflösung.</>}
</p>
</>
) : status === 'passwort' ? (
<section class="tor">
<h1>Dieser Link ist mit einem Wort geschützt</h1>
<p>Gib das Zugangswort ein, das du zusammen mit dem Link bekommen hast.</p>
<form method="get" class="tor-form">
<input type="password" name="pw" placeholder="Zugangswort" aria-label="Zugangswort" autofocus required />
<button type="submit">Ansehen</button>
</form>
{pw && <p class="fehler">Das war leider nicht richtig.</p>}
</section>
) : (
<section class="tor">
<h1>{meldung[status]?.h ?? 'Nicht verfügbar'}</h1>
<p>{meldung[status]?.t ?? ''}</p>
</section>
)}
</main>
<style>
.teilen { max-width: 1000px; margin: 0 auto; padding: 40px 20px 72px; }
.kopf { margin-bottom: 26px; }
.kopf h1 { font-size: clamp(1.5rem, 4vw, 2rem); margin: 0 0 8px; letter-spacing: -0.02em; }
.notiz { margin: 0 0 10px; color: var(--ink-soft); max-width: 60ch; line-height: 1.6; }
.raster { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 18px; }
.raster.einzeln { grid-template-columns: minmax(0, 1fr); max-width: 720px; }
.bild { margin: 0; background: var(--card); border: 1px solid var(--line); border-radius: var(--radius); overflow: hidden; }
.bild a { display: block; line-height: 0; background: var(--paper); }
.bild img { width: 100%; height: auto; display: block; }
figcaption { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 10px 12px; }
.mass { font-family: var(--font-mono); font-size: 10px; letter-spacing: 0.1em; color: var(--soft); }
.knopf { background: var(--accent); color: #fff; text-decoration: none; font-size: 13px; font-weight: 600;
padding: 8px 14px; border-radius: 3px; white-space: nowrap; }
.fein { font-size: 12px; color: var(--soft); }
.leer { color: var(--soft); }
.hinweis { margin-top: 28px; padding-top: 18px; border-top: 1px solid var(--line);
font-size: 13px; color: var(--ink-soft); line-height: 1.6; max-width: 62ch; }
.tor { max-width: 460px; margin: 8vh auto 0; text-align: center; }
.tor h1 { font-size: 1.4rem; margin: 0 0 12px; }
.tor p { color: var(--ink-soft); line-height: 1.6; margin: 0 0 20px; }
.tor-form { display: flex; gap: 8px; }
.tor-form input { flex: 1; padding: 12px; border: 1px solid var(--line); border-radius: var(--radius-sm);
background: var(--card); font-size: 16px; font-family: inherit; color: var(--ink); }
.tor-form button { border: none; border-radius: var(--radius-sm); background: var(--accent); color: #fff;
padding: 12px 20px; font-weight: 600; font-family: inherit; font-size: 15px; cursor: pointer; }
.fehler { color: var(--err); margin-top: 14px; font-size: 14px; }
@media (max-width: 640px) {
.teilen { padding: 24px 14px 56px; }
.raster { grid-template-columns: 1fr; gap: 14px; }
.knopf { padding: 11px 16px; }
}
</style>
</Base>
+21
View File
@@ -0,0 +1,21 @@
---
import Base from '../layouts/Base.astro';
import SharesApp from '../components/SharesApp';
---
<Base title="Geteilte Links · Klarbild">
<section class="seite">
<header class="seitenkopf">
<h1>Geteilte Links</h1>
<p class="lead">Links, mit denen andere deine Bilder ansehen können — ohne Konto.
Hier siehst du, wie oft sie geöffnet wurden, und kannst sie jederzeit abschalten.</p>
</header>
<SharesApp client:load />
</section>
<style>
.seite { max-width: var(--maxw); margin: 0 auto; padding: 26px 20px 80px; }
.seitenkopf { margin-bottom: 20px; }
.seitenkopf h1 { font-size: clamp(1.4rem, 4vw, 1.9rem); margin: 0 0 6px; letter-spacing: -0.02em; }
.lead { margin: 0; color: var(--ink-soft); line-height: 1.6; max-width: 64ch; font-size: 0.95rem; }
@media (max-width: 640px) { .seite { padding: 18px 14px 90px; } }
</style>
</Base>
+38
View File
@@ -90,5 +90,43 @@ async function maybeComplete(jobId: string): Promise<void> {
await notifyJobDone(Number(jt.telegram_chat_id), jobId); await notifyJobDone(Number(jt.telegram_chat_id), jobId);
} catch (e) { console.error('[worker] Telegram-Notify:', e); } } catch (e) { console.error('[worker] Telegram-Notify:', e); }
} }
// Hinweis per E-Mail — nur, wenn der Auftraggeber das ausdrücklich
// eingeschaltet hat. Fehlschläge bleiben im Log; ein Auftrag gilt nicht
// deshalb als gescheitert, weil eine Benachrichtigung nicht rausging.
try { await mailJobDone(jobId, finalStatus, row.done, row.failed); }
catch (e) { console.error('[worker] Mail-Notify:', e); }
} }
} }
/** Kurze Rückmeldung an den Auftraggeber, wenn er sie bestellt hat. */
async function mailJobDone(jobId: string, status: string, fertig: number, fehler: number): Promise<void> {
const u = await one<{ email: string | null; display_name: string | null; username: string; private: boolean }>(
`SELECT u.email, u.display_name, u.username, j.private
FROM jobs j JOIN users u ON u.id = j.created_by
WHERE j.id = $1 AND u.notify_jobs = true AND u.email IS NOT NULL`, [jobId]);
// Private Sitzungen hinterlassen bewusst keine Spur — auch keine Mail.
if (!u?.email || u.private) return;
const { trySendMail, mailLayout, mailReady } = await import('./lib/mail');
if (!(await mailReady())) return;
const basis = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
const url = `${basis}/warteschlange`;
const name = u.display_name || u.username;
const gut = status !== 'failed';
const titel = gut ? 'Deine Bilder sind fertig' : 'Ein Auftrag ist fehlgeschlagen';
const kern = gut
? `${fertig} ${fertig === 1 ? 'Bild ist' : 'Bilder sind'} fertig${fehler ? `, ${fehler} nicht` : ''}.`
: `Es hat leider nichts geklappt (${fehler} ${fehler === 1 ? 'Position' : 'Positionen'}). In der Warteschlange steht der Grund.`;
await trySendMail({
to: u.email,
subject: `Klarbild — ${titel.toLowerCase()}`,
text: `Hallo ${name},\n\n${kern}\n\n${url}\n\n`
+ `Diese Hinweise kannst du in Klarbild unter „Mein Konto" wieder abschalten.`,
html: mailLayout(titel, [
`Hallo ${name},`, kern,
`Diese Hinweise kannst du in Klarbild unter „Mein Konto" jederzeit wieder abschalten.`,
], { text: 'In Klarbild ansehen', url }),
});
}