import type { APIRoute } from 'astro';
import { randomBytes, createHash } from 'node:crypto';
import { one, query } from '../../../lib/db';
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) =>
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(/\/+$/, '');
/**
* 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 ip = echteIp(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('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
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]);
// 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);
// 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 }>(
'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;
// 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 ${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 }),
}).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 });
};