feat: API v1 fuer Aufgaben
Lesen, erfassen, aendern — mehr nicht. Status, Bereich und Projekt lassen sich von aussen nicht setzen: Was hereinkommt, landet in der Inbox und wird hier einsortiert, sonst raeumt ein fremder Dienst die eigene Ordnung um. Mit einer Kennung ist das Erfassen wiederholbar: Zweimal dasselbe ergibt eine Aufgabe, nicht zwei. Das ist der Unterschied zwischen einer Schnittstelle, die man gefahrlos wiederholen kann, und einer, die bei jedem Netzhaenger Karteileichen anlegt. Jede schreibende Nutzung steht im Protokoll, mitsamt dem Token. Lesen nicht: Ein Dienst, der im Minutentakt abfragt, wuerde das Protokoll unbrauchbar machen. Alle Fehlschlaege sehen von aussen gleich aus. Ob ein Token unbekannt, abgelaufen oder widerrufen ist, geht niemanden etwas an. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
d5c7974af3
commit
6d3ebd05ae
@@ -0,0 +1,143 @@
|
|||||||
|
import { aenderungEingabe } from '@/lib/api'
|
||||||
|
import type { Prisma } from '@/generated/prisma/client'
|
||||||
|
import { db } from '@/server/db'
|
||||||
|
import { abgelehnt, apiProtokoll, daten, rumpfLesen, wache } from '@/server/api/wache'
|
||||||
|
import { jetzt } from '@/server/zeitgeber'
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eine einzelne Aufgabe lesen und ändern.
|
||||||
|
*
|
||||||
|
* Ändern heißt: Titel, Notiz, Fälligkeit, Priorität, Aufwand — und abhaken.
|
||||||
|
* Bereich und Projekt bleiben außen vor; die Einordnung ist Sache dessen, der
|
||||||
|
* plant, nicht dessen, der meldet.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const AUSWAHL = {
|
||||||
|
id: true,
|
||||||
|
titel: true,
|
||||||
|
notiz: true,
|
||||||
|
status: true,
|
||||||
|
prioritaet: true,
|
||||||
|
aufwandMinuten: true,
|
||||||
|
faelligAm: true,
|
||||||
|
quelle: true,
|
||||||
|
quelleRef: true,
|
||||||
|
quelleUrl: true,
|
||||||
|
erstelltAm: true,
|
||||||
|
bereich: { select: { name: true } },
|
||||||
|
projekt: { select: { name: true } },
|
||||||
|
} as const
|
||||||
|
|
||||||
|
type Zeile = {
|
||||||
|
id: string
|
||||||
|
titel: string
|
||||||
|
notiz: string | null
|
||||||
|
status: string
|
||||||
|
prioritaet: number
|
||||||
|
aufwandMinuten: number | null
|
||||||
|
faelligAm: Date | null
|
||||||
|
quelle: string
|
||||||
|
quelleRef: string | null
|
||||||
|
quelleUrl: string | null
|
||||||
|
erstelltAm: Date
|
||||||
|
bereich: { name: string } | null
|
||||||
|
projekt: { name: string } | null
|
||||||
|
}
|
||||||
|
|
||||||
|
function alsAntwort(a: Zeile) {
|
||||||
|
return {
|
||||||
|
id: a.id,
|
||||||
|
titel: a.titel,
|
||||||
|
notiz: a.notiz,
|
||||||
|
status: a.status,
|
||||||
|
prioritaet: a.prioritaet,
|
||||||
|
aufwandMinuten: a.aufwandMinuten,
|
||||||
|
faelligAm: a.faelligAm ? a.faelligAm.toISOString() : null,
|
||||||
|
quelle: a.quelle,
|
||||||
|
ref: a.quelleRef,
|
||||||
|
url: a.quelleUrl,
|
||||||
|
erstelltAm: a.erstelltAm.toISOString(),
|
||||||
|
bereich: a.bereich?.name ?? null,
|
||||||
|
projekt: a.projekt?.name ?? null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
anfrage: Request,
|
||||||
|
{ params }: { params: Promise<{ id: string }> },
|
||||||
|
) {
|
||||||
|
const geprueft = await wache(anfrage, 'lesen')
|
||||||
|
if (!geprueft.ok) return geprueft.antwort
|
||||||
|
|
||||||
|
const { id } = await params
|
||||||
|
const aufgabe = await db.aufgabe.findUnique({ where: { id }, select: AUSWAHL })
|
||||||
|
if (!aufgabe) return abgelehnt('nichtGefunden')
|
||||||
|
|
||||||
|
return daten(alsAntwort(aufgabe))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PATCH(
|
||||||
|
anfrage: Request,
|
||||||
|
{ params }: { params: Promise<{ id: string }> },
|
||||||
|
) {
|
||||||
|
const geprueft = await wache(anfrage, 'schreiben')
|
||||||
|
if (!geprueft.ok) return geprueft.antwort
|
||||||
|
const { aufrufer } = geprueft
|
||||||
|
|
||||||
|
const { id } = await params
|
||||||
|
const rumpf = await rumpfLesen(anfrage)
|
||||||
|
if (rumpf === undefined) return abgelehnt('ungueltig')
|
||||||
|
|
||||||
|
const gelesen = aenderungEingabe.safeParse(rumpf)
|
||||||
|
if (!gelesen.success) {
|
||||||
|
return daten(
|
||||||
|
{
|
||||||
|
fehler: 'Die Anfrage war fehlerhaft.',
|
||||||
|
felder: gelesen.error.issues.map((f) => ({
|
||||||
|
feld: f.path.join('.'),
|
||||||
|
grund: f.message,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
400,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const vorhanden = await db.aufgabe.findUnique({
|
||||||
|
where: { id },
|
||||||
|
select: { id: true, status: true },
|
||||||
|
})
|
||||||
|
if (!vorhanden) return abgelehnt('nichtGefunden')
|
||||||
|
|
||||||
|
const { erledigt, faelligAm, titel, notiz, prioritaet, aufwandMinuten } = gelesen.data
|
||||||
|
const zeitpunkt = jetzt()
|
||||||
|
|
||||||
|
// Feld fuer Feld statt Spread: Mit exactOptionalPropertyTypes ist ein
|
||||||
|
// gesetztes "undefined" etwas anderes als ein fehlendes Feld, und Prisma
|
||||||
|
// wuerde daraus einen Schreibversuch machen.
|
||||||
|
const werte: Prisma.AufgabeUpdateInput = {}
|
||||||
|
if (titel !== undefined) werte.titel = titel
|
||||||
|
if (notiz !== undefined) werte.notiz = notiz
|
||||||
|
if (prioritaet !== undefined) werte.prioritaet = prioritaet
|
||||||
|
if (aufwandMinuten !== undefined) werte.aufwandMinuten = aufwandMinuten
|
||||||
|
if (faelligAm !== undefined) {
|
||||||
|
werte.faelligAm = faelligAm === null ? null : new Date(faelligAm)
|
||||||
|
}
|
||||||
|
if (erledigt !== undefined) {
|
||||||
|
werte.status = erledigt ? 'ERLEDIGT' : 'OFFEN'
|
||||||
|
werte.erledigtAm = erledigt ? zeitpunkt : null
|
||||||
|
}
|
||||||
|
|
||||||
|
const aufgabe = await db.aufgabe.update({
|
||||||
|
where: { id: vorhanden.id },
|
||||||
|
data: werte,
|
||||||
|
select: AUSWAHL,
|
||||||
|
})
|
||||||
|
|
||||||
|
await apiProtokoll(aufrufer, 'api.geaendert', aufgabe.id, {
|
||||||
|
felder: Object.keys(gelesen.data),
|
||||||
|
})
|
||||||
|
|
||||||
|
return daten(alsAntwort(aufgabe))
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import { abfrage, aufgabeEingabe } from '@/lib/api'
|
||||||
|
import type { Prisma } from '@/generated/prisma/client'
|
||||||
|
import { db } from '@/server/db'
|
||||||
|
import { abgelehnt, apiProtokoll, daten, rumpfLesen, wache } from '@/server/api/wache'
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aufgaben lesen und anlegen.
|
||||||
|
*
|
||||||
|
* Was hier hereinkommt, landet in der **Inbox**: ohne Bereich, ohne Projekt,
|
||||||
|
* mit Quelle `API`. Ein fremder Dienst soll melden, was es zu tun gibt — wo
|
||||||
|
* es hingehört, wird hier entschieden.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Wie eine Aufgabe nach außen aussieht. Bewusst schmal und stabil. */
|
||||||
|
function alsAntwort(a: {
|
||||||
|
id: string
|
||||||
|
titel: string
|
||||||
|
notiz: string | null
|
||||||
|
status: string
|
||||||
|
prioritaet: number
|
||||||
|
aufwandMinuten: number | null
|
||||||
|
faelligAm: Date | null
|
||||||
|
quelle: string
|
||||||
|
quelleRef: string | null
|
||||||
|
quelleUrl: string | null
|
||||||
|
erstelltAm: Date
|
||||||
|
bereich: { name: string } | null
|
||||||
|
projekt: { name: string } | null
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
|
id: a.id,
|
||||||
|
titel: a.titel,
|
||||||
|
notiz: a.notiz,
|
||||||
|
status: a.status,
|
||||||
|
prioritaet: a.prioritaet,
|
||||||
|
aufwandMinuten: a.aufwandMinuten,
|
||||||
|
faelligAm: a.faelligAm ? a.faelligAm.toISOString() : null,
|
||||||
|
quelle: a.quelle,
|
||||||
|
ref: a.quelleRef,
|
||||||
|
url: a.quelleUrl,
|
||||||
|
erstelltAm: a.erstelltAm.toISOString(),
|
||||||
|
bereich: a.bereich?.name ?? null,
|
||||||
|
projekt: a.projekt?.name ?? null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const AUSWAHL = {
|
||||||
|
id: true,
|
||||||
|
titel: true,
|
||||||
|
notiz: true,
|
||||||
|
status: true,
|
||||||
|
prioritaet: true,
|
||||||
|
aufwandMinuten: true,
|
||||||
|
faelligAm: true,
|
||||||
|
quelle: true,
|
||||||
|
quelleRef: true,
|
||||||
|
quelleUrl: true,
|
||||||
|
erstelltAm: true,
|
||||||
|
bereich: { select: { name: true } },
|
||||||
|
projekt: { select: { name: true } },
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export async function GET(anfrage: Request) {
|
||||||
|
const geprueft = await wache(anfrage, 'lesen')
|
||||||
|
if (!geprueft.ok) return geprueft.antwort
|
||||||
|
|
||||||
|
const suche = new URL(anfrage.url).searchParams
|
||||||
|
const gelesen = abfrage.safeParse({
|
||||||
|
grenze: suche.get('grenze') ?? undefined,
|
||||||
|
versatz: suche.get('versatz') ?? undefined,
|
||||||
|
offen: suche.get('offen'),
|
||||||
|
})
|
||||||
|
if (!gelesen.success) return abgelehnt('ungueltig')
|
||||||
|
|
||||||
|
const { grenze, versatz, offen } = gelesen.data
|
||||||
|
const wo: Prisma.AufgabeWhereInput =
|
||||||
|
offen === null
|
||||||
|
? {}
|
||||||
|
: offen
|
||||||
|
? { status: { in: ['OFFEN', 'GEPLANT', 'WARTET'] } }
|
||||||
|
: { status: { in: ['ERLEDIGT', 'VERWORFEN'] } }
|
||||||
|
|
||||||
|
const [zeilen, gesamt] = await Promise.all([
|
||||||
|
db.aufgabe.findMany({
|
||||||
|
where: wo,
|
||||||
|
orderBy: [{ faelligAm: { sort: 'asc', nulls: 'last' } }, { erstelltAm: 'desc' }],
|
||||||
|
take: grenze,
|
||||||
|
skip: versatz,
|
||||||
|
select: AUSWAHL,
|
||||||
|
}),
|
||||||
|
db.aufgabe.count({ where: wo }),
|
||||||
|
])
|
||||||
|
|
||||||
|
// Lesen wird nicht protokolliert: Es ändert nichts, und ein Dienst, der im
|
||||||
|
// Minutentakt abfragt, würde das Protokoll unbrauchbar machen.
|
||||||
|
return daten({ aufgaben: zeilen.map(alsAntwort), gesamt, grenze, versatz })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(anfrage: Request) {
|
||||||
|
const geprueft = await wache(anfrage, 'erfassen')
|
||||||
|
if (!geprueft.ok) return geprueft.antwort
|
||||||
|
const { aufrufer } = geprueft
|
||||||
|
|
||||||
|
const rumpf = await rumpfLesen(anfrage)
|
||||||
|
if (rumpf === undefined) return abgelehnt('ungueltig')
|
||||||
|
|
||||||
|
const gelesen = aufgabeEingabe.safeParse(rumpf)
|
||||||
|
if (!gelesen.success) {
|
||||||
|
return daten(
|
||||||
|
{
|
||||||
|
fehler: 'Die Anfrage war fehlerhaft.',
|
||||||
|
felder: gelesen.error.issues.map((f) => ({
|
||||||
|
feld: f.path.join('.'),
|
||||||
|
grund: f.message,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
400,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const eingabe = gelesen.data
|
||||||
|
const werte = {
|
||||||
|
titel: eingabe.titel,
|
||||||
|
notiz: eingabe.notiz,
|
||||||
|
quelle: 'API' as const,
|
||||||
|
quelleUrl: eingabe.url,
|
||||||
|
// Wie im Formular: Ein reines Datum wird zu Mitternacht UTC. Der Planer
|
||||||
|
// rechnet Faelligkeiten ohnehin auf Berliner Kalendertage um.
|
||||||
|
faelligAm: eingabe.faelligAm ? new Date(eingabe.faelligAm) : null,
|
||||||
|
...(eingabe.prioritaet ? { prioritaet: eingabe.prioritaet } : {}),
|
||||||
|
...(eingabe.aufwandMinuten ? { aufwandMinuten: eingabe.aufwandMinuten } : {}),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mit Kennung ist der Aufruf wiederholbar: Zweimal dasselbe ergibt eine
|
||||||
|
// Aufgabe, nicht zwei. Ohne Kennung entsteht jedes Mal eine neue – dann
|
||||||
|
// hat der Aufrufer es so gewollt.
|
||||||
|
const aufgabe = eingabe.ref
|
||||||
|
? await db.aufgabe.upsert({
|
||||||
|
where: { quelle_quelleRef: { quelle: 'API', quelleRef: eingabe.ref } },
|
||||||
|
update: { titel: werte.titel, notiz: werte.notiz, quelleUrl: werte.quelleUrl },
|
||||||
|
create: { ...werte, quelleRef: eingabe.ref },
|
||||||
|
select: AUSWAHL,
|
||||||
|
})
|
||||||
|
: await db.aufgabe.create({ data: werte, select: AUSWAHL })
|
||||||
|
|
||||||
|
await apiProtokoll(aufrufer, 'api.erfasst', aufgabe.id, { ref: eingabe.ref })
|
||||||
|
|
||||||
|
return daten(alsAntwort(aufgabe), 201)
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { tokenAusKopfzeilen } from '@/lib/api'
|
||||||
|
import { tokenPruefen } from '@/server/api/token'
|
||||||
|
import { abgelehnt, daten } from '@/server/api/wache'
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* „Wer bin ich, und was darf ich?"
|
||||||
|
*
|
||||||
|
* Der erste Aufruf beim Einrichten. Er braucht **kein** Recht — sonst müsste
|
||||||
|
* man raten, ob der Token falsch ist oder nur zu wenig darf, und genau diese
|
||||||
|
* Ratestunde soll niemand haben.
|
||||||
|
*/
|
||||||
|
export async function GET(anfrage: Request) {
|
||||||
|
const aufrufer = await tokenPruefen(
|
||||||
|
tokenAusKopfzeilen(
|
||||||
|
anfrage.headers.get('Authorization'),
|
||||||
|
anfrage.headers.get('X-Planer-Token'),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if (!aufrufer) return abgelehnt('nichtAngemeldet')
|
||||||
|
|
||||||
|
// Kein Wort über den Benutzer außer dem Namen: Die Schnittstelle ist kein
|
||||||
|
// Adressbuch.
|
||||||
|
return daten({ name: aufrufer.name, rechte: aufrufer.rechte })
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import 'server-only'
|
||||||
|
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'
|
||||||
|
import {
|
||||||
|
PRAEFIX_LAENGE,
|
||||||
|
TOKEN_MARKE,
|
||||||
|
rechteLesen,
|
||||||
|
tokenZerlegen,
|
||||||
|
type Recht,
|
||||||
|
} from '@/lib/api'
|
||||||
|
import { db } from '@/server/db'
|
||||||
|
import { jetzt } from '@/server/zeitgeber'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tokens für die öffentliche Schnittstelle.
|
||||||
|
*
|
||||||
|
* Gespeichert wird nur der Hash. Ein Token ist ein Passwort — es gibt keinen
|
||||||
|
* Grund, es zurücklesen zu können, und jeden Grund, es nicht zu können.
|
||||||
|
*
|
||||||
|
* SHA-256 ohne Salz und ohne Streckung ist hier richtig, anders als bei
|
||||||
|
* Passwörtern: Der Wert hat 32 Byte Zufall aus einer sicheren Quelle. Gegen
|
||||||
|
* so etwas hilft kein Wörterbuch, und die Prüfung muss bei jeder Anfrage
|
||||||
|
* schnell sein.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Ziffern und Kleinbuchstaben — das Präfix wird auch mal abgetippt. */
|
||||||
|
const PRAEFIX_ZEICHEN = 'abcdefghijkmnopqrstuvwxyz23456789'
|
||||||
|
|
||||||
|
function praefixBauen(): string {
|
||||||
|
const bytes = randomBytes(PRAEFIX_LAENGE)
|
||||||
|
return [...bytes].map((b) => PRAEFIX_ZEICHEN[b % PRAEFIX_ZEICHEN.length]).join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tokenHashen(wert: string): string {
|
||||||
|
return createHash('sha256').update(wert, 'utf8').digest('hex')
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NeuerToken = {
|
||||||
|
id: string
|
||||||
|
/** Nur hier, nur jetzt. Danach steht der Wert nirgends mehr. */
|
||||||
|
klartext: string
|
||||||
|
praefix: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Legt einen Token an und gibt ihn ein einziges Mal heraus. */
|
||||||
|
export async function tokenAnlegen(
|
||||||
|
benutzerId: string,
|
||||||
|
name: string,
|
||||||
|
rechte: readonly Recht[],
|
||||||
|
gueltigTage: number | null,
|
||||||
|
): Promise<NeuerToken> {
|
||||||
|
const praefix = praefixBauen()
|
||||||
|
const geheimnis = randomBytes(32).toString('base64url')
|
||||||
|
const klartext = `${TOKEN_MARKE}_${praefix}_${geheimnis}`
|
||||||
|
|
||||||
|
const zeile = await db.apiToken.create({
|
||||||
|
data: {
|
||||||
|
benutzerId,
|
||||||
|
name,
|
||||||
|
praefix,
|
||||||
|
hash: tokenHashen(klartext),
|
||||||
|
rechte: rechte.join(','),
|
||||||
|
gueltigBis:
|
||||||
|
gueltigTage === null
|
||||||
|
? null
|
||||||
|
: new Date(jetzt().getTime() + gueltigTage * 86_400_000),
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
return { id: zeile.id, klartext, praefix }
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Aufrufer = {
|
||||||
|
tokenId: string
|
||||||
|
benutzerId: string
|
||||||
|
email: string
|
||||||
|
name: string
|
||||||
|
rechte: Recht[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüft einen Token und gibt den Aufrufer zurück.
|
||||||
|
*
|
||||||
|
* Alle Fehlschläge sehen von außen gleich aus: unbekannt, abgelaufen,
|
||||||
|
* widerrufen oder zu einem gesperrten Konto gehörend. Wer probiert, soll
|
||||||
|
* nicht erfahren, welcher Fall vorliegt.
|
||||||
|
*/
|
||||||
|
export async function tokenPruefen(wert: string | null): Promise<Aufrufer | null> {
|
||||||
|
if (!wert) return null
|
||||||
|
|
||||||
|
const teile = tokenZerlegen(wert)
|
||||||
|
if (!teile) return null
|
||||||
|
|
||||||
|
const zeile = await db.apiToken.findUnique({
|
||||||
|
where: { praefix: teile.praefix },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
hash: true,
|
||||||
|
rechte: true,
|
||||||
|
gueltigBis: true,
|
||||||
|
widerrufenAm: true,
|
||||||
|
benutzer: { select: { id: true, email: true, name: true, aktiv: true } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (!zeile) return null
|
||||||
|
|
||||||
|
// Auch wenn das Präfix stimmt: Der Rest muss ebenfalls passen, und der
|
||||||
|
// Vergleich läuft in gleichbleibender Zeit.
|
||||||
|
const erwartet = Buffer.from(zeile.hash, 'utf8')
|
||||||
|
const bekommen = Buffer.from(tokenHashen(wert.trim()), 'utf8')
|
||||||
|
if (erwartet.length !== bekommen.length) return null
|
||||||
|
if (!timingSafeEqual(erwartet, bekommen)) return null
|
||||||
|
|
||||||
|
const zeitpunkt = jetzt()
|
||||||
|
if (zeile.widerrufenAm) return null
|
||||||
|
if (zeile.gueltigBis && zeile.gueltigBis.getTime() < zeitpunkt.getTime()) return null
|
||||||
|
if (!zeile.benutzer.aktiv) return null
|
||||||
|
|
||||||
|
// Nicht auf das Schreiben warten: Die Anfrage soll deswegen nicht langsamer
|
||||||
|
// werden, und ein verlorener Zeitstempel ist zu verschmerzen.
|
||||||
|
void db.apiToken
|
||||||
|
.update({ where: { id: zeile.id }, data: { letzteNutzung: zeitpunkt } })
|
||||||
|
.catch(() => undefined)
|
||||||
|
|
||||||
|
return {
|
||||||
|
tokenId: zeile.id,
|
||||||
|
benutzerId: zeile.benutzer.id,
|
||||||
|
email: zeile.benutzer.email,
|
||||||
|
name: zeile.benutzer.name ?? zeile.benutzer.email,
|
||||||
|
rechte: rechteLesen(zeile.rechte),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zieht einen Token zurück. Die Zeile bleibt — für das Protokoll. */
|
||||||
|
export async function tokenWiderrufen(id: string, benutzerId: string): Promise<boolean> {
|
||||||
|
const zeile = await db.apiToken.findFirst({
|
||||||
|
where: { id, benutzerId, widerrufenAm: null },
|
||||||
|
select: { id: true },
|
||||||
|
})
|
||||||
|
if (!zeile) return false
|
||||||
|
|
||||||
|
await db.apiToken.update({
|
||||||
|
where: { id: zeile.id },
|
||||||
|
data: { widerrufenAm: jetzt() },
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import 'server-only'
|
||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import { FEHLER, darf, tokenAusKopfzeilen, type Recht } from '@/lib/api'
|
||||||
|
import { auditSchreiben } from '@/server/audit'
|
||||||
|
import { tokenPruefen, type Aufrufer } from '@/server/api/token'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die Tür zur öffentlichen Schnittstelle.
|
||||||
|
*
|
||||||
|
* Jede Route geht hier durch, und jede Route protokolliert. Eine
|
||||||
|
* Schnittstelle, bei der sich hinterher nicht sagen lässt, wer was geändert
|
||||||
|
* hat, ist in einer Anwendung mit fremden Zugängen nicht zu verantworten.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type Wachergebnis =
|
||||||
|
{ ok: true; aufrufer: Aufrufer } | { ok: false; antwort: NextResponse }
|
||||||
|
|
||||||
|
function fehler(art: keyof typeof FEHLER): NextResponse {
|
||||||
|
const { status, fehler: text } = FEHLER[art]
|
||||||
|
return NextResponse.json(
|
||||||
|
{ fehler: text },
|
||||||
|
{
|
||||||
|
status,
|
||||||
|
headers: {
|
||||||
|
'Cache-Control': 'no-store',
|
||||||
|
// Sagt Werkzeugen, wie sie sich ausweisen sollen.
|
||||||
|
...(status === 401 ? { 'WWW-Authenticate': 'Bearer' } : {}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Prüft Token und Recht in einem Zug. */
|
||||||
|
export async function wache(anfrage: Request, gebraucht: Recht): Promise<Wachergebnis> {
|
||||||
|
const wert = tokenAusKopfzeilen(
|
||||||
|
anfrage.headers.get('Authorization'),
|
||||||
|
anfrage.headers.get('X-Planer-Token'),
|
||||||
|
)
|
||||||
|
|
||||||
|
const aufrufer = await tokenPruefen(wert)
|
||||||
|
if (!aufrufer) return { ok: false, antwort: fehler('nichtAngemeldet') }
|
||||||
|
|
||||||
|
if (!darf(aufrufer.rechte, gebraucht)) {
|
||||||
|
// Das Fehlen eines Rechts wird protokolliert: Ein Dienst, der immer
|
||||||
|
// wieder an dieselbe Wand läuft, ist entweder falsch eingerichtet oder
|
||||||
|
// versucht etwas.
|
||||||
|
await auditSchreiben({
|
||||||
|
akteur: { id: aufrufer.benutzerId, email: aufrufer.email },
|
||||||
|
aktion: 'api.abgewiesen',
|
||||||
|
objektTyp: 'ApiToken',
|
||||||
|
objektId: aufrufer.tokenId,
|
||||||
|
details: { gebraucht, hat: aufrufer.rechte.join(',') },
|
||||||
|
})
|
||||||
|
return { ok: false, antwort: fehler('nichtErlaubt') }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true, aufrufer }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Antwort mit Daten. Nie zwischenspeichern — die Liste ändert sich ständig. */
|
||||||
|
export function daten(inhalt: unknown, status = 200): NextResponse {
|
||||||
|
return NextResponse.json(inhalt, {
|
||||||
|
status,
|
||||||
|
headers: { 'Cache-Control': 'no-store' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function abgelehnt(art: keyof typeof FEHLER): NextResponse {
|
||||||
|
return fehler(art)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liest den Rumpf als JSON, ohne bei Unsinn zu stolpern.
|
||||||
|
*
|
||||||
|
* Gibt `undefined` zurueck, wenn sich nichts lesen liess — das ist von
|
||||||
|
* einem gelesenen `null` zu unterscheiden.
|
||||||
|
*/
|
||||||
|
export async function rumpfLesen(anfrage: Request): Promise<unknown> {
|
||||||
|
try {
|
||||||
|
return (await anfrage.json()) as unknown
|
||||||
|
} catch {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Schreibt einen Protokolleintrag für eine Nutzung der Schnittstelle. */
|
||||||
|
export async function apiProtokoll(
|
||||||
|
aufrufer: Aufrufer,
|
||||||
|
aktion: string,
|
||||||
|
objektId: string | null,
|
||||||
|
details?: Record<string, unknown>,
|
||||||
|
): Promise<void> {
|
||||||
|
await auditSchreiben({
|
||||||
|
akteur: { id: aufrufer.benutzerId, email: aufrufer.email },
|
||||||
|
aktion,
|
||||||
|
objektTyp: 'Aufgabe',
|
||||||
|
objektId,
|
||||||
|
// Der Name des Tokens gehört dazu: Sonst steht im Protokoll nur, dass
|
||||||
|
// "der Benutzer" es war — und das war er ja gerade nicht selbst.
|
||||||
|
details: { ueber: 'api', token: aufrufer.tokenId, ...details },
|
||||||
|
})
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user