diff --git a/src/app/api/v1/aufgaben/[id]/route.ts b/src/app/api/v1/aufgaben/[id]/route.ts new file mode 100644 index 0000000..93fd7d6 --- /dev/null +++ b/src/app/api/v1/aufgaben/[id]/route.ts @@ -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)) +} diff --git a/src/app/api/v1/aufgaben/route.ts b/src/app/api/v1/aufgaben/route.ts new file mode 100644 index 0000000..d9db9fd --- /dev/null +++ b/src/app/api/v1/aufgaben/route.ts @@ -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) +} diff --git a/src/app/api/v1/selbst/route.ts b/src/app/api/v1/selbst/route.ts new file mode 100644 index 0000000..ba4b969 --- /dev/null +++ b/src/app/api/v1/selbst/route.ts @@ -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 }) +} diff --git a/src/server/api/token.ts b/src/server/api/token.ts new file mode 100644 index 0000000..b04ff44 --- /dev/null +++ b/src/server/api/token.ts @@ -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 { + 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 { + 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 { + 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 +} diff --git a/src/server/api/wache.ts b/src/server/api/wache.ts new file mode 100644 index 0000000..efc0039 --- /dev/null +++ b/src/server/api/wache.ts @@ -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 { + 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 { + 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, +): Promise { + 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 }, + }) +}