diff --git a/FEATURES.md b/FEATURES.md index 0e673e8..81e6ea8 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -503,6 +503,78 @@ Dateneinsicht. Ein Administrator sieht dadurch weiterhin keine fremden Projekte. - **Zeitlimit** pro Aufruf über `GIT_API_TIMEOUT_MS` einstellbar; die Teilabfragen laufen parallel, eine fehlschlagende Liste bricht den restlichen Sync nicht ab. +### 4.6 Aufgabe ↔ Git-Issue + +Die Verbindung geht in beide Richtungen — mit einer bewussten Grenze. + +**Nebula → Git.** Ein Knopf im Aufgabendialog legt zur Aufgabe ein Issue im +verknüpften Repository an. Nummer und Adresse landen an der Aufgabe (`sourceIssue`, +`sourceUrl`, `sourceRepositoryId` — dieselben Felder, die die Übernahme aus Issues seit +v0.9.0 füllt, samt Eindeutigkeit je Repository gegen Dubletten). In den Rumpf kommt ein +Rückverweis auf das Projekt. + +Ausdrücklich **per Knopf und nie nebenbei**: was in ein fremdes System geschrieben wird, +soll jemand gewollt haben. Danach wandert der Zustand allerdings mit — erledigt schließt +das Issue, ein Zurückziehen öffnet es wieder. Schlägt der Aufruf fehl, zieht die Aufgabe +trotzdem um; ein nicht erreichbares Git-System darf das Abhaken nicht verhindern. + +**Git → Nebula** über Webhooks, siehe unten. + +**Die Zustände passen nicht aufeinander, und das bleibt so.** Nebula kennt +`Offen · In Arbeit · Blockiert · Erledigt`, ein Issue nur offen oder geschlossen. Nach +außen geht deshalb nur die Grenze um „erledigt“; ein Rundlauf verliert *In Arbeit* und +*Blockiert*. Labels wie `nebula:doing` wären die Alternative, würden aber den +Issue-Tracker mit Zuständen füllen, die dort niemand pflegt. Die feine Abstufung bleibt +in Nebula. + +Eine echte beidseitige Synchronisation mit Konfliktauflösung gibt es **nicht** — das ist +der klassische Ort für stillen Datenverlust. + +**Kein Widerspruch zu „keine eigenen Git-Operationen“:** Issues laufen über die HTTP-API +der Provider, dieselbe Schicht wie alles andere (`lib/git/http.ts`, SSRF-Schutz +inklusive). Kein Klon, kein Arbeitsverzeichnis. Schreibende Aufrufe folgen dabei +**keinen Umleitungen** — ein 307 auf eine interne Adresse nähme den Rumpf und den Token +im Header mit. + +### 4.7 Webhooks + +Ein Push aktualisiert den Stand sofort, statt bis zum nächsten Durchlauf des Zeitgebers +zu warten; ein geschlossenes Issue hakt seine Aufgabe ab. Der Zeitplan bleibt als +Rückfalllösung bestehen. + +Einzurichten am Repository (Funksymbol im Git-Bereich): Nebula erzeugt URL und +Geheimnis, eingetragen wird der Haken im Git-System von Hand. Über die API anzulegen +wäre machbar, verlangte aber Administrationsrechte am Repository — deutlich mehr, als +Nebula sonst braucht. + +Das Geheimnis ist **genau einmal** zu sehen; danach liegt es verschlüsselt in der +Datenbank, wie die Zugangstoken, und verlässt den Server nicht mehr. + +| System | Ausweis | Kopfzeile | +| --- | --- | --- | +| GitHub | HMAC-SHA256 über den Rumpf | `X-Hub-Signature-256: sha256=…` | +| Gitea | dieselbe Rechnung, ohne Präfix | `X-Gitea-Signature` (ältere: `X-Hub-Signature-256`) | +| GitLab | das Geheimnis im Klartext | `X-Gitlab-Token` | + +GitLabs Verfahren ist schwächer: es beweist nur, dass der Absender das Geheimnis kennt, +nicht dass der Rumpf unverändert ist. Mehr gibt die Plattform nicht her; verglichen wird +trotzdem zeitkonstant. + +Geprüft wird gegen den **rohen** Rumpf — durch `JSON.parse` und `JSON.stringify` gedreht +käme eine andere Zeichenfolge heraus und die Signatur passte nie. + +Was der Endpunkt bewusst **nicht** tut: den Rumpf als Quelle für Inhalte benutzen. Titel, +Beschreibung und Zustand kommen weiter aus Nebula beziehungsweise beim nächsten Abgleich +aus der API. Wer das Geheimnis kennt, kann also höchstens eine Aufgabe auf- oder zumachen +und einen Abgleich auslösen — keine beliebigen Daten einschleusen. + +Unbekannte Ereignisse werden angenommen und liegen gelassen. Ein Fehler brächte das +Git-System zum Wiederholen und irgendwann zum Abschalten des Hakens. + +Der Endpunkt ist der einzige ohne Anmeldung, der schreibt. Deshalb: Ratenbegrenzung je +Repository (60/Minute), Deckel auf die Rumpfgröße, und ein unbekanntes Repository +antwortet wie ein nicht eingerichtetes — mit 404, damit die ID nicht bestätigt wird. + --- ## 4a. Export, Import und Sicherung diff --git a/ROADMAP.md b/ROADMAP.md index ad8ee31..98fb013 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -27,15 +27,6 @@ Migration wert. --- -### 1.1 Webhooks statt Polling `M` - -Ein Push aktualisiert den Snapshot sofort, statt bis zum nächsten Durchlauf -zu warten. Braucht einen öffentlich erreichbaren Endpunkt mit -Signaturprüfung je Provider — sinnvoll erst, wenn die Instanz von außen -erreichbar ist. Der Zeitplan bleibt als Rückfalllösung. - ---- - ### 1.2 Läuft es noch? `M` Eine Health-Check-URL je Projekt, die der Zeitgeber regelmäßig anpingt. @@ -52,47 +43,21 @@ Sitzungen. --- -### 1.4 v1.7.0 „Aufgaben und Issues" `M` + `M` +### 1.4 Aufgaben ↔ Issues: was noch fehlt `S` -Nachverfolgbarkeit zwischen Nebula-Aufgabe und Git. Die Zuordnung steht im -Schema bereits: `sourceIssue`, `sourceUrl`, `sourceRepositoryId` samt -`@@unique`. Die Richtung **Git → Nebula** gibt es seit v0.9.0 -(`tasks/from-issues`); es fehlt die Gegenrichtung. +Mit v1.7.0 stehen beide Richtungen: „als Issue anlegen“ und der Rückweg über +Webhooks. Offen geblieben ist Kleinkram: -**Kein Widerspruch zu „Bewusst nicht".** Ausgeschlossen sind dort *eigene -Git-Operationen* — klonen, committen, pushen. Issues laufen über die -HTTP-API der Provider, dieselbe Schicht wie alles andere (`lib/git/http.ts`, -SSRF-Schutz inklusive). Kein Klon, kein Arbeitsverzeichnis. +- **Mehrere Repositories.** Der Knopf nimmt immer das führende. Bei einem + Projekt aus Frontend und Backend fehlt die Wahl. +- **Beim Übernehmen aus Issues** wird der Rückweg nicht automatisch + eingerichtet — der Webhook ist getrennt zu setzen. -Warum Issues und nicht eine Datei im Repo: **dort verlinkt Git ohnehin.** -Ein `fixes #12` in der Commit-Nachricht stellt die Verbindung von selbst -her — die Kette Aufgabe → Commit → PR gibt es geschenkt, statt sie zu bauen. -Damit erledigt sich auch „Aufgabe ↔ Commit verknüpfen" aus Abschnitt 3. - -**Stufe 1 — eine Richtung, ausdrücklich** `M` -Ein Knopf „als Issue anlegen" an der Aufgabe. Nummer und URL wandern in die -vorhandenen Felder. Kein Automatismus: was nach außen geht, wird -angestoßen, nicht nebenbei mitgeschrieben. - -**Stufe 2 — Rückweg über Webhooks** `M` -Setzt 1.1 voraus. Geschlossenes Issue → Aufgabe nach `DONE`. Erst damit ist -„synchron" ehrlich, ohne im Minutentakt fremde APIs zu fragen. - -**Die Zustände passen nicht aufeinander, und das bleibt so.** Nebula kennt -`TODO/DOING/BLOCKED/DONE`, ein Issue nur offen/geschlossen. Ein Rundlauf -verliert also `DOING` und `BLOCKED` — bewusst hingenommen: die feine -Abstufung bleibt in Nebula, nach Git geht nur offen/zu. Labels wie -`nebula:doing` wären die Alternative, würden aber den Issue-Tracker mit -Zuständen zumüllen, die dort niemand pflegt. - -**Echte beidseitige Synchronisation mit Konfliktauflösung ist nicht -vorgesehen.** Das ist der klassische Ort für stillen Datenverlust, und für -ein persönliches Dashboard steht der Aufwand in keinem Verhältnis. - -Für Projekte ohne Issue-Tracker wäre eine `.nebula/tasks.md` über die -Contents-API denkbar — aber nur als **einseitiger Spiegel**, nicht als -Synchronisation: zwei Schreiber auf einer Datei, und jede Statusänderung -ein Commit. +Was **nicht** kommt, bleibt wie besprochen: eine echte beidseitige +Synchronisation mit Konfliktauflösung. Die Zustände passen ohnehin nicht +aufeinander — ein Issue kennt nur offen und geschlossen, `DOING` und +`BLOCKED` gehen im Rundlauf verloren. Das ist hingenommen, statt den +Issue-Tracker mit Labels zu füllen, die dort niemand pflegt. --- @@ -172,6 +137,7 @@ Damit klar ist, was **nicht** kommt und warum: | **v1.4.0** README-Vorschau auf der Projektseite | ✓ | | **v1.5.0** Sicherheitsdurchsicht, Server-Anleitung und Update-Skript | ✓ | | **v1.6.0** Mobile Ansicht, Listenansicht, Schnellerfassung, Aufgaben über alle Projekte | ✓ | +| **v1.7.0** Aufgabe als Git-Issue anlegen, Webhooks für Push und Issues | ✓ | Zur Entscheidung beim Export: Git-Tokens gehen **nicht** mit. Die Verbindung wird nur beschrieben, das Token ist nach dem Import neu zu hinterlegen. Snapshots bleiben diff --git a/package.json b/package.json index b12e176..9fb664c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nebula", - "version": "1.6.0", + "version": "1.7.0", "private": true, "description": "Nebula – Dashboard fuer Vibe-Coding Projekte mit Git-Anbindung (GitHub, GitLab, Gitea)", "scripts": { diff --git a/prisma/migrations/20260815000000_repository_webhook/migration.sql b/prisma/migrations/20260815000000_repository_webhook/migration.sql new file mode 100644 index 0000000..bdec992 --- /dev/null +++ b/prisma/migrations/20260815000000_repository_webhook/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "Repository" ADD COLUMN "webhookLastAt" TIMESTAMP(3), +ADD COLUMN "webhookSecret" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 909b5fd..5eaed02 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -407,6 +407,16 @@ model Repository { notifiedCiState String? notifiedRelease String? + /// Gemeinsames Geheimnis fuer eingehende Webhooks, verschluesselt wie die + /// Zugangstoken. Je Repository, weil der Haken im Git-System dort + /// eingetragen wird und sich einzeln zuruecknehmen laesst. + /// NULL heisst: fuer dieses Repository ist kein Webhook eingerichtet, und + /// eingehende Aufrufe werden abgewiesen. + webhookSecret String? + /// Wann zuletzt ein gueltiger Webhook ankam – die einzige Rueckmeldung + /// darueber, ob der Haken im Git-System wirklich funktioniert. + webhookLastAt DateTime? + snapshot RepoSnapshot? createdAt DateTime @default(now()) diff --git a/src/app/api/repositories/[id]/webhook/route.ts b/src/app/api/repositories/[id]/webhook/route.ts new file mode 100644 index 0000000..88f475b --- /dev/null +++ b/src/app/api/repositories/[id]/webhook/route.ts @@ -0,0 +1,63 @@ +import { randomBytes } from "node:crypto"; +import { NextResponse } from "next/server"; +import { requireUserApi } from "@/lib/auth/guard"; +import { APP_URL } from "@/lib/config"; +import { encryptSecret } from "@/lib/crypto"; +import { prisma } from "@/lib/db"; +import { ownedRepository } from "@/lib/repositories"; +import { jsonError } from "@/lib/security/request"; + +export const dynamic = "force-dynamic"; + +type Params = { params: Promise<{ id: string }> }; + +/** + * Richtet den Webhook eines Repositories ein oder nimmt ihn zurueck. + * + * Das Geheimnis wird hier erzeugt und **genau einmal** im Klartext + * zurueckgegeben – danach liegt es nur noch verschluesselt in der Datenbank, + * wie die Zugangstoken. Wer es verliert, erzeugt ein neues; das alte gilt + * dann nicht mehr. + * + * Eingetragen wird der Haken im Git-System von Hand. Ihn ueber die API + * anzulegen waere machbar, verlangte aber Administrationsrechte am + * Repository – deutlich mehr, als Nebula sonst braucht. + */ +export async function POST(_request: Request, { params }: Params) { + const auth = await requireUserApi(); + if (!auth.ok) return auth.response; + + const { id } = await params; + const repository = await ownedRepository(id, auth.user.id); + if (!repository) return jsonError("Repository nicht gefunden", 404); + + // 32 Byte, hexadezimal. Lang genug, dass Raten aussichtslos ist, und + // ohne Sonderzeichen, die in Formularfeldern Aerger machen. + const secret = randomBytes(32).toString("hex"); + + await prisma.repository.update({ + where: { id: repository.id }, + data: { webhookSecret: encryptSecret(secret), webhookLastAt: null }, + }); + + return NextResponse.json({ + secret, + url: `${APP_URL}/api/webhooks/${repository.id}`, + }); +} + +export async function DELETE(_request: Request, { params }: Params) { + const auth = await requireUserApi(); + if (!auth.ok) return auth.response; + + const { id } = await params; + const repository = await ownedRepository(id, auth.user.id); + if (!repository) return jsonError("Repository nicht gefunden", 404); + + await prisma.repository.update({ + where: { id: repository.id }, + data: { webhookSecret: null, webhookLastAt: null }, + }); + + return NextResponse.json({ ok: true }); +} diff --git a/src/app/api/webhooks/[repositoryId]/route.ts b/src/app/api/webhooks/[repositoryId]/route.ts new file mode 100644 index 0000000..5fa295c --- /dev/null +++ b/src/app/api/webhooks/[repositoryId]/route.ts @@ -0,0 +1,136 @@ +import { NextResponse } from "next/server"; +import { tryDecryptSecret } from "@/lib/crypto"; +import { prisma } from "@/lib/db"; +import { rateLimit } from "@/lib/security/rateLimit"; +import { syncRepository } from "@/lib/sync"; +import { statusForIssueState } from "@/lib/tasks/issues"; +import { refreshDerivedProgress } from "@/lib/tasks"; +import { parseEvent, verifySignature } from "@/lib/webhooks/verify"; + +export const dynamic = "force-dynamic"; + +type Params = { params: Promise<{ repositoryId: string }> }; + +/** Groesse des Rumpfes deckeln – ein Push-Ereignis ist nie ein Megabyte. */ +const MAX_BODY = 512 * 1024; + +/** + * Eingehende Webhooks von GitHub, GitLab und Gitea. + * + * Der einzige Endpunkt ohne Anmeldung, der etwas schreibt. Ausgewiesen wird + * sich mit einer Signatur ueber den Rumpf, geprueft gegen das Geheimnis, das + * beim Einrichten am Repository hinterlegt wurde. + * + * Absichtlich sparsam in dem, was er tut: + * + * push – stoesst denselben Abgleich an wie der Zeitgeber + * issues – geschlossen oder wieder geoeffnet wandert an die Aufgabe + * + * Alles andere wird angenommen und liegen gelassen. Ein Webhook, der mit + * einem Fehler antwortet, wird von den Systemen wiederholt und irgendwann + * abgeschaltet – deshalb ist "verstanden, nichts zu tun" die richtige + * Antwort auf ein unbekanntes Ereignis. + * + * Was hier NICHT passiert: der Rumpf wird nie als Quelle fuer Inhalte + * benutzt. Titel, Beschreibung und Zustand einer Aufgabe kommen weiter aus + * Nebula beziehungsweise beim naechsten Abgleich aus der API. Wer das + * Geheimnis kennt, kann also hoechstens eine Aufgabe auf- oder zumachen und + * einen Abgleich ausloesen – nicht beliebige Daten einschleusen. + */ +export async function POST(request: Request, { params }: Params) { + const { repositoryId } = await params; + + // Die ID steht in der URL und ist damit halboeffentlich. Ein Deckel + // dagegen, sie als Orakel zu benutzen oder den Abgleich zu verheizen. + const limit = rateLimit(`webhook:${repositoryId}`, 60, 60_000); + if (!limit.ok) { + return NextResponse.json( + { error: "Zu viele Aufrufe." }, + { status: 429, headers: { "Retry-After": String(limit.retryAfter) } }, + ); + } + + const repository = await prisma.repository.findUnique({ + where: { id: repositoryId }, + include: { connection: true }, + }); + + // Unbekanntes Repository und fehlendes Geheimnis sind dieselbe Antwort: + // ob es die ID gibt, geht den Anrufer nichts an. + const secret = repository?.webhookSecret + ? tryDecryptSecret(repository.webhookSecret) + : null; + if (!repository || !secret) { + return NextResponse.json({ error: "Unbekannt." }, { status: 404 }); + } + + const roh = await request.text(); + if (roh.length > MAX_BODY) { + return NextResponse.json({ error: "Rumpf zu gross." }, { status: 413 }); + } + + /** + * Der **rohe** Rumpf geht in die Pruefung, nicht das geparste Objekt: + * durch JSON.parse und JSON.stringify gedreht kaeme eine andere Zeichen- + * folge heraus und die Signatur passte nie. + */ + const provider = repository.connection?.provider ?? "GITEA"; + const geprueft = verifySignature(provider, secret, roh, request.headers); + if (!geprueft.ok) { + return NextResponse.json({ error: "Signatur ungueltig." }, { status: 401 }); + } + + let payload: unknown = null; + try { + payload = JSON.parse(roh); + } catch { + return NextResponse.json({ error: "Kein gueltiges JSON." }, { status: 400 }); + } + + const eventName = + request.headers.get("x-github-event") ?? + request.headers.get("x-gitea-event") ?? + request.headers.get("x-gitlab-event"); + + const ereignis = parseEvent(provider, eventName, payload); + + // Ab hier gilt der Aufruf als echt – das ist die Rueckmeldung darueber, + // ob der Haken im Git-System ueberhaupt funktioniert. + await prisma.repository.update({ + where: { id: repository.id }, + data: { webhookLastAt: new Date() }, + }); + + if (ereignis.kind === "push") { + // Derselbe Weg wie beim Zeitgeber – nur eben sofort statt beim + // naechsten Durchlauf. + await syncRepository(repository.id).catch(() => undefined); + return NextResponse.json({ ok: true, handled: "push" }); + } + + if (ereignis.kind === "issue") { + const task = await prisma.task.findFirst({ + where: { sourceRepositoryId: repository.id, sourceIssue: ereignis.number }, + select: { id: true, status: true, projectId: true }, + }); + if (!task) { + return NextResponse.json({ ok: true, handled: "issue", matched: false }); + } + + const ziel = statusForIssueState(ereignis.open, task.status); + if (ziel !== task.status) { + await prisma.task.update({ + where: { id: task.id }, + data: { + status: ziel, + doneAt: ziel === "DONE" ? new Date() : null, + }, + }); + await refreshDerivedProgress(task.projectId); + } + + return NextResponse.json({ ok: true, handled: "issue", matched: true, status: ziel }); + } + + return NextResponse.json({ ok: true, handled: "ignored", event: ereignis.name }); +} diff --git a/src/app/projects/[id]/page.tsx b/src/app/projects/[id]/page.tsx index b975665..400254b 100644 --- a/src/app/projects/[id]/page.tsx +++ b/src/app/projects/[id]/page.tsx @@ -11,7 +11,7 @@ import { TaskBoard } from "@/components/tasks/TaskBoard"; import { requireUserPage } from "@/lib/auth/guard"; import { getSessionUser } from "@/lib/auth/session"; import { prisma } from "@/lib/db"; -import { connectionSafeSelect, projectDetailInclude } from "@/lib/projects"; +import { connectionSafeSelect, projectDetailInclude, webhookRepositoryIds } from "@/lib/projects"; import type { ConnectionSafe, ProjectDetail } from "@/types"; export const dynamic = "force-dynamic"; @@ -55,6 +55,9 @@ export default async function ProjectPage({ params }: PageProps) { const detail = project as unknown as ProjectDetail; + // Nur die Angabe "hier haengt ein Webhook", nicht das Geheimnis selbst. + const webhookIds = await webhookRepositoryIds(detail.id); + const fuehrend = detail.repositories[0]?.snapshot; const readme = fuehrend?.readme ? { @@ -116,6 +119,7 @@ export default async function ProjectPage({ params }: PageProps) { diff --git a/src/components/GitPanel.tsx b/src/components/GitPanel.tsx index 422b900..c38da89 100644 --- a/src/components/GitPanel.tsx +++ b/src/components/GitPanel.tsx @@ -17,6 +17,7 @@ import { Lock, Package, Plus, + Radio, RefreshCw, ShieldCheck, Star, @@ -26,6 +27,7 @@ import { import { CiBadge } from "@/components/CiBadge"; import { Heatmap } from "@/components/Heatmap"; import { RepositoryDialog } from "@/components/RepositoryDialog"; +import { WebhookDialog } from "@/components/WebhookDialog"; import { Sparkline } from "@/components/Sparkline"; import { repoTitle } from "@/lib/git/meta"; import { asArray, asCalendar, asRecord } from "@/types"; @@ -55,9 +57,13 @@ type TabKey = (typeof TABS)[number]["key"]; export function GitPanel({ project, connections, + webhookRepoIds = [], }: { project: ProjectDetail; connections: ConnectionSafe[]; + /** Repositories mit hinterlegtem Webhook. Das Geheimnis selbst bleibt + auf dem Server – hier kommt nur an, ob eines existiert. */ + webhookRepoIds?: string[]; }) { const router = useRouter(); const [tab, setTab] = useState("pulls"); @@ -65,6 +71,7 @@ export function GitPanel({ const [importing, setImporting] = useState(false); const [message, setMessage] = useState<{ ok: boolean; text: string } | null>(null); const [addOpen, setAddOpen] = useState(false); + const [webhookOpen, setWebhookOpen] = useState(false); const repositories = project.repositories; const [selectedId, setSelectedId] = useState(repositories[0]?.id ?? null); @@ -72,6 +79,7 @@ export function GitPanel({ // Nach dem Entfernen zeigt die Auswahl ins Leere – dann auf das fuehrende // zurueckfallen, statt eine leere Ansicht zu zeigen. const repository = repositories.find((entry) => entry.id === selectedId) ?? repositories[0] ?? null; + const webhookAktiv = repository ? webhookRepoIds.includes(repository.id) : false; /** Offene Issues als Aufgaben anlegen – ohne erneuten Abruf beim Provider. */ async function importIssues() { @@ -280,6 +288,24 @@ export function GitPanel({ Repository )} + + ) : ( + + )} + +
+ + +
+ + + + ); +} + +function Feld({ + label, + wert, + kopiert, + onCopy, +}: { + label: string; + wert: string; + kopiert: boolean; + onCopy: () => void; +}) { + return ( +
+ {label} +
+ {/* tabIndex: der Kasten scrollt seitlich und muss deshalb auch mit + der Tastatur erreichbar sein – sonst kommt man an das Ende einer + langen URL nur mit der Maus. */} + + {wert} + + +
+
+ ); +} diff --git a/src/lib/changelog.ts b/src/lib/changelog.ts index 70d6279..73c6490 100644 --- a/src/lib/changelog.ts +++ b/src/lib/changelog.ts @@ -41,6 +41,40 @@ export const KIND_META: Record }; export const CHANGELOG: Release[] = [ + { + version: "1.7.0", + date: "2026-08-15", + headline: "Aufgaben und Git-Issues finden zusammen.", + changes: [ + { + kind: "neu", + text: + "Eine Aufgabe lässt sich als Issue im verknüpften Repository anlegen — per Knopf, " + + "nie nebenbei. Danach wandert der Zustand mit: erledigt schließt das Issue, ein " + + "Zurückziehen öffnet es wieder.", + }, + { + kind: "neu", + text: + "Webhooks. Ein Push aktualisiert den Stand sofort, statt bis zum nächsten " + + "Abgleich zu warten; ein geschlossenes Issue hakt seine Aufgabe ab. Einzurichten " + + "am Repository — URL und Geheimnis stehen dort bereit.", + }, + { + kind: "behoben", + text: + "Auf dem Telefon verdeckte die Bildschirmtastatur den halben Dialog, weil er mit " + + "der vollen Fensterhöhe rechnete. Dialoge richten sich jetzt nach dem sichtbaren " + + "Bereich und rücken über die Tastatur.", + }, + { + kind: "besser", + text: + "Aufgaben lassen sich in der Übersicht direkt bearbeiten. Bisher führte von dort " + + "nur der Weg ins Projekt.", + }, + ], + }, { version: "1.6.0", date: "2026-08-14", diff --git a/src/lib/projects.ts b/src/lib/projects.ts index 157330d..bd55253 100644 --- a/src/lib/projects.ts +++ b/src/lib/projects.ts @@ -65,17 +65,66 @@ export const connectionSafeSelect = Prisma.validator updatedAt: true, }); +/** + * Felder eines Repositories, die nach aussen duerfen. + * + * Ausdruecklich als Liste und nicht als "include": ein include liefert jedes + * Skalarfeld mit, auch neu hinzugekommene. Das Webhook-Geheimnis waere so + * still in jede Projektantwort und in jede Seite gewandert – verschluesselt + * zwar, aber nach aussen gehoert es trotzdem nicht. Dieselbe Ueberlegung wie + * bei connectionSafeSelect. + * + * Ob ein Webhook eingerichtet ist, sagt webhookLastAt nicht zuverlaessig + * (frisch eingerichtet ist es null). Diese Angabe holt die Projektseite + * deshalb getrennt – siehe webhookRepositoryIds. + */ +export const repositorySafeSelect = Prisma.validator()({ + id: true, + projectId: true, + label: true, + repoUrl: true, + repoOwner: true, + repoName: true, + defaultBranch: true, + connectionId: true, + position: true, + lastSyncedAt: true, + syncError: true, + autoSync: true, + syncFailureCount: true, + notifiedSyncError: true, + notifiedCiState: true, + notifiedRelease: true, + webhookLastAt: true, + createdAt: true, + updatedAt: true, + connection: { select: connectionSafeSelect }, + snapshot: true, +}); + /** * Ein Repository mitsamt Verbindung und Stand. Die Reihenfolge ist ueberall * dieselbe: das fuehrende zuerst, denn auf der Karte ist nur fuer eines Platz. */ export const repositoryInclude = Prisma.validator()({ repositories: { - include: { connection: { select: connectionSafeSelect }, snapshot: true }, + select: repositorySafeSelect, orderBy: [{ position: "asc" }, { createdAt: "asc" }], }, }); +/** + * IDs der Repositories eines Projekts, fuer die ein Webhook hinterlegt ist. + * Getrennt geholt, damit das Geheimnis selbst nirgends mitgereicht wird. + */ +export async function webhookRepositoryIds(projectId: string): Promise { + const treffer = await prisma.repository.findMany({ + where: { projectId, webhookSecret: { not: null } }, + select: { id: true }, + }); + return treffer.map((eintrag) => eintrag.id); +} + export const projectListInclude = Prisma.validator()({ ...repositoryInclude, _count: { select: { notes: true, tasks: true } }, diff --git a/src/lib/webhooks/verify.test.ts b/src/lib/webhooks/verify.test.ts new file mode 100644 index 0000000..bfdf2a7 --- /dev/null +++ b/src/lib/webhooks/verify.test.ts @@ -0,0 +1,137 @@ +import { createHmac } from "node:crypto"; +import { describe, expect, it } from "vitest"; +import { parseEvent, verifySignature } from "./verify"; + +const GEHEIM = "ein-geheimnis-das-lang-genug-ist"; +const RUMPF = JSON.stringify({ action: "closed", issue: { number: 12 } }); + +function signiere(secret: string, body: string): string { + return createHmac("sha256", secret).update(body, "utf8").digest("hex"); +} + +function kopf(eintraege: Record): Headers { + return new Headers(eintraege); +} + +describe("verifySignature – GitHub", () => { + it("nimmt die richtige Signatur an", () => { + const headers = kopf({ "x-hub-signature-256": `sha256=${signiere(GEHEIM, RUMPF)}` }); + expect(verifySignature("GITHUB", GEHEIM, RUMPF, headers)).toEqual({ ok: true }); + }); + + it("weist eine fremde Signatur ab", () => { + const headers = kopf({ "x-hub-signature-256": `sha256=${signiere("anderes", RUMPF)}` }); + expect(verifySignature("GITHUB", GEHEIM, RUMPF, headers)).toEqual({ + ok: false, + reason: "mismatch", + }); + }); + + it("weist ab, wenn der Rumpf veraendert wurde", () => { + // Das ist der eigentliche Zweck: die Signatur haengt am Inhalt. + const headers = kopf({ "x-hub-signature-256": `sha256=${signiere(GEHEIM, RUMPF)}` }); + const veraendert = JSON.stringify({ action: "closed", issue: { number: 99 } }); + expect(verifySignature("GITHUB", GEHEIM, veraendert, headers).ok).toBe(false); + }); + + it("weist ab, wenn gar keine Signatur mitkommt", () => { + expect(verifySignature("GITHUB", GEHEIM, RUMPF, kopf({}))).toEqual({ + ok: false, + reason: "missing", + }); + }); + + it("laesst sich nicht mit dem blossen Praefix taeuschen", () => { + expect(verifySignature("GITHUB", GEHEIM, RUMPF, kopf({ "x-hub-signature-256": "sha256=" })).ok).toBe( + false, + ); + }); +}); + +describe("verifySignature – Gitea", () => { + it("nimmt die Signatur ohne Praefix an", () => { + const headers = kopf({ "x-gitea-signature": signiere(GEHEIM, RUMPF) }); + expect(verifySignature("GITEA", GEHEIM, RUMPF, headers)).toEqual({ ok: true }); + }); + + it("nimmt ersatzweise das GitHub-Format an", () => { + // Aeltere Gitea-Fassungen senden nur X-Hub-Signature-256. + const headers = kopf({ "x-hub-signature-256": `sha256=${signiere(GEHEIM, RUMPF)}` }); + expect(verifySignature("GITEA", GEHEIM, RUMPF, headers)).toEqual({ ok: true }); + }); + + it("weist eine falsche Signatur ab", () => { + const headers = kopf({ "x-gitea-signature": signiere("anderes", RUMPF) }); + expect(verifySignature("GITEA", GEHEIM, RUMPF, headers).ok).toBe(false); + }); +}); + +describe("verifySignature – GitLab", () => { + it("vergleicht den Token im Klartext", () => { + expect(verifySignature("GITLAB", GEHEIM, RUMPF, kopf({ "x-gitlab-token": GEHEIM }))).toEqual({ + ok: true, + }); + }); + + it("weist einen falschen Token ab", () => { + expect( + verifySignature("GITLAB", GEHEIM, RUMPF, kopf({ "x-gitlab-token": "falsch" })).ok, + ).toBe(false); + }); + + it("weist ab, wenn der Token fehlt", () => { + expect(verifySignature("GITLAB", GEHEIM, RUMPF, kopf({}))).toEqual({ + ok: false, + reason: "missing", + }); + }); +}); + +describe("parseEvent", () => { + it("erkennt einen Push", () => { + expect(parseEvent("GITHUB", "push", {})).toEqual({ kind: "push" }); + }); + + it("erkennt ein geschlossenes Issue bei GitHub und Gitea", () => { + const nutzlast = { action: "closed", issue: { number: 12 } }; + expect(parseEvent("GITHUB", "issues", nutzlast)).toEqual({ + kind: "issue", + number: 12, + open: false, + }); + expect(parseEvent("GITEA", "issues", nutzlast)).toEqual({ + kind: "issue", + number: 12, + open: false, + }); + }); + + it("erkennt ein wieder geoeffnetes Issue", () => { + expect(parseEvent("GITHUB", "issues", { action: "reopened", issue: { number: 7 } })).toEqual({ + kind: "issue", + number: 7, + open: true, + }); + }); + + it("nimmt bei GitLab die projektinterne Nummer", () => { + // "iid" ist die Nummer aus der Oberflaeche; "id" waere instanzweit. + const ereignis = parseEvent("GITLAB", "Issue Hook", { + object_kind: "issue", + object_attributes: { iid: 5, state: "closed" }, + }); + expect(ereignis).toEqual({ kind: "issue", number: 5, open: false }); + }); + + it("laesst Unbekanntes liegen, statt zu scheitern", () => { + expect(parseEvent("GITHUB", "issue_comment", {}).kind).toBe("ignored"); + expect(parseEvent("GITHUB", "issues", { action: "labeled", issue: { number: 1 } }).kind).toBe( + "ignored", + ); + expect(parseEvent("GITHUB", null, null).kind).toBe("ignored"); + }); + + it("laesst ein Issue ohne Nummer liegen", () => { + expect(parseEvent("GITHUB", "issues", { action: "closed", issue: {} }).kind).toBe("ignored"); + }); +}); diff --git a/src/lib/webhooks/verify.ts b/src/lib/webhooks/verify.ts new file mode 100644 index 0000000..3d1919e --- /dev/null +++ b/src/lib/webhooks/verify.ts @@ -0,0 +1,139 @@ +import { createHmac, timingSafeEqual } from "node:crypto"; +import type { GitProvider } from "@prisma/client"; + +/** + * Pruefung eingehender Webhooks. + * + * Der Endpunkt ist die einzige Stelle der Anwendung, die ohne Anmeldung + * schreibend wirkt – deshalb steht die Pruefung hier fuer sich, ohne + * Datenbank und ohne Netz, und ist damit vollstaendig testbar. + * + * Die drei Systeme machen es unterschiedlich: + * + * GitHub X-Hub-Signature-256: "sha256=", HMAC-SHA256 ueber den Rumpf + * Gitea X-Gitea-Signature: "" ohne Praefix, sonst gleich + * GitLab X-Gitlab-Token: das Geheimnis im Klartext, keine Signatur + * + * GitLabs Verfahren ist schwaecher – es beweist nur, dass der Absender das + * Geheimnis kennt, nicht dass der Rumpf unveraendert ist. Mehr gibt die + * Plattform nicht her; verglichen wird trotzdem zeitkonstant. + */ + +export type VerifyResult = + | { ok: true } + | { ok: false; reason: "missing" | "mismatch" | "unsupported" }; + +/** Vergleich ohne Laufzeitunterschied – ein Zeichenvergleich waere angreifbar. */ +function equals(a: string, b: string): boolean { + const links = Buffer.from(a, "utf8"); + const rechts = Buffer.from(b, "utf8"); + // timingSafeEqual verlangt gleiche Laenge und wirft sonst. Die Laenge + // selbst ist kein Geheimnis, der fruehe Ausstieg also unbedenklich. + if (links.length !== rechts.length) return false; + return timingSafeEqual(links, rechts); +} + +function hmacHex(secret: string, body: string): string { + return createHmac("sha256", secret).update(body, "utf8").digest("hex"); +} + +/** + * Prueft die Signatur eines eingehenden Aufrufs. + * + * `body` muss der **rohe** Rumpf sein, genau wie er ankam. Ein durch + * JSON.parse und JSON.stringify gedrehter Rumpf hat eine andere Signatur – + * andere Reihenfolge, anderes Escaping, kein Leerraum. + */ +export function verifySignature( + provider: GitProvider, + secret: string, + body: string, + headers: Headers, +): VerifyResult { + switch (provider) { + case "GITHUB": { + const gesendet = headers.get("x-hub-signature-256"); + if (!gesendet) return { ok: false, reason: "missing" }; + const erwartet = `sha256=${hmacHex(secret, body)}`; + return equals(gesendet, erwartet) ? { ok: true } : { ok: false, reason: "mismatch" }; + } + + case "GITEA": { + // Gitea schickt die Signatur ohne Praefix. Aeltere Fassungen senden + // zusaetzlich X-Hub-Signature-256 im GitHub-Format – beides gilt. + const gitea = headers.get("x-gitea-signature"); + if (gitea) { + return equals(gitea, hmacHex(secret, body)) + ? { ok: true } + : { ok: false, reason: "mismatch" }; + } + const hub = headers.get("x-hub-signature-256"); + if (hub) { + return equals(hub, `sha256=${hmacHex(secret, body)}`) + ? { ok: true } + : { ok: false, reason: "mismatch" }; + } + return { ok: false, reason: "missing" }; + } + + case "GITLAB": { + const token = headers.get("x-gitlab-token"); + if (!token) return { ok: false, reason: "missing" }; + return equals(token, secret) ? { ok: true } : { ok: false, reason: "mismatch" }; + } + + default: + return { ok: false, reason: "unsupported" }; + } +} + +/** Welches Ereignis kam an – auf einen gemeinsamen Nenner gebracht. */ +export type WebhookEvent = + | { kind: "push" } + | { kind: "issue"; number: number; open: boolean } + | { kind: "ignored"; name: string }; + +interface IssuePayload { + action?: string; + issue?: { number?: number; iid?: number; state?: string }; + object_attributes?: { iid?: number; action?: string; state?: string }; + object_kind?: string; +} + +/** + * Uebersetzt den Rumpf in ein Ereignis, das die Anwendung kennt. + * + * Bewusst grob: Nebula interessiert nur, ob ein Push kam (dann lohnt ein + * Abgleich) und ob ein Issue geschlossen oder wieder geoeffnet wurde (dann + * wandert die Aufgabe mit). Alles andere wird benannt und liegen gelassen. + */ +export function parseEvent( + provider: GitProvider, + eventName: string | null, + payload: unknown, +): WebhookEvent { + const daten = (payload ?? {}) as IssuePayload; + const name = (eventName ?? daten.object_kind ?? "").toLowerCase(); + + if (name === "push" || name === "push hook") return { kind: "push" }; + + if (provider === "GITLAB") { + if (name !== "issue" && name !== "issue hook") return { kind: "ignored", name }; + const attrs = daten.object_attributes; + const nummer = attrs?.iid; + if (typeof nummer !== "number") return { kind: "ignored", name }; + // GitLab meldet den Zustand als "opened" oder "closed". + const offen = attrs?.state !== "closed"; + return { kind: "issue", number: nummer, open: offen }; + } + + // GitHub und Gitea nennen das Ereignis "issues" und legen die Tat in + // "action" ab. Umbenennungen und Kommentare interessieren hier nicht. + if (name !== "issues") return { kind: "ignored", name }; + const nummer = daten.issue?.number; + if (typeof nummer !== "number") return { kind: "ignored", name }; + if (daten.action !== "closed" && daten.action !== "reopened" && daten.action !== "opened") { + return { kind: "ignored", name: `issues.${daten.action ?? "?"}` }; + } + return { kind: "issue", number: nummer, open: daten.action !== "closed" }; +} diff --git a/src/middleware.ts b/src/middleware.ts index f2cf86f..f5158e8 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -19,7 +19,13 @@ const SESSION_COOKIE = "nebula_session"; /** Ohne Anmeldung erreichbar. */ const PUBLIC_PATHS = ["/login", "/setup", "/register"]; -const PUBLIC_API_PREFIXES = ["/api/auth/"]; +/** + * Eingehende Webhooks bringen kein Cookie mit – sie kommen von GitHub, + * GitLab oder Gitea. Ausgewiesen wird sich dort mit einer Signatur ueber den + * Rumpf, geprueft im Endpunkt selbst (lib/webhooks/verify). Ohne gueltiges + * Geheimnis passiert nichts. + */ +const PUBLIC_API_PREFIXES = ["/api/auth/", "/api/webhooks/"]; /** * Der Docker-Healthcheck hat kein Cookie. Manifest und Symbole braucht der * Browser, bevor sich jemand angemeldet hat – wuerden sie zur Anmeldeseite @@ -91,7 +97,12 @@ export function middleware(request: NextRequest) { const isDev = process.env.NODE_ENV === "development"; // ── 1. CSRF ──────────────────────────────────────────────── - if (!SAFE_METHODS.has(request.method) && !isSameOrigin(request)) { + // Webhooks kommen von einem fremden Server und koennen keinen passenden + // Origin mitbringen. Die Herkunftspruefung schuetzt gegen den Browser + // eines angemeldeten Nutzers – hier gibt es keinen, und ausgewiesen wird + // sich stattdessen mit der Signatur ueber den Rumpf. + const istWebhook = pathname.startsWith("/api/webhooks/"); + if (!istWebhook && !SAFE_METHODS.has(request.method) && !isSameOrigin(request)) { return NextResponse.json( { error: "Anfrage von fremdem Ursprung abgelehnt." }, { status: 403 },