fix: findings from the code review of the mail and sharing work

Two reviews, both dimensions. The serious ones, verified before fixing:

Sessions never expired server-side and survived a password reset. `Max-Age` is
a request to the browser, not a rule for the server, and `readSession` only
checked the signature -- a leaked cookie stayed valid indefinitely, and the very
reset meant to rescue a compromised account left the intruder logged in. A
demoted admin also kept admin, because the role came from the cookie. Now the
cookie carries a session epoch, `verifySession` checks the account against the
database on every request and takes the role from there, and the epoch is
bumped on password reset and on any role change. This was pre-existing, but the
reset feature promised safety it did not deliver.

"Allow download: off" did nothing for images with transparency -- exactly the
cutouts and stickers this tool exists for. The view path skipped resizing when
`has_alpha`, so the untouched original went out at full resolution, right-click
saveable. Verified: now 1500x2000 instead of 2400x3200, and the download route
returns 403.

The share title went into outgoing mail HTML unescaped while the message was
escaped -- a foreign link could be posted through the account's own
SPF/DKIM-signed mailserver. Both escaped now, plus length limits.

argon2 ran on every request for passphrase-protected links, including every
image: 130 ms and 64 MiB each. A 40-image gallery would have walked into the
memory limit, and a few dozen wrong guesses could have stalled the process.
Checked once, then a short-lived signed cookie scoped to that one link. The
passphrase moved from the query string to POST (it was landing in history and
proxy logs) and wrong guesses are now rate limited.

`days` accepted nonsense and quietly produced an unlimited link -- `Number('abc')`
is NaN and `NaN > 0` is false. `[]` coerced to 0, same result. Now rejected
outright, which matters when the pictures are of children.

Rate limits keyed on `clientAddress`, which is the *first* X-Forwarded-For
value -- set by the caller. Counting from the right instead, with the number of
trusted proxies configurable (TRUSTED_PROXIES, default 1), plus per-username and
per-recipient counters, a real limit on outgoing mail, and a limiter that
expires entries individually instead of clearing everyone's counter.

Also: expired links could still be mailed; missing PUBLIC_BASE_URL sent dead
links while reporting success; the reset endpoint's response time revealed
whether an account existed (send is no longer awaited); invalid UUIDs produced
500s; the email uniqueness race returned an HTML error page; share_views grew
without bound and is now one row per visitor per hour, hashed per link, user
agent reduced to browser and system, deleted after 90 days; double provenance
marking appended a second XMP packet with the older model instead of replacing.

21 new tests (97 total) for shares, mail and provenance -- all three were
completely untested, and that is where both reviews found the most.

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:25:02 +00:00
1 parent 63d4b7d894
commit 1d78535662
22 files changed
+752 -108

No files matched your search

+6 -1
View File
@@ -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);
+16 -5
View File
@@ -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) },
});
};
+20 -17
View File
@@ -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<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 }`).
@@ -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 <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 }),
+9 -1
View File
@@ -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) {
+30 -9
View File
@@ -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 });
}
};
+26 -11
View File
@@ -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<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);
// 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);
}
};
+24 -3
View File
@@ -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<any>(
@@ -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 <b> 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: <b>${titel}</b>.`,
...(nachricht ? [nachricht.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/\n/g, '<br>')] : []),
`${esc(absender)} hat dir etwas über Klarbild geschickt: <b>${esc(titel)}</b>.`,
...(nachricht ? [esc(nachricht).replace(/\n/g, '<br>')] : []),
[gueltig, wort].filter(Boolean).join(' '),
];