feat: Versand per Web-Push und E-Mail

Push ueber VAPID, Mail ueber SMTP, beide unabhaengig zuschaltbar. Die
Schluessel eines Push-Geraets liegen verschluesselt in der Datenbank.

Ein Geraet, das der Push-Dienst mit 404 oder 410 ablehnt, wird sofort
entfernt; andere Fehler zaehlen hoch und fuehren nach fuenf Versuchen
zum selben Ergebnis. Eine Liste toter Abos putzt sich so von allein.

Der Nachweis wird vor dem Senden geschrieben, nicht danach: lieber eine
verpasste Nachricht als eine, die im Minutentakt erneut versucht wird.

Weder Endpunkt noch Schluessel noch Mailinhalt landen im Protokoll.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-09-11 08:59:31 +02:00
co-authored by Claude Opus 5
parent ddb13d7dd0
commit e0e11c85b2
7 changed files with 787 additions and 0 deletions
+120
View File
@@ -0,0 +1,120 @@
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { auditSchreiben } from '@/server/audit'
import { verlangeAnmeldungFuerApi } from '@/server/berechtigung'
import { geraetAbmelden, geraetAnmelden } from '@/server/benachrichtigung/push'
import { umgebung } from '@/server/umgebung'
export const dynamic = 'force-dynamic'
/**
* An- und Abmelden eines Browsers für Push-Nachrichten.
*
* Der öffentliche VAPID-Schlüssel kommt über GET; er ist nicht geheim, wird
* aber nur an Angemeldete herausgegeben — es gibt keinen Grund, die
* Einrichtung nach außen zu zeigen.
*/
const anmeldung = z.object({
endpunkt: z.url(),
p256dh: z.string().min(1).max(500),
auth: z.string().min(1).max(500),
bezeichnung: z.string().trim().max(60).nullable().default(null),
})
export async function GET() {
const benutzer = await verlangeAnmeldungFuerApi()
if (!benutzer) {
return NextResponse.json({ fehler: 'Nicht angemeldet.' }, { status: 401 })
}
const einst = umgebung()
return NextResponse.json(
{
aktiv: einst.pushAktiv,
oeffentlicherSchluessel: einst.pushAktiv ? einst.VAPID_OEFFENTLICH : null,
},
{ headers: { 'Cache-Control': 'no-store' } },
)
}
export async function POST(anfrage: Request) {
const benutzer = await verlangeAnmeldungFuerApi()
if (!benutzer) {
return NextResponse.json({ fehler: 'Nicht angemeldet.' }, { status: 401 })
}
if (!umgebung().pushAktiv) {
return NextResponse.json(
{ fehler: 'Push ist auf diesem Server nicht eingerichtet.' },
{ status: 400 },
)
}
let inhalt: unknown
try {
inhalt = await anfrage.json()
} catch {
return NextResponse.json({ fehler: 'Ungültige Anfrage.' }, { status: 400 })
}
const gelesen = anmeldung.safeParse(inhalt)
if (!gelesen.success) {
return NextResponse.json(
{ fehler: 'Die Anmeldung war unvollständig.' },
{ status: 400 },
)
}
const geraet = await geraetAnmelden(
benutzer.id,
{
endpunkt: gelesen.data.endpunkt,
p256dh: gelesen.data.p256dh,
auth: gelesen.data.auth,
},
gelesen.data.bezeichnung,
)
await auditSchreiben({
akteur: { id: benutzer.id, email: benutzer.email },
aktion: 'push.angemeldet',
objektTyp: 'PushGeraet',
objektId: geraet.id,
// Weder Endpunkt noch Schlüssel – beides taugt zum Zustellen.
details: { bezeichnung: gelesen.data.bezeichnung },
})
return NextResponse.json({ ok: true })
}
const abmeldung = z.object({ endpunkt: z.url() })
export async function DELETE(anfrage: Request) {
const benutzer = await verlangeAnmeldungFuerApi()
if (!benutzer) {
return NextResponse.json({ fehler: 'Nicht angemeldet.' }, { status: 401 })
}
let inhalt: unknown
try {
inhalt = await anfrage.json()
} catch {
return NextResponse.json({ fehler: 'Ungültige Anfrage.' }, { status: 400 })
}
const gelesen = abmeldung.safeParse(inhalt)
if (!gelesen.success) {
return NextResponse.json({ fehler: 'Ungültige Anfrage.' }, { status: 400 })
}
await geraetAbmelden(gelesen.data.endpunkt)
await auditSchreiben({
akteur: { id: benutzer.id, email: benutzer.email },
aktion: 'push.abgemeldet',
objektTyp: 'PushGeraet',
objektId: null,
})
return NextResponse.json({ ok: true })
}