diff --git a/migrations/015_session_epoch.sql b/migrations/015_session_epoch.sql
new file mode 100644
index 0000000..8ac634e
--- /dev/null
+++ b/migrations/015_session_epoch.sql
@@ -0,0 +1,37 @@
+-- Sitzungen serverseitig entwertbar machen ---------------------------------
+-- Bis hierher war das Sitzungs-Cookie nur HMAC-signiert: Wer es einmal hatte,
+-- kam damit unbegrenzt hinein — auch nachdem das Passwort zurückgesetzt oder
+-- die Rolle entzogen wurde. Genau der Vorgang, der ein übernommenes Konto
+-- retten soll, half also nicht.
+--
+-- `session_epoch` wird bei jedem Passwortwechsel und jeder Rollenänderung
+-- hochgezählt. Das Cookie trägt den Stand mit; stimmt er nicht mehr, gilt es
+-- nicht mehr. Ein Zähler statt eines Zeitstempels, weil er keine Uhrzeit-
+-- Ungenauigkeiten kennt.
+ALTER TABLE users ADD COLUMN IF NOT EXISTS session_epoch integer NOT NULL DEFAULT 0;
+
+-- Für die Namensvergabe: `renames.ts` fragt bei jedem fertigen Bild, ob ein
+-- Name schon vergeben ist — ohne Index war das ein voller Tabellendurchlauf.
+CREATE INDEX IF NOT EXISTS items_filename_idx ON items(filename);
+
+-- Aufräumen: Beide Tabellen wuchsen unbegrenzt.
+CREATE INDEX IF NOT EXISTS share_views_at_idx ON share_views(at);
+CREATE INDEX IF NOT EXISTS password_resets_expires_idx ON password_resets(expires_at);
+
+-- Zähler für verschiedene Besucher je Teilen-Link. Vorher wurde er bei jedem
+-- Aufruf der Verwaltungsseite über `count(DISTINCT ip_hash)` aus share_views
+-- gerechnet — das wurde mit wachsender Tabelle zäh und machte die Seite
+-- angreifbar, weil jeder mit dem Link beliebig viele Zeilen erzeugen konnte.
+ALTER TABLE shares ADD COLUMN IF NOT EXISTS besucher integer NOT NULL DEFAULT 0;
+
+-- Nur eine Zeile je Link, Besucher und Stunde: Ohne diese Grenze konnte ein
+-- Empfänger (oder ein -Tag auf irgendeiner Seite) die Tabelle unbegrenzt
+-- füllen und damit die Platte volllaufen lassen.
+--
+-- Der Stundenwert wird beim Schreiben mitgegeben statt im Index gerechnet:
+-- `date_trunc` auf einem timestamptz hängt an der Zeitzonen-Einstellung und ist
+-- damit nicht unveränderlich genug für einen Index.
+ALTER TABLE share_views ADD COLUMN IF NOT EXISTS stunde text;
+CREATE UNIQUE INDEX IF NOT EXISTS share_views_einmal_je_stunde_idx
+ ON share_views (share_id, kind, ip_hash, stunde)
+ WHERE ip_hash IS NOT NULL AND stunde IS NOT NULL;
diff --git a/src/components/KontoApp.tsx b/src/components/KontoApp.tsx
index ccb09f6..5df300c 100644
--- a/src/components/KontoApp.tsx
+++ b/src/components/KontoApp.tsx
@@ -24,6 +24,9 @@ export default function KontoApp() {
const sagen = (t: string, ok: boolean) => { setMsg({ t, ok }); setTimeout(() => setMsg(null), 5000); };
const patch = async (body: unknown, erfolg: string) => {
+ // Den neuen Wert sofort übernehmen, sonst springt der Haken beim Rerender
+ // auf den alten Stand zurück und man klickt aus Versehen ein zweites Mal.
+ setMe((m) => (m ? { ...m, ...(body as object) } as Me : m));
setLaeuft(true);
const r = await fetch('/api/me', {
method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
@@ -89,7 +92,7 @@ export default function KontoApp() {
Es wird festgehalten, wann und womit geöffnet wurde —
- nicht wer. Die IP-Adresse liegt nur gehasht vor und lässt sich nicht zurückrechnen.
+
Festgehalten wird, wann und mit welcher Art Gerät geöffnet
+ wurde — nicht wer. Von der IP-Adresse wird nur ein verschlüsselter Fingerabdruck
+ gespeichert, der außerdem an diesen einen Link gebunden ist; über mehrere Links hinweg
+ lässt sich damit niemand verfolgen. Nach 90 Tagen wird alles gelöscht.
)}
diff --git a/src/lib/auth.ts b/src/lib/auth.ts
index c3841d2..5a329e1 100644
--- a/src/lib/auth.ts
+++ b/src/lib/auth.ts
@@ -1,8 +1,10 @@
import argon2 from 'argon2';
import { createHmac, timingSafeEqual } from 'node:crypto';
-import { one } from './db';
+import { one, query } from './db';
export interface SessionUser { uid: string; role: 'user' | 'admin'; name: string; }
+/** Was tatsächlich im Cookie steht — inklusive der Angaben, die es entwertbar machen. */
+interface SessionPayload extends SessionUser { iat: number; ep: number }
const COOKIE = 'kb_session';
const MAX_AGE = 60 * 60 * 24 * 30; // 30 Tage
@@ -20,8 +22,8 @@ function sign(data: string): string {
return createHmac('sha256', secret()).update(data).digest('base64url');
}
-export function makeSessionCookie(u: SessionUser): string {
- const payload = Buffer.from(JSON.stringify({ ...u, iat: Math.floor(Date.now() / 1000) }))
+export function makeSessionCookie(u: SessionUser, epoch = 0): string {
+ const payload = Buffer.from(JSON.stringify({ ...u, iat: Math.floor(Date.now() / 1000), ep: epoch }))
.toString('base64url');
const value = `${payload}.${sign(payload)}`;
return `${COOKIE}=${value}; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=${MAX_AGE}`;
@@ -30,7 +32,18 @@ export function makeSessionCookie(u: SessionUser): string {
export const clearSessionCookie = () =>
`${COOKIE}=; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=0`;
-export function readSession(cookieHeader?: string | null): SessionUser | null {
+/**
+ * Cookie prüfen — Signatur **und** Alter.
+ *
+ * Die Alterprüfung ist nicht Kosmetik: `Max-Age` ist eine Bitte an den Browser,
+ * keine Regel für den Server. Ohne sie gilt ein einmal abgeflossenes Cookie
+ * unbegrenzt, auch Jahre später und auch per curl.
+ *
+ * Ob das Cookie **inhaltlich** noch gilt (Passwort gewechselt, Rolle entzogen,
+ * Konto gelöscht), lässt sich hier nicht entscheiden — dafür braucht es die
+ * Datenbank. Das erledigt `verifySession` in der Middleware.
+ */
+export function readSession(cookieHeader?: string | null): (SessionUser & { ep: number }) | null {
if (!cookieHeader) return null;
const raw = cookieHeader.split(/;\s*/).find((c) => c.startsWith(`${COOKIE}=`))?.slice(COOKIE.length + 1);
if (!raw) return null;
@@ -40,19 +53,48 @@ export function readSession(cookieHeader?: string | null): SessionUser | null {
const a = Buffer.from(sig), b = Buffer.from(expected);
if (a.length !== b.length || !timingSafeEqual(a, b)) return null;
try {
- const obj = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
- return { uid: obj.uid, role: obj.role, name: obj.name };
+ const obj = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')) as SessionPayload;
+ if (!obj.uid || typeof obj.iat !== 'number') return null;
+ if (Math.floor(Date.now() / 1000) - obj.iat > MAX_AGE) return null; // serverseitig abgelaufen
+ return { uid: obj.uid, role: obj.role, name: obj.name, ep: Number(obj.ep) || 0 };
} catch {
return null;
}
}
-export async function login(username: string, password: string): Promise {
- const u = await one<{ id: string; password_hash: string; role: 'user' | 'admin'; display_name: string }>(
- 'SELECT id, password_hash, role, display_name FROM users WHERE username=$1', [username]);
+/**
+ * Gegenprobe in der Datenbank: Gibt es das Konto noch, gilt der Sitzungsstand
+ * noch, und welche Rolle hat es **jetzt**?
+ *
+ * Die Rolle kommt bewusst aus der Datenbank und nicht aus dem Cookie — sonst
+ * behielte ein herabgestufter Admin seine Rechte bis zum Ablauf des Cookies.
+ * Ein Punkt-Lookup über den Primärschlüssel, den `tokenUser` ohnehin macht.
+ */
+export async function verifySession(s: (SessionUser & { ep: number }) | null): Promise {
+ if (!s?.uid) return null;
+ const u = await one<{ role: 'user' | 'admin'; display_name: string | null; username: string; session_epoch: number }>(
+ 'SELECT role, display_name, username, session_epoch FROM users WHERE id = $1', [s.uid]);
+ if (!u) return null; // Konto gelöscht
+ if ((u.session_epoch ?? 0) !== s.ep) return null; // Passwort gewechselt oder Rolle geändert
+ return { uid: s.uid, role: u.role, name: u.display_name || u.username };
+}
+
+/** Alle Sitzungen eines Kontos entwerten — nach Passwortwechsel oder Rollenänderung. */
+export async function bumpSessionEpoch(userId: string): Promise {
+ await query('UPDATE users SET session_epoch = session_epoch + 1 WHERE id = $1', [userId]);
+}
+
+export async function login(
+ username: string, password: string,
+): Promise<{ user: SessionUser; epoch: number } | null> {
+ const u = await one<{ id: string; password_hash: string; role: 'user' | 'admin'; display_name: string; username: string; session_epoch: number }>(
+ 'SELECT id, password_hash, role, display_name, username, session_epoch FROM users WHERE username=$1', [username]);
if (!u) return null;
if (!(await verifyPassword(u.password_hash, password))) return null;
- return { uid: u.id, role: u.role, name: u.display_name || username };
+ return {
+ user: { uid: u.id, role: u.role, name: u.display_name || u.username },
+ epoch: u.session_epoch ?? 0,
+ };
}
// --- einfacher In-Memory-Ratelimiter: max 5 Fehlversuche / 15 Min pro IP ---
diff --git a/src/lib/limit.ts b/src/lib/limit.ts
new file mode 100644
index 0000000..a8a1246
--- /dev/null
+++ b/src/lib/limit.ts
@@ -0,0 +1,72 @@
+// Ratenbegrenzung und die Frage, wem man die Absender-Adresse glaubt.
+//
+// Bewusst ohne Abhängigkeit und im Speicher: Klarbild läuft als **ein** Node-
+// Prozess. Käme je ein zweiter dazu, gehört das in die Datenbank oder nach
+// Redis — dann ist diese Datei die eine Stelle, die man austauscht.
+
+/**
+ * Die tatsächliche Adresse des Aufrufers.
+ *
+ * Wichtig und leicht zu übersehen: Astros `clientAddress` nimmt den **ersten**
+ * Wert aus `X-Forwarded-For`. Diesen Wert schickt der Angreifer selbst — ein
+ * Reverse Proxy hängt die echte Adresse hinten an. Jede Begrenzung, die auf
+ * `clientAddress` baut, lässt sich damit umgehen, indem man den Header pro
+ * Anfrage variiert.
+ *
+ * Deshalb wird von rechts gezählt: Bei `TRUSTED_PROXIES=1` (Standard, ein
+ * vorgelagerter Proxy wie Traefik) ist der letzte Eintrag der, den der Proxy
+ * selbst gesetzt hat — den kann niemand von außen fälschen. Ohne Proxy
+ * (`TRUSTED_PROXIES=0`) wird der Header gar nicht beachtet.
+ */
+export function echteIp(request: Request, fallback?: string | null): string {
+ const vertraut = Number(process.env.TRUSTED_PROXIES ?? '1');
+ if (!Number.isFinite(vertraut) || vertraut <= 0) return fallback || 'unbekannt';
+ const kette = (request.headers.get('x-forwarded-for') || '')
+ .split(',').map((s) => s.trim()).filter(Boolean);
+ if (!kette.length) return fallback || 'unbekannt';
+ // Von rechts so viele Sprünge zurück, wie es vertrauenswürdige Proxys gibt.
+ const i = Math.max(0, kette.length - vertraut);
+ return kette[i] || fallback || 'unbekannt';
+}
+
+interface Eintrag { n: number; bis: number }
+const eimer = new Map();
+let letzteReinigung = 0;
+
+/** Abgelaufene Einträge entfernen — gezielt, nicht alles auf einmal. */
+function aufraeumen(jetzt: number): void {
+ if (jetzt - letzteReinigung < 60_000) return;
+ letzteReinigung = jetzt;
+ for (const [k, e] of eimer) if (e.bis < jetzt) eimer.delete(k);
+ // Notbremse, falls sich trotzdem zu viel ansammelt: die ältesten zuerst.
+ if (eimer.size > 50_000) {
+ const nach = [...eimer.entries()].sort((a, b) => a[1].bis - b[1].bis);
+ for (const [k] of nach.slice(0, eimer.size - 25_000)) eimer.delete(k);
+ }
+}
+
+/**
+ * Einen Versuch zählen. Liefert `true`, wenn die Grenze überschritten ist.
+ *
+ * Anders als vorher wird hier **nicht** die ganze Tabelle geleert, wenn sie
+ * groß wird — damit hätte ein Angreifer die Zähler aller anderen mit
+ * zurückgesetzt.
+ */
+export function zuOft(schluessel: string, max: number, fensterMs: number): boolean {
+ const jetzt = Date.now();
+ aufraeumen(jetzt);
+ const e = eimer.get(schluessel);
+ if (!e || e.bis < jetzt) { eimer.set(schluessel, { n: 1, bis: jetzt + fensterMs }); return false; }
+ e.n++;
+ return e.n > max;
+}
+
+/** Zähler zurücksetzen — nach einem erfolgreichen Vorgang. */
+export const zuruecksetzen = (schluessel: string): void => { eimer.delete(schluessel); };
+
+/** Wie viele Versuche im laufenden Fenster noch frei sind. */
+export function restVersuche(schluessel: string, max: number): number {
+ const e = eimer.get(schluessel);
+ if (!e || e.bis < Date.now()) return max;
+ return Math.max(0, max - e.n);
+}
diff --git a/src/lib/mail.ts b/src/lib/mail.ts
index 1462480..4d8b213 100644
--- a/src/lib/mail.ts
+++ b/src/lib/mail.ts
@@ -43,7 +43,7 @@ export interface MailInput {
html?: string;
}
-const esc = (s: string) =>
+export const esc = (s: string) =>
s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"');
/**
@@ -126,6 +126,18 @@ export async function testMail(): Promise<{ ok: boolean; message: string }> {
}
}
-/** Grobe Plausibilitätsprüfung einer Adresse. */
-export const istMail = (v: string): boolean =>
- /^[^\s@]+@[^\s@.]+(\.[^\s@.]+)+$/.test(String(v || '').trim()) && String(v).length <= 254;
+/**
+ * Grobe Plausibilitätsprüfung einer Adresse.
+ *
+ * Steuerzeichen werden am **rohen** Wert geprüft, nicht am getrimmten: In
+ * JavaScript passt `$` ohne `m`-Flag auch vor einem abschließenden
+ * Zeilenumbruch, und `trim()` würde ihn ohnehin wegnehmen. Beides zusammen
+ * hätte `"a@b.de\n"` durchgelassen — und genau damit fängt jede
+ * Kopfzeilen-Einschleusung an.
+ */
+export const istMail = (v: string): boolean => {
+ const roh = String(v ?? '');
+ if (/[\r\n\t\0]/.test(roh)) return false;
+ const s = roh.trim();
+ return !!s && s.length <= 254 && /^[^\s@]+@[^\s@.]+(\.[^\s@.]+)+$/.test(s);
+};
diff --git a/src/lib/maintenance.ts b/src/lib/maintenance.ts
index 751f078..1c5ba20 100644
--- a/src/lib/maintenance.ts
+++ b/src/lib/maintenance.ts
@@ -110,6 +110,23 @@ export async function purgePrivate(olderThanMin = 60): Promise<{ deleted: number
return { deleted: rows.length };
}
+/**
+ * Aufzeichnungen und abgelaufene Rücksetz-Links wegräumen.
+ *
+ * Beide Tabellen wuchsen vorher unbegrenzt. Bei `share_views` ist das nicht nur
+ * eine Platzfrage: Es sind Aufzeichnungen darüber, wann jemand ein Bild
+ * angesehen hat. Sie länger aufzuheben, als man sie braucht, wäre schlicht
+ * unnötig — 90 Tage reichen für „wurde das eigentlich angeschaut?".
+ */
+export async function purgeTracking(tage = 90): Promise<{ views: number; resets: number }> {
+ const v = await query<{ id: string }>(
+ `DELETE FROM share_views WHERE at < now() - ($1 || ' days')::interval RETURNING id`, [String(tage)]);
+ const r = await query<{ token_hash: string }>(
+ `DELETE FROM password_resets
+ WHERE expires_at < now() - interval '7 days' RETURNING token_hash`);
+ return { views: v.length, resets: r.length };
+}
+
let timer: NodeJS.Timeout | null = null;
/** Wartungs-Lauf: Retention + private Sessions vergessen. */
export function startMaintenance(): void {
@@ -123,6 +140,8 @@ export function startMaintenance(): void {
}
const p = await purgePrivate(60);
if (p.deleted) console.log(`[maintenance] Privat: ${p.deleted} Ergebnisse vergessen.`);
+ const t = await purgeTracking(90);
+ if (t.views || t.resets) console.log(`[maintenance] Aufgeräumt: ${t.views} Zugriffe, ${t.resets} alte Rücksetz-Links.`);
} catch (e) { console.error('[maintenance]', e); }
};
timer = setInterval(tick, 30 * 60 * 1000); // alle 30 min (auch für private Purge)
diff --git a/src/lib/provenance.ts b/src/lib/provenance.ts
index 77f13be..96de339 100644
Binary files a/src/lib/provenance.ts and b/src/lib/provenance.ts differ
diff --git a/src/lib/shares.ts b/src/lib/shares.ts
index c868bb6..d3e5a5a 100644
--- a/src/lib/shares.ts
+++ b/src/lib/shares.ts
@@ -6,7 +6,7 @@
// 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 { randomBytes, createHash, createHmac, timingSafeEqual } from 'node:crypto';
import argon2 from 'argon2';
import { one, query } from './db.ts';
@@ -50,6 +50,36 @@ export interface ShareErstellen {
}
export const MAX_ITEMS = 500;
+export const MAX_TITEL = 200;
+export const MAX_NOTIZ = 2000;
+const MAX_TAGE = 3650;
+
+/**
+ * Tage bis zum Ablauf sauber lesen.
+ *
+ * `Number('abc')` ist `NaN`, und `NaN > 0` ist `false` — mit der naiven
+ * Schreibweise landete jede unsinnige Eingabe im Zweig „unbegrenzt". Genau die
+ * Voreinstellung, die diese Datei oben als falsch bezeichnet. Deshalb: alles,
+ * was keine sinnvolle Zahl ist, wird abgelehnt statt stillschweigend gedeutet.
+ */
+export function tageLesen(v: unknown, standard: number): number {
+ if (v === undefined || v === null || v === '') return standard;
+ // Nur Zahl oder Zeichenkette annehmen: `Number([])` ist 0 und `Number([7])`
+ // ist 7 — ein versehentlich mitgeschicktes Array würde sonst stillschweigend
+ // zu „unbegrenzt" oder zu einer erfundenen Frist.
+ if (typeof v !== 'number' && typeof v !== 'string')
+ throw new Error('Gültigkeit muss eine Zahl von Tagen sein.');
+ const n = Number(v);
+ if (!Number.isFinite(n)) throw new Error('Gültigkeit muss eine Zahl von Tagen sein.');
+ const t = Math.trunc(n);
+ if (t < 0 || t > MAX_TAGE) throw new Error(`Gültigkeit muss zwischen 0 und ${MAX_TAGE} Tagen liegen.`);
+ return t;
+}
+
+const kurz = (v: unknown, max: number): string | null => {
+ const t = String(v ?? '').trim();
+ return t ? t.slice(0, max) : null;
+};
/**
* Link anlegen. Ohne ausdrückliche Angabe gilt die Voreinstellung aus den
@@ -60,7 +90,7 @@ export async function createShare(input: ShareErstellen): Promise {
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 tage = tageLesen(input.days, s?.share_default_days ?? 30);
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;
@@ -68,12 +98,22 @@ export async function createShare(input: ShareErstellen): Promise {
const share = await one(
`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,
+ [neuerSlug(), kind, kurz(input.title, MAX_TITEL), kurz(input.note, MAX_NOTIZ),
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]);
+ // In **einem** Schritt einfügen: Vorher waren es bis zu 500 einzelne Abfragen,
+ // und wenn eine davon scheiterte (etwa weil ein Bild zwischenzeitlich gelöscht
+ // wurde), blieb ein halb gefüllter Link stehen, während die Oberfläche einen
+ // Fehler meldete.
+ try {
+ await query(
+ `INSERT INTO share_items (share_id, item_id, position)
+ SELECT $1, x.id, x.pos FROM unnest($2::uuid[]) WITH ORDINALITY AS x(id, pos)
+ ON CONFLICT DO NOTHING`, [share!.id, ids]);
+ } catch (e) {
+ // Kein halber Link: Was nicht vollständig entstehen konnte, wird entfernt.
+ await query('DELETE FROM shares WHERE id = $1', [share!.id]).catch(() => {});
+ throw e;
}
return share!;
}
@@ -103,20 +143,18 @@ export async function resolveShare(slug: string, passwort?: string | null): Prom
}
export interface ShareBild {
- id: string; filename: string | null; output_px: string | null;
- mode: string | null; created_at: string;
+ id: string; filename: string | null; output_px: string | null; created_at: string;
}
/** Die Bilder hinter einem Link, in der gespeicherten Reihenfolge. */
export async function shareItems(shareId: string): Promise {
return query(
- // `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
+ // Kein Join auf `jobs`: `items.job_id` ist laut Schema nullable, ein INNER
+ // JOIN könnte also Zeilen verschlucken — und gebraucht wurde davon ohnehin
+ // nichts.
+ `SELECT i.id, i.filename, i.output_px, 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]);
}
@@ -137,21 +175,53 @@ export async function trackShare(
shareId: string, kind: 'view' | 'download', ip?: string | null, ua?: string | null,
): Promise {
const salz = process.env.SESSION_SECRET || 'klarbild';
- const ipHash = ip ? createHash('sha256').update(salz + '|' + ip).digest('base64url').slice(0, 22) : null;
+ // Der Hash bindet die Kennung des Links mit ein: Damit lässt sich derselbe
+ // Besucher **innerhalb** eines Links wiedererkennen, aber nicht über mehrere
+ // Links hinweg verfolgen. (Ehrlich gesagt: Das ist Pseudonymisierung, keine
+ // Anonymisierung — wer den Server-Schlüssel hat, kann den IPv4-Raum
+ // durchrechnen. Deshalb wird auch nur 90 Tage aufgehoben.)
+ const ipHash = ip
+ ? createHash('sha256').update(`${salz}|${shareId}|${ip}`).digest('base64url').slice(0, 22)
+ : null;
+ const stunde = new Date().toISOString().slice(0, 13); // „2026-09-01T14"
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]);
+ // Höchstens eine Zeile je Besucher und Stunde — sonst füllt ein einzelner
+ // Empfänger (oder ein -Tag auf einer fremden Seite) die Tabelle.
+ const neu = await query<{ id: string }>(
+ `INSERT INTO share_views (share_id, kind, ip_hash, user_agent, stunde)
+ VALUES ($1,$2,$3,$4,$5) ON CONFLICT DO NOTHING RETURNING id`,
+ [shareId, kind, ipHash, geraet(ua), stunde]);
+
+ // Der Zähler zählt jeden Aufruf, die Besucherzahl nur neue Kennungen.
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]);
+ views = views + CASE WHEN $2='view' THEN 1 ELSE 0 END,
+ downloads = downloads + CASE WHEN $2='download' THEN 1 ELSE 0 END,
+ besucher = besucher + CASE WHEN $3 AND $2='view' THEN 1 ELSE 0 END
+ WHERE id = $1`, [shareId, kind, neu.length > 0]);
} catch (e: any) {
// Zählen ist Beiwerk — es darf die Auslieferung nie verhindern.
console.error('[share] Zugriff nicht gezählt:', e?.message || e);
}
}
+/**
+ * Aus dem User-Agent nur Browser und System behalten.
+ * Die vollständige Zeichenkette ist ein brauchbarer Fingerabdruck; für „womit
+ * wurde geöffnet" reicht „Safari · iPhone" völlig.
+ */
+export function geraet(ua?: string | null): string | null {
+ const s = String(ua || '');
+ if (!s) return null;
+ const browser = /Edg\//.test(s) ? 'Edge' : /OPR\//.test(s) ? 'Opera'
+ : /Chrome\//.test(s) ? 'Chrome' : /Firefox\//.test(s) ? 'Firefox'
+ : /Safari\//.test(s) ? 'Safari' : 'Browser';
+ const system = /iPhone/.test(s) ? 'iPhone' : /iPad/.test(s) ? 'iPad'
+ : /Android/.test(s) ? 'Android' : /Macintosh|Mac OS/.test(s) ? 'Mac'
+ : /Windows/.test(s) ? 'Windows' : /Linux/.test(s) ? 'Linux' : '';
+ return system ? `${browser} · ${system}` : browser;
+}
+
export interface ShareStat extends Share {
anzahl: number; besucher: number;
}
@@ -159,9 +229,14 @@ export interface ShareStat extends Share {
/** Übersicht für den Eigentümer (Admins sehen alle). */
export async function listShares(userId: string | null, istAdmin: boolean): Promise {
return query(
+ // `anzahl` zählt dieselben Bedingungen wie `shareItems` — sonst nennt die
+ // Verwaltung eine andere Zahl als die Seite, die der Empfänger sieht.
+ // `besucher` kommt aus der gepflegten Spalte statt aus einer Aggregation
+ // über share_views: Bei vielen Zugriffen wurde diese Seite sonst zäh.
`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
+ (SELECT count(*)::int FROM share_items si JOIN items i ON i.id = si.item_id
+ WHERE si.share_id = s.id AND i.status='done' AND i.result_path IS NOT NULL) AS anzahl,
+ s.besucher
FROM shares s
WHERE $2::boolean OR s.created_by = $1
ORDER BY s.created_at DESC LIMIT 200`, [userId, istAdmin]);
@@ -200,11 +275,11 @@ export async function updateShare(
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.title !== undefined) set('title', kurz(a.title, MAX_TITEL));
+ if (a.note !== undefined) set('note', kurz(a.note, MAX_NOTIZ));
if (a.allowDownload !== undefined) set('allow_download', !!a.allowDownload);
if (a.days !== undefined) {
- const t = Number(a.days) || 0;
+ const t = tageLesen(a.days, 0);
set('expires_at', t > 0 ? new Date(Date.now() + t * 86400_000) : null);
}
if (a.password !== undefined) {
@@ -221,9 +296,45 @@ export async function deleteShare(id: string, userId: string | null, istAdmin: b
return r.length > 0;
}
-/** Zeitkonstanter Vergleich — für Stellen, an denen ein Zeitunterschied verriete, wie weit man richtig lag. */
+/** Zeitkonstanter Vergleich — verrät nicht über die Dauer, 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);
}
+
+/* ---------------- Nachweis für ein geprüftes Zugangswort ----------------
+ * `argon2.verify` kostet rund 130 ms und 64 MiB — und lief vorher bei **jeder**
+ * Anfrage, also auch für jedes einzelne Bild einer Galerie. Eine Seite mit 40
+ * Bildern hätte den Prozess in die Speichergrenze getrieben, und ein paar
+ * Dutzend Anfragen mit falschem Wort hätten die ganze Anwendung lahmgelegt.
+ *
+ * Deshalb: einmal prüfen, danach einen kurzlebigen, signierten Nachweis
+ * mitführen, der **nur für diesen einen Link** gilt. */
+
+const NACHWEIS_DAUER = 6 * 60 * 60; // 6 Stunden
+
+export const nachweisName = (slug: string) => `kb_s_${slug}`;
+
+export function nachweisBauen(shareId: string): string {
+ const bis = Math.floor(Date.now() / 1000) + NACHWEIS_DAUER;
+ const sig = createHmac('sha256', process.env.SESSION_SECRET || 'klarbild')
+ .update(`${shareId}.${bis}`).digest('base64url');
+ return `${bis}.${sig}`;
+}
+
+export function nachweisGilt(shareId: string, wert?: string | null): boolean {
+ if (!wert) return false;
+ const [bisRoh, sig] = String(wert).split('.');
+ const bis = Number(bisRoh);
+ if (!Number.isFinite(bis) || bis < Math.floor(Date.now() / 1000)) return false;
+ const soll = createHmac('sha256', process.env.SESSION_SECRET || 'klarbild')
+ .update(`${shareId}.${bis}`).digest('base64url');
+ return !!sig && gleich(sig, soll);
+}
+
+/** Nachweis aus dem Cookie-Kopf ziehen. */
+export function nachweisAusCookie(cookieHeader: string | null | undefined, slug: string): string | null {
+ const name = nachweisName(slug);
+ return cookieHeader?.split(/;\s*/).find((c) => c.startsWith(`${name}=`))?.slice(name.length + 1) || null;
+}
diff --git a/src/middleware.ts b/src/middleware.ts
index f2ceaf9..fbe84c4 100644
--- a/src/middleware.ts
+++ b/src/middleware.ts
@@ -1,6 +1,6 @@
import { defineMiddleware } from 'astro:middleware';
import { ensureInit } from './lib/init';
-import { readSession } from './lib/auth';
+import { readSession, verifySession } from './lib/auth';
import { one } from './lib/db';
const PUBLIC_PATHS = [
@@ -27,7 +27,10 @@ export const onRequest = defineMiddleware(async (ctx, next) => {
// Health/Webhook dürfen laufen, auch wenn Init noch hakt — sonst blockiert nichts.
try { await ensureInit(); } catch (e) { if (ctx.url.pathname !== '/api/health') throw e; }
- let user = readSession(ctx.request.headers.get('cookie'));
+ // Signatur und Alter prüft `readSession`; ob das Konto noch existiert, die
+ // Rolle noch stimmt und der Sitzungsstand noch gilt, entscheidet erst die
+ // Datenbank — sonst überlebte ein Cookie jeden Passwortwechsel.
+ let user = await verifySession(readSession(ctx.request.headers.get('cookie')));
// Programmatischer Zugriff per API-Token (nur für /api/*, außer Admin-Bereich).
if (!user && ctx.url.pathname.startsWith('/api/') && !ctx.url.pathname.startsWith('/api/admin')) {
user = await tokenUser(ctx.request.headers.get('authorization'));
diff --git a/src/pages/api/admin/users.ts b/src/pages/api/admin/users.ts
index ecb3b9f..ccf3742 100644
--- a/src/pages/api/admin/users.ts
+++ b/src/pages/api/admin/users.ts
@@ -1,6 +1,6 @@
import type { APIRoute } from 'astro';
import { one, query } from '../../../lib/db';
-import { hashPassword } from '../../../lib/auth';
+import { hashPassword, bumpSessionEpoch } from '../../../lib/auth';
export const prerender = false;
const json = (b: unknown, s = 200) =>
@@ -16,6 +16,8 @@ export const POST: APIRoute = async ({ request }) => {
const b = await request.json();
if (b.id && b.password) {
await query('UPDATE users SET password_hash=$2 WHERE id=$1', [b.id, await hashPassword(b.password)]);
+ // Neues Passwort heißt: alte Sitzungen gelten nicht mehr.
+ await bumpSessionEpoch(b.id);
return json({ ok: true });
}
if (b.id && 'private_forced' in b) {
@@ -24,6 +26,9 @@ export const POST: APIRoute = async ({ request }) => {
}
if (b.id && b.role) {
await query(`UPDATE users SET role=$2 WHERE id=$1`, [b.id, b.role === 'admin' ? 'admin' : 'user']);
+ // Rolle geändert → Sitzung entwerten, sonst behielte ein herabgestufter
+ // Admin seine Rechte bis zum Ablauf des Cookies.
+ await bumpSessionEpoch(b.id);
return json({ ok: true });
}
if (!b.username || !b.password) return json({ error: 'username + password nötig.' }, 400);
diff --git a/src/pages/api/auth/login.ts b/src/pages/api/auth/login.ts
index 2ea72d2..8de0c76 100644
--- a/src/pages/api/auth/login.ts
+++ b/src/pages/api/auth/login.ts
@@ -1,10 +1,14 @@
import type { APIRoute } from 'astro';
import { login, makeSessionCookie, rateLimited, noteFailure, clearFailures } from '../../../lib/auth';
+import { echteIp, zuOft } from '../../../lib/limit';
export const prerender = false;
export const POST: APIRoute = async ({ request, clientAddress }) => {
- const ip = clientAddress || 'unknown';
+ // Nicht `clientAddress` direkt: Das ist der **erste** Wert aus
+ // X-Forwarded-For und damit vom Aufrufer selbst gesetzt — die Sperre nach
+ // fünf Fehlversuchen ließe sich sonst mit einem wechselnden Header umgehen.
+ const ip = echteIp(request, clientAddress);
if (rateLimited(ip)) {
return new Response(JSON.stringify({ error: 'Zu viele Versuche. Bitte in 15 Minuten erneut.' }),
{ status: 429, headers: { 'Content-Type': 'application/json' } });
@@ -19,15 +23,22 @@ export const POST: APIRoute = async ({ request, clientAddress }) => {
username = String(f.get('username') || ''); password = String(f.get('password') || '');
}
- const user = await login(username.trim(), password);
- if (!user) {
+ // Zusätzlich je Benutzername zählen — sonst hilft die IP-Sperre nichts, wenn
+ // der Angreifer aus vielen Netzen kommt.
+ if (zuOft('login-user:' + username.trim().toLowerCase(), 10, 15 * 60_000)) {
+ return new Response(JSON.stringify({ error: 'Zu viele Versuche. Bitte in 15 Minuten erneut.' }),
+ { status: 429, headers: { 'Content-Type': 'application/json' } });
+ }
+
+ const angemeldet = await login(username.trim(), password);
+ if (!angemeldet) {
noteFailure(ip);
return new Response(JSON.stringify({ error: 'Benutzername oder Passwort falsch.' }),
{ status: 401, headers: { 'Content-Type': 'application/json' } });
}
clearFailures(ip);
- return new Response(JSON.stringify({ ok: true, role: user.role }), {
+ return new Response(JSON.stringify({ ok: true, role: angemeldet.user.role }), {
status: 200,
- headers: { 'Content-Type': 'application/json', 'Set-Cookie': makeSessionCookie(user) },
+ headers: { 'Content-Type': 'application/json', 'Set-Cookie': makeSessionCookie(angemeldet.user, angemeldet.epoch) },
});
};
diff --git a/src/pages/api/auth/reset.ts b/src/pages/api/auth/reset.ts
index 13163f0..832933b 100644
--- a/src/pages/api/auth/reset.ts
+++ b/src/pages/api/auth/reset.ts
@@ -1,8 +1,9 @@
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';
+import { hashPassword, bumpSessionEpoch } from '../../../lib/auth';
+import { sendMail, mailLayout, mailReady, istMail, esc } from '../../../lib/mail';
+import { zuOft, echteIp } from '../../../lib/limit';
export const prerender = false;
const json = (b: unknown, s = 200) =>
@@ -12,17 +13,6 @@ 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();
-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 }`).
@@ -31,6 +21,7 @@ function zuOft(schluessel: string, max = 5, fensterMs = 15 * 60_000): boolean {
* Adresse gibt. Sonst ließe sich hier ablesen, wer ein Konto hat.
*/
export const POST: APIRoute = async ({ request, clientAddress }) => {
+ const ip = echteIp(request, clientAddress);
const b = await request.json().catch(() => ({}));
// --- Neues Passwort setzen -------------------------------------------------
@@ -38,7 +29,7 @@ export const POST: APIRoute = async ({ request, clientAddress }) => {
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);
+ if (zuOft('reset-einloesen:' + ip, 20, 15 * 60_000)) return json({ error: 'Zu viele Versuche. Bitte später erneut.' }, 429);
const r = await one<{ user_id: string }>(
`SELECT user_id FROM password_resets
@@ -48,13 +39,22 @@ export const POST: APIRoute = async ({ request, clientAddress }) => {
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]);
+ // Entscheidend: **alle bestehenden Sitzungen entwerten**. Wer das Passwort
+ // zurücksetzt, tut das meist, weil jemand anderes hineingekommen ist — ohne
+ // diesen Schritt bliebe dessen Cookie bis zu 30 Tage gültig und der ganze
+ // Vorgang wäre wirkungslos.
+ await bumpSessionEpoch(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
+ // Zwei Grenzen: je Absender **und** je Zieladresse. Ohne die zweite ließe sich
+ // ein bestimmtes Postfach von wechselnden Adressen aus zumüllen — auf Kosten
+ // des Zustellrufs der eigenen Domain.
+ if (zuOft('reset-ip:' + ip, 5, 15 * 60_000)) return json({ ok: true });
+ if (zuOft('reset-mail:' + email, 3, 60 * 60_000)) return json({ ok: true });
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 }>(
@@ -69,14 +69,17 @@ export const POST: APIRoute = async ({ request, clientAddress }) => {
const url = `${basis()}/passwort?token=${token}`;
const name = u.display_name || u.username;
- await sendMail({
+ // Nicht abwarten: Ein SMTP-Versand dauert Hunderte Millisekunden. Wartet die
+ // Antwort darauf, verrät allein die Dauer, ob es das Konto gibt — und die
+ // gleichlautende Antwort unten wäre wertlos.
+ void 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},`,
+ `Hallo ${esc(name)},`,
`über den Knopf unten setzt du dir ein neues Passwort für Klarbild. Der Link gilt ${GUELTIG_MINUTEN} Minuten und lässt sich nur einmal verwenden.`,
`Wenn du das nicht angefordert hast, kannst du diese Nachricht ignorieren — es ändert sich dann nichts.`,
], { text: 'Neues Passwort setzen', url }),
diff --git a/src/pages/api/me.ts b/src/pages/api/me.ts
index 78cdd9d..561822c 100644
--- a/src/pages/api/me.ts
+++ b/src/pages/api/me.ts
@@ -39,7 +39,15 @@ export const PATCH: APIRoute = async ({ request, locals }) => {
'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]);
+ try {
+ await query('UPDATE users SET email=$2 WHERE id=$1', [locals.user.uid, roh || null]);
+ } catch (e: any) {
+ // Zwei gleichzeitige Anfragen können an der Prüfung oben vorbeikommen —
+ // der eindeutige Index fängt es ab. Dann dieselbe verständliche Auskunft
+ // wie oben statt einer HTML-Fehlerseite.
+ if (e?.code === '23505') return json({ error: 'Diese Adresse gehört schon zu einem anderen Konto.' }, 409);
+ throw e;
+ }
}
if ('notify_jobs' in b) {
diff --git a/src/pages/api/s/[slug]/file.ts b/src/pages/api/s/[slug]/file.ts
index 7152622..8f62160 100644
--- a/src/pages/api/s/[slug]/file.ts
+++ b/src/pages/api/s/[slug]/file.ts
@@ -2,7 +2,7 @@ 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';
+import { resolveShare, shareHasItem, trackShare, nachweisGilt, nachweisAusCookie } from '../../../../lib/shares';
export const prerender = false;
@@ -15,7 +15,15 @@ export const prerender = false;
*/
export const GET: APIRoute = async ({ params, url, request, clientAddress }) => {
const q = url.searchParams;
- const { status, share } = await resolveShare(params.slug!, q.get('pw'));
+ // Kein `?pw=` mehr: Bei geschützten Links zählt allein der Nachweis, den die
+ // Seite nach der einmaligen Prüfung als Cookie gesetzt hat. Sonst liefe für
+ // jedes einzelne Bild erneut argon2 (130 ms, 64 MiB) — bei einer Galerie mit
+ // 40 Bildern reicht das, um den Prozess in die Speichergrenze zu treiben.
+ let { status, share } = await resolveShare(params.slug!, null);
+ if (status === 'passwort' && share
+ && nachweisGilt(share.id, nachweisAusCookie(request.headers.get('cookie'), params.slug!))) {
+ status = 'ok';
+ }
if (status !== 'ok' || !share) {
return new Response(
status === 'abgelaufen' ? 'Dieser Link ist abgelaufen.'
@@ -50,13 +58,23 @@ export const GET: APIRoute = async ({ params, url, request, clientAddress }) =>
// 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) {
+ //
+ // Wichtig: Das galt vorher **nicht** für freigestellte Motive — die liefen
+ // ungefiltert durch, in voller Auflösung, mit Rechtsklick speicherbar. Damit
+ // war „Herunterladen aus" genau bei Stickern und Freistellern wirkungslos.
+ // Alphabilder werden deshalb als PNG verkleinert statt übersprungen.
+ if (!download && !wantThumb) {
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 */ }
+ const klein = sharp(buf, { failOn: 'none', limitInputPixels: 400_000_000 })
+ .resize({ width: 2000, height: 2000, fit: 'inside', withoutEnlargement: true });
+ if (item.has_alpha) { buf = await klein.png({ compressionLevel: 8 }).toBuffer(); contentType = 'image/png'; }
+ else { buf = await klein.jpeg({ quality: 88, mozjpeg: true }).toBuffer(); contentType = 'image/jpeg'; }
+ } catch (e: any) {
+ // Nur bei gesperrtem Download ist das Original tabu — dann lieber nichts
+ // ausliefern als versehentlich die volle Auflösung.
+ console.error('[share] Ansichtsfassung fehlgeschlagen:', e?.message || e);
+ if (!share.allow_download) return new Response('Vorschau nicht verfügbar.', { status: 503 });
+ }
}
if (download) {
@@ -75,7 +93,10 @@ export const GET: APIRoute = async ({ params, url, request, clientAddress }) =>
headers['Content-Disposition'] = `attachment; filename="${name}"`;
}
return new Response(new Uint8Array(buf), { headers });
- } catch {
+ } catch (e: any) {
+ // Ursache ins Log — sonst sucht der Betreiber bei einem Speicherausfall
+ // vergeblich, während alle Empfänger „Bild nicht mehr vorhanden" sehen.
+ console.error('[share] Auslieferung fehlgeschlagen', path, e?.message || e);
return new Response('Bild nicht mehr vorhanden.', { status: 404 });
}
};
diff --git a/src/pages/api/shares/index.ts b/src/pages/api/shares/index.ts
index d1e72bc..877ed23 100644
--- a/src/pages/api/shares/index.ts
+++ b/src/pages/api/shares/index.ts
@@ -1,7 +1,9 @@
import type { APIRoute } from 'astro';
-import { one } from '../../../lib/db';
+import { query } from '../../../lib/db';
import { createShare, listShares, MAX_ITEMS } from '../../../lib/shares';
+const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
+
export const prerender = false;
const json = (b: unknown, s = 200) =>
new Response(JSON.stringify(b), { status: s, headers: { 'Content-Type': 'application/json' } });
@@ -25,19 +27,28 @@ export const POST: APIRoute = async ({ request, locals }) => {
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);
+ // Kennungen zuerst prüfen — eine ungültige würde Postgres sonst mit
+ // „invalid input syntax for type uuid" abbrechen lassen und der Aufrufer
+ // bekäme einen 500 statt einer klaren Auskunft.
+ if (ids.some((id) => !UUID.test(id))) return json({ error: 'Ungültige Bild-Kennung.' }, 400);
+
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(
- `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);
+ // Eine Abfrage statt einer je Bild: bei 500 Bildern waren das 500 Rundreisen.
+ const gefunden = await query<{ id: string; status: string; result_path: string | null; created_by: string | null; private: boolean; library_visibility: string }>(
+ `SELECT i.id, i.status, i.result_path, j.created_by, j.private,
+ (SELECT library_visibility FROM settings WHERE id=1) AS library_visibility
+ FROM items i JOIN jobs j ON j.id = i.job_id
+ WHERE i.id = ANY($1::uuid[])`, [ids]);
+ if (gefunden.length !== ids.length) return json({ error: 'Ein Bild ist nicht (mehr) verfügbar.' }, 404);
+
+ for (const it of gefunden) {
+ if (!it.result_path || it.status !== 'done') return json({ error: 'Ein Bild ist nicht (mehr) verfügbar.' }, 404);
// 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);
+ const eigen = it.created_by === locals.user.uid;
+ if (!istAdmin && !eigen && it.library_visibility !== 'shared')
+ return json({ error: 'Kein Zugriff auf eines der Bilder.' }, 403);
}
try {
@@ -50,6 +61,10 @@ export const POST: APIRoute = async ({ request, locals }) => {
});
return json({ share });
} catch (e: any) {
- return json({ error: e?.message || 'Link konnte nicht angelegt werden.' }, 400);
+ // Eigene Meldungen (Gültigkeit, leere Auswahl) durchreichen, Datenbank-
+ // Interna nicht — die verraten Tabellen- und Spaltennamen.
+ const eigen = /Gültigkeit|ausgewählt|Bilder je Link/.test(String(e?.message || ''));
+ if (!eigen) console.error('[shares] Anlegen fehlgeschlagen:', e?.message || e);
+ return json({ error: eigen ? e.message : 'Link konnte nicht angelegt werden.' }, 400);
}
};
diff --git a/src/pages/api/shares/send.ts b/src/pages/api/shares/send.ts
index 27504cd..3fd682e 100644
--- a/src/pages/api/shares/send.ts
+++ b/src/pages/api/shares/send.ts
@@ -1,6 +1,7 @@
import type { APIRoute } from 'astro';
import { one } from '../../../lib/db';
-import { sendMail, mailLayout, mailReady, istMail } from '../../../lib/mail';
+import { sendMail, mailLayout, mailReady, istMail, esc } from '../../../lib/mail';
+import { zuOft } from '../../../lib/limit';
export const prerender = false;
const json = (b: unknown, s = 200) =>
@@ -8,6 +9,7 @@ const json = (b: unknown, s = 200) =>
const basis = () => (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
const MAX_EMPFAENGER = 10;
+const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
/**
* Einen Teilen-Link per E-Mail verschicken.
@@ -28,6 +30,16 @@ export const POST: APIRoute = async ({ request, locals }) => {
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);
+ // Ohne absolute Basisadresse stünde in der Mail ein relativer Pfad, den kein
+ // Postfach anklickbar macht — dann lieber gar nicht verschicken.
+ if (!/^https?:\/\//.test(basis()))
+ return json({ error: 'Die Basisadresse des Servers fehlt (PUBLIC_BASE_URL) — der Link in der Mail wäre unbrauchbar.' }, 503);
+
+ // Je Absender begrenzen, nicht je IP: Wer angemeldet ist, ist identifiziert.
+ if (zuOft('mailversand:' + locals.user.uid, 50, 60 * 60_000))
+ return json({ error: 'Für diese Stunde ist genug verschickt. Bitte später weitermachen.' }, 429);
+
+ if (!UUID.test(String(b.shareId || ''))) return json({ error: 'Unbekannter Link.' }, 404);
const istAdmin = locals.user.role === 'admin';
const share = await one(
@@ -36,6 +48,10 @@ export const POST: APIRoute = async ({ request, locals }) => {
[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);
+ // Auch der Ablauf zählt: Sonst bekommt die Oma eine Mail auf eine Seite, die
+ // ihr „Dieser Link ist abgelaufen" zeigt.
+ if (share.expires_at && new Date(share.expires_at).getTime() < Date.now())
+ return json({ error: 'Dieser Link ist abgelaufen — erst die Gültigkeit verlängern.' }, 409);
const url = `${basis()}/s/${share.slug}`;
const absender = locals.user.name || locals.user.username || 'Jemand';
@@ -48,9 +64,14 @@ export const POST: APIRoute = async ({ request, locals }) => {
? 'Zum Öffnen brauchst du außerdem das Zugangswort — das bekommst du getrennt.'
: '';
+ // `mailLayout` setzt Absätze bewusst roh ins HTML, damit funktioniert —
+ // alles, was aus Nutzereingaben stammt, muss deshalb **hier** maskiert werden.
+ // Vorher war nur die Nachricht maskiert, der Titel nicht: Damit ließ sich über
+ // den eigenen, per SPF und DKIM signierten Mailserver ein fremder Link in
+ // vertrautem Gewand verschicken.
const absaetze = [
- `${absender} hat dir etwas über Klarbild geschickt: ${titel}.`,
- ...(nachricht ? [nachricht.replace(/&/g, '&').replace(/')] : []),
+ `${esc(absender)} hat dir etwas über Klarbild geschickt: ${esc(titel)}.`,
+ ...(nachricht ? [esc(nachricht).replace(/\n/g, ' ')] : []),
[gueltig, wort].filter(Boolean).join(' '),
];
diff --git a/src/pages/s/[slug].astro b/src/pages/s/[slug].astro
index 54c61d3..257008d 100644
--- a/src/pages/s/[slug].astro
+++ b/src/pages/s/[slug].astro
@@ -5,11 +5,42 @@
// 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';
+import { resolveShare, shareItems, trackShare, nachweisBauen, nachweisGilt, nachweisAusCookie, nachweisName } from '../../lib/shares';
+import { zuOft, echteIp } from '../../lib/limit';
const slug = Astro.params.slug!;
-const pw = Astro.url.searchParams.get('pw');
-const { status, share } = await resolveShare(slug, pw);
+
+// Zugangswort kommt per POST (nicht als `?pw=` in der Adresse — das landete
+// sonst im Verlauf, in Proxy-Protokollen und in jedem weitergeleiteten Link).
+// Nach erfolgreicher Prüfung merkt sich ein kurzlebiges, auf diesen Link
+// beschränktes Cookie das Ergebnis: `argon2.verify` kostet 130 ms und 64 MiB
+// und lief sonst bei **jedem** Bildaufruf erneut.
+let eingabe: string | null = null;
+let falsch = false;
+let gesperrt = false;
+if (Astro.request.method === 'POST') {
+ // Durchprobieren begrenzen: Ein Zugangswort, das man am Telefon durchgibt,
+ // ist kurz — ohne Bremse wäre Raten aussichtsreich, und jede Prüfung kostet
+ // 130 ms und 64 MiB.
+ if (zuOft(`wort:${slug}:${echteIp(Astro.request, Astro.clientAddress)}`, 10, 15 * 60_000)) {
+ gesperrt = true;
+ } else {
+ const f = await Astro.request.formData().catch(() => null);
+ eingabe = String(f?.get('pw') || '') || null;
+ }
+}
+const cookieKopf = Astro.request.headers.get('cookie');
+let { status, share } = await resolveShare(slug, eingabe);
+
+// Bereits nachgewiesen? Dann ohne erneutes Hashen durchlassen.
+if (status === 'passwort' && share && nachweisGilt(share.id, nachweisAusCookie(cookieKopf, slug))) {
+ status = 'ok';
+}
+if (status === 'ok' && share?.password_hash && eingabe) {
+ Astro.response.headers.append('Set-Cookie',
+ `${nachweisName(slug)}=${nachweisBauen(share.id)}; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=21600`);
+}
+if (status === 'passwort' && eingabe) falsch = true;
let bilder: Awaited> = [];
if (status === 'ok' && share) {
@@ -22,8 +53,9 @@ Astro.response.headers.set('X-Robots-Tag', 'noindex, nofollow, noarchive, noimag
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 dl = (id: string) => `/api/s/${slug}/file?item=${id}&download=1`;
+const gross = (id: string) => `/api/s/${slug}/file?item=${id}`;
+const klein = (id: string) => `/api/s/${slug}/file?item=${id}&thumb=1`;
const meldung: Record = {
'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.' },
@@ -50,7 +82,11 @@ const meldung: Record = {
{bilder.map((b) => (
-
+ {/* In der Übersicht das Vorschaubild — sonst rechnet der Server
+ für jede Kachel ein Vollbild neu. Das große Bild kommt erst
+ beim Antippen. */}
+ 1 ? klein(b.id) : gross(b.id)}
+ alt={b.filename || 'Bild'} loading="lazy" />
{b.output_px?.replace('x', ' × ') || ''}
@@ -67,16 +103,23 @@ const meldung: Record = {
Am Handy: Bild antippen, dann lange darauf drücken und „Zu Fotos hinzufügen" wählen.
{share.allow_download && <> Über Herunterladen kommt die Datei in voller Auflösung.>}
+
+ Wer dir diesen Link geschickt hat, sieht, wann und mit welcher Art Gerät er geöffnet
+ wurde — nicht, wer das war. Deine Adresse wird nur verschlüsselt abgelegt und nach
+ 90 Tagen gelöscht.
+
>
) : status === 'passwort' ? (
Dieser Link ist mit einem Wort geschützt
Gib das Zugangswort ein, das du zusammen mit dem Link bekommen hast.
-
- {pw &&
Das war leider nicht richtig.
}
+ {gesperrt
+ ?
Zu viele Versuche. Bitte in einer Viertelstunde noch einmal.
+ : falsch &&
Das war leider nicht richtig.
}
) : (
@@ -105,6 +148,7 @@ const meldung: Record = {
.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; }
+ .datenschutz { margin: 10px 0 0; font-size: 12px; color: var(--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; }
diff --git a/tests/mail.test.ts b/tests/mail.test.ts
new file mode 100644
index 0000000..9d92bbe
--- /dev/null
+++ b/tests/mail.test.ts
@@ -0,0 +1,66 @@
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { istMail, mailLayout, esc } from '../src/lib/mail.ts';
+
+test('Gültige Adressen werden erkannt', () => {
+ for (const gut of ['mail@tillheidrich.de', 'a.b+c@sub.beispiel.co.uk', 'x@y.de', 'Oma_1949@web.de']) {
+ assert.ok(istMail(gut), `fälschlich abgelehnt: ${gut}`);
+ }
+});
+
+test('Ungültige Adressen werden abgelehnt — auch die mit angehängtem Zeilenumbruch', () => {
+ const schlecht = [
+ '', ' ', 'keine-adresse', 'a@b', '@b.de', 'a@.de', 'a b@c.de', 'a@b .de',
+ 'a@b.de\nBcc: opfer@example.com', // Kopfzeilen-Einschleusung
+ 'a@b.de\r\nSubject: x',
+ 'a@b.de\n', // JS-„$" passt auch vor dem Zeilenende
+ 'a'.repeat(250) + '@b.de', // zu lang
+ ];
+ for (const s of schlecht) assert.ok(!istMail(s), `durchgerutscht: ${JSON.stringify(s)}`);
+});
+
+test('esc entschärft alles, was HTML zerlegen könnte', () => {
+ assert.equal(esc('x'), '<b>x</b>');
+ assert.equal(esc('a & b'), 'a & b');
+ assert.equal(esc('sagte "hallo"'), 'sagte "hallo"');
+ // Reihenfolge: & zuerst, sonst würde < zu <
+ assert.equal(esc('<&>'), '<&>');
+});
+
+test('Die Überschrift der Mail wird immer maskiert', () => {
+ const html = mailLayout('', ['Text']);
+ assert.ok(!html.includes('' });
+ assert.ok(!html.includes('