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:
co-authored by
Claude Opus 5
parent
ddb13d7dd0
commit
e0e11c85b2
@@ -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 })
|
||||
}
|
||||
Reference in New Issue
Block a user