Ein Push aktualisiert den Stand sofort, statt bis zum naechsten Durchlauf des Zeitgebers zu warten; ein geschlossenes Issue hakt seine Aufgabe ab. Der Zeitplan bleibt als Rueckfalllosung. Der Endpunkt ist der einzige ohne Anmeldung, der schreibt – entsprechend eng gefasst: - Ausgewiesen wird sich mit einer Signatur ueber den Rumpf. GitHub und Gitea rechnen HMAC-SHA256, GitLab schickt nur den Token im Klartext; dessen Verfahren ist schwaecher, mehr gibt die Plattform nicht her. Verglichen wird durchweg zeitkonstant. - Geprueft wird gegen den **rohen** Rumpf. Durch JSON.parse und JSON.stringify gedreht kaeme eine andere Zeichenfolge heraus und die Signatur passte nie. - Der Rumpf ist nie Quelle fuer Inhalte. Wer das Geheimnis kennt, kann eine Aufgabe auf- oder zumachen und einen Abgleich ausloesen – keine beliebigen Daten einschleusen. - Ratenbegrenzung je Repository, Deckel auf die Rumpfgroesse, und ein unbekanntes Repository antwortet wie ein nicht eingerichtetes: 404, damit die ID nicht bestaetigt wird. - Unbekannte Ereignisse werden angenommen und liegen gelassen. Ein Fehler brachte das Git-System zum Wiederholen und irgendwann zum Abschalten. Das Geheimnis liegt verschluesselt am Repository, wie die Zugangstoken, und ist genau einmal zu sehen. Damit es nicht doch nach draussen wandert, laedt repositoryInclude die Repositories nicht mehr per include, sondern ueber eine ausdrueckliche Feldliste: ein include haette das neue Feld still in jede Projektantwort und jede Seite mitgenommen. Ob ein Haken eingerichtet ist, holt die Projektseite getrennt. Die Migration legt zwei nullbare Spalten an, mehr nicht; der Gegenlauf von migrate diff kommt leer heraus. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+72
@@ -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
|
- **Zeitlimit** pro Aufruf über `GIT_API_TIMEOUT_MS` einstellbar; die Teilabfragen laufen
|
||||||
parallel, eine fehlschlagende Liste bricht den restlichen Sync nicht ab.
|
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
|
## 4a. Export, Import und Sicherung
|
||||||
|
|||||||
+13
-47
@@ -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`
|
### 1.2 Läuft es noch? `M`
|
||||||
|
|
||||||
Eine Health-Check-URL je Projekt, die der Zeitgeber regelmäßig anpingt.
|
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
|
Mit v1.7.0 stehen beide Richtungen: „als Issue anlegen“ und der Rückweg über
|
||||||
Schema bereits: `sourceIssue`, `sourceUrl`, `sourceRepositoryId` samt
|
Webhooks. Offen geblieben ist Kleinkram:
|
||||||
`@@unique`. Die Richtung **Git → Nebula** gibt es seit v0.9.0
|
|
||||||
(`tasks/from-issues`); es fehlt die Gegenrichtung.
|
|
||||||
|
|
||||||
**Kein Widerspruch zu „Bewusst nicht".** Ausgeschlossen sind dort *eigene
|
- **Mehrere Repositories.** Der Knopf nimmt immer das führende. Bei einem
|
||||||
Git-Operationen* — klonen, committen, pushen. Issues laufen über die
|
Projekt aus Frontend und Backend fehlt die Wahl.
|
||||||
HTTP-API der Provider, dieselbe Schicht wie alles andere (`lib/git/http.ts`,
|
- **Beim Übernehmen aus Issues** wird der Rückweg nicht automatisch
|
||||||
SSRF-Schutz inklusive). Kein Klon, kein Arbeitsverzeichnis.
|
eingerichtet — der Webhook ist getrennt zu setzen.
|
||||||
|
|
||||||
Warum Issues und nicht eine Datei im Repo: **dort verlinkt Git ohnehin.**
|
Was **nicht** kommt, bleibt wie besprochen: eine echte beidseitige
|
||||||
Ein `fixes #12` in der Commit-Nachricht stellt die Verbindung von selbst
|
Synchronisation mit Konfliktauflösung. Die Zustände passen ohnehin nicht
|
||||||
her — die Kette Aufgabe → Commit → PR gibt es geschenkt, statt sie zu bauen.
|
aufeinander — ein Issue kennt nur offen und geschlossen, `DOING` und
|
||||||
Damit erledigt sich auch „Aufgabe ↔ Commit verknüpfen" aus Abschnitt 3.
|
`BLOCKED` gehen im Rundlauf verloren. Das ist hingenommen, statt den
|
||||||
|
Issue-Tracker mit Labels zu füllen, die dort niemand pflegt.
|
||||||
**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.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -172,6 +137,7 @@ Damit klar ist, was **nicht** kommt und warum:
|
|||||||
| **v1.4.0** README-Vorschau auf der Projektseite | ✓ |
|
| **v1.4.0** README-Vorschau auf der Projektseite | ✓ |
|
||||||
| **v1.5.0** Sicherheitsdurchsicht, Server-Anleitung und Update-Skript | ✓ |
|
| **v1.5.0** Sicherheitsdurchsicht, Server-Anleitung und Update-Skript | ✓ |
|
||||||
| **v1.6.0** Mobile Ansicht, Listenansicht, Schnellerfassung, Aufgaben über alle Projekte | ✓ |
|
| **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
|
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
|
beschrieben, das Token ist nach dem Import neu zu hinterlegen. Snapshots bleiben
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "nebula",
|
"name": "nebula",
|
||||||
"version": "1.6.0",
|
"version": "1.7.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Nebula – Dashboard fuer Vibe-Coding Projekte mit Git-Anbindung (GitHub, GitLab, Gitea)",
|
"description": "Nebula – Dashboard fuer Vibe-Coding Projekte mit Git-Anbindung (GitHub, GitLab, Gitea)",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Repository" ADD COLUMN "webhookLastAt" TIMESTAMP(3),
|
||||||
|
ADD COLUMN "webhookSecret" TEXT;
|
||||||
@@ -407,6 +407,16 @@ model Repository {
|
|||||||
notifiedCiState String?
|
notifiedCiState String?
|
||||||
notifiedRelease 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?
|
snapshot RepoSnapshot?
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -11,7 +11,7 @@ import { TaskBoard } from "@/components/tasks/TaskBoard";
|
|||||||
import { requireUserPage } from "@/lib/auth/guard";
|
import { requireUserPage } from "@/lib/auth/guard";
|
||||||
import { getSessionUser } from "@/lib/auth/session";
|
import { getSessionUser } from "@/lib/auth/session";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { connectionSafeSelect, projectDetailInclude } from "@/lib/projects";
|
import { connectionSafeSelect, projectDetailInclude, webhookRepositoryIds } from "@/lib/projects";
|
||||||
import type { ConnectionSafe, ProjectDetail } from "@/types";
|
import type { ConnectionSafe, ProjectDetail } from "@/types";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@@ -55,6 +55,9 @@ export default async function ProjectPage({ params }: PageProps) {
|
|||||||
|
|
||||||
const detail = project as unknown as ProjectDetail;
|
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 fuehrend = detail.repositories[0]?.snapshot;
|
||||||
const readme = fuehrend?.readme
|
const readme = fuehrend?.readme
|
||||||
? {
|
? {
|
||||||
@@ -116,6 +119,7 @@ export default async function ProjectPage({ params }: PageProps) {
|
|||||||
<GitPanel
|
<GitPanel
|
||||||
project={detail}
|
project={detail}
|
||||||
connections={connections as unknown as ConnectionSafe[]}
|
connections={connections as unknown as ConnectionSafe[]}
|
||||||
|
webhookRepoIds={webhookIds}
|
||||||
/>
|
/>
|
||||||
<ActivityFeed projectId={detail.id} initial={detail.activities} />
|
<ActivityFeed projectId={detail.id} initial={detail.activities} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
Lock,
|
Lock,
|
||||||
Package,
|
Package,
|
||||||
Plus,
|
Plus,
|
||||||
|
Radio,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
Star,
|
Star,
|
||||||
@@ -26,6 +27,7 @@ import {
|
|||||||
import { CiBadge } from "@/components/CiBadge";
|
import { CiBadge } from "@/components/CiBadge";
|
||||||
import { Heatmap } from "@/components/Heatmap";
|
import { Heatmap } from "@/components/Heatmap";
|
||||||
import { RepositoryDialog } from "@/components/RepositoryDialog";
|
import { RepositoryDialog } from "@/components/RepositoryDialog";
|
||||||
|
import { WebhookDialog } from "@/components/WebhookDialog";
|
||||||
import { Sparkline } from "@/components/Sparkline";
|
import { Sparkline } from "@/components/Sparkline";
|
||||||
import { repoTitle } from "@/lib/git/meta";
|
import { repoTitle } from "@/lib/git/meta";
|
||||||
import { asArray, asCalendar, asRecord } from "@/types";
|
import { asArray, asCalendar, asRecord } from "@/types";
|
||||||
@@ -55,9 +57,13 @@ type TabKey = (typeof TABS)[number]["key"];
|
|||||||
export function GitPanel({
|
export function GitPanel({
|
||||||
project,
|
project,
|
||||||
connections,
|
connections,
|
||||||
|
webhookRepoIds = [],
|
||||||
}: {
|
}: {
|
||||||
project: ProjectDetail;
|
project: ProjectDetail;
|
||||||
connections: ConnectionSafe[];
|
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 router = useRouter();
|
||||||
const [tab, setTab] = useState<TabKey>("pulls");
|
const [tab, setTab] = useState<TabKey>("pulls");
|
||||||
@@ -65,6 +71,7 @@ export function GitPanel({
|
|||||||
const [importing, setImporting] = useState(false);
|
const [importing, setImporting] = useState(false);
|
||||||
const [message, setMessage] = useState<{ ok: boolean; text: string } | null>(null);
|
const [message, setMessage] = useState<{ ok: boolean; text: string } | null>(null);
|
||||||
const [addOpen, setAddOpen] = useState(false);
|
const [addOpen, setAddOpen] = useState(false);
|
||||||
|
const [webhookOpen, setWebhookOpen] = useState(false);
|
||||||
|
|
||||||
const repositories = project.repositories;
|
const repositories = project.repositories;
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(repositories[0]?.id ?? null);
|
const [selectedId, setSelectedId] = useState<string | null>(repositories[0]?.id ?? null);
|
||||||
@@ -72,6 +79,7 @@ export function GitPanel({
|
|||||||
// Nach dem Entfernen zeigt die Auswahl ins Leere – dann auf das fuehrende
|
// Nach dem Entfernen zeigt die Auswahl ins Leere – dann auf das fuehrende
|
||||||
// zurueckfallen, statt eine leere Ansicht zu zeigen.
|
// zurueckfallen, statt eine leere Ansicht zu zeigen.
|
||||||
const repository = repositories.find((entry) => entry.id === selectedId) ?? repositories[0] ?? null;
|
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. */
|
/** Offene Issues als Aufgaben anlegen – ohne erneuten Abruf beim Provider. */
|
||||||
async function importIssues() {
|
async function importIssues() {
|
||||||
@@ -280,6 +288,24 @@ export function GitPanel({
|
|||||||
Repository
|
Repository
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setWebhookOpen(true)}
|
||||||
|
className={cn(
|
||||||
|
"rounded-lg p-2 transition-colors",
|
||||||
|
webhookAktiv
|
||||||
|
? "text-emerald-300 hover:bg-emerald-500/10"
|
||||||
|
: "text-slate-500 hover:bg-white/8 hover:text-violet-300",
|
||||||
|
)}
|
||||||
|
aria-label="Webhook einrichten"
|
||||||
|
title={
|
||||||
|
webhookAktiv
|
||||||
|
? "Webhook ist eingerichtet – meldet Aenderungen sofort"
|
||||||
|
: "Webhook einrichten – Aenderungen sofort statt beim naechsten Abgleich"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Radio className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => removeRepository(repository)}
|
onClick={() => removeRepository(repository)}
|
||||||
@@ -312,6 +338,16 @@ export function GitPanel({
|
|||||||
connections={connections}
|
connections={connections}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<WebhookDialog
|
||||||
|
open={webhookOpen}
|
||||||
|
onClose={() => setWebhookOpen(false)}
|
||||||
|
repositoryId={repository.id}
|
||||||
|
repositoryTitle={repoTitle(repository)}
|
||||||
|
provider={repository.connection?.provider ?? null}
|
||||||
|
eingerichtet={webhookAktiv}
|
||||||
|
zuletzt={repository.webhookLastAt}
|
||||||
|
/>
|
||||||
|
|
||||||
{message ? (
|
{message ? (
|
||||||
<p
|
<p
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|||||||
@@ -0,0 +1,244 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { Check, Copy, Loader2, Radio, Trash2 } from "lucide-react";
|
||||||
|
import type { GitProvider } from "@prisma/client";
|
||||||
|
import { Modal } from "@/components/ui/Modal";
|
||||||
|
import { formatRelative } from "@/lib/utils";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Einrichtung des Webhooks fuer ein Repository.
|
||||||
|
*
|
||||||
|
* Der Haken wird im Git-System von Hand eingetragen. Ihn ueber die API
|
||||||
|
* anzulegen waere machbar, verlangte aber Administrationsrechte am
|
||||||
|
* Repository – deutlich mehr, als Nebula sonst braucht, und es waere ein
|
||||||
|
* Recht, das die meisten Token gar nicht haben.
|
||||||
|
*
|
||||||
|
* Das Geheimnis ist genau einmal zu sehen: danach liegt nur noch die
|
||||||
|
* verschluesselte Fassung in der Datenbank, wie bei den Zugangstoken.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Wo der Haken im jeweiligen System einzutragen ist. */
|
||||||
|
const ANLEITUNG: Record<GitProvider, { pfad: string; ereignisse: string }> = {
|
||||||
|
GITHUB: {
|
||||||
|
pfad: "Settings → Webhooks → Add webhook",
|
||||||
|
ereignisse: "Content type „application/json“, Events: Pushes und Issues",
|
||||||
|
},
|
||||||
|
GITEA: {
|
||||||
|
pfad: "Einstellungen → Webhooks → Gitea",
|
||||||
|
ereignisse: "Auslöser: Push und Issues",
|
||||||
|
},
|
||||||
|
GITLAB: {
|
||||||
|
pfad: "Settings → Webhooks",
|
||||||
|
ereignisse: "Trigger: Push events und Issues events",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function WebhookDialog({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
repositoryId,
|
||||||
|
repositoryTitle,
|
||||||
|
provider,
|
||||||
|
eingerichtet,
|
||||||
|
zuletzt,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
repositoryId: string;
|
||||||
|
repositoryTitle: string;
|
||||||
|
provider: GitProvider | null;
|
||||||
|
eingerichtet: boolean;
|
||||||
|
zuletzt: Date | string | null;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [fehler, setFehler] = useState<string | null>(null);
|
||||||
|
const [geheimnis, setGeheimnis] = useState<string | null>(null);
|
||||||
|
const [url, setUrl] = useState<string | null>(null);
|
||||||
|
const [kopiert, setKopiert] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Ohne Verbindung ist das System unbekannt. Dann lieber allgemein bleiben,
|
||||||
|
// als eine Anleitung fuer das falsche zu zeigen.
|
||||||
|
const anleitung = provider ? ANLEITUNG[provider] : null;
|
||||||
|
|
||||||
|
async function erzeugen() {
|
||||||
|
setBusy(true);
|
||||||
|
setFehler(null);
|
||||||
|
try {
|
||||||
|
const antwort = await fetch(`/api/repositories/${repositoryId}/webhook`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
});
|
||||||
|
const nutzlast = await antwort.json().catch(() => ({}));
|
||||||
|
if (!antwort.ok) throw new Error(nutzlast.error ?? "Fehlgeschlagen.");
|
||||||
|
setGeheimnis(nutzlast.secret);
|
||||||
|
setUrl(nutzlast.url);
|
||||||
|
router.refresh();
|
||||||
|
} catch (gefangen) {
|
||||||
|
setFehler(gefangen instanceof Error ? gefangen.message : String(gefangen));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function zuruecknehmen() {
|
||||||
|
if (!window.confirm("Webhook zuruecknehmen? Eingehende Aufrufe werden dann abgewiesen.")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await fetch(`/api/repositories/${repositoryId}/webhook`, { method: "DELETE" });
|
||||||
|
setGeheimnis(null);
|
||||||
|
setUrl(null);
|
||||||
|
router.refresh();
|
||||||
|
onClose();
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function kopieren(wert: string, was: string) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(wert);
|
||||||
|
setKopiert(was);
|
||||||
|
setTimeout(() => setKopiert(null), 2000);
|
||||||
|
} catch {
|
||||||
|
// Ohne Zwischenablage bleibt der Text zum Markieren stehen.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
width="md"
|
||||||
|
title="Webhook einrichten"
|
||||||
|
description={`Damit meldet ${repositoryTitle} Aenderungen sofort, statt auf den naechsten Abgleich zu warten.`}
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{eingerichtet && !geheimnis ? (
|
||||||
|
<div className="rounded-lg border border-emerald-500/25 bg-emerald-500/8 px-3 py-2.5">
|
||||||
|
<p className="flex items-center gap-2 text-sm text-emerald-100">
|
||||||
|
<Radio className="h-4 w-4 shrink-0" />
|
||||||
|
Webhook ist eingerichtet.
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-emerald-300/70" suppressHydrationWarning>
|
||||||
|
{zuletzt
|
||||||
|
? `Zuletzt empfangen ${formatRelative(zuletzt)}.`
|
||||||
|
: "Bisher ist noch kein Aufruf angekommen — der Haken im Git-System fehlt vermutlich noch."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{geheimnis && url ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Feld label="URL" wert={url} kopiert={kopiert === "url"} onCopy={() => kopieren(url, "url")} />
|
||||||
|
<Feld
|
||||||
|
label="Geheimnis (Secret)"
|
||||||
|
wert={geheimnis}
|
||||||
|
kopiert={kopiert === "secret"}
|
||||||
|
onCopy={() => kopieren(geheimnis, "secret")}
|
||||||
|
/>
|
||||||
|
<p className="rounded-lg border border-amber-500/25 bg-amber-500/8 px-3 py-2 text-xs text-amber-200">
|
||||||
|
Das Geheimnis ist <strong>jetzt zum einzigen Mal</strong> zu sehen. Danach liegt es
|
||||||
|
nur noch verschluesselt in der Datenbank. Verloren? Dann hier ein neues erzeugen —
|
||||||
|
das alte gilt dann nicht mehr.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="rounded-lg border border-white/8 bg-white/2 px-3 py-2.5 text-xs text-slate-400">
|
||||||
|
<p className="font-semibold text-slate-300">Im Git-System eintragen</p>
|
||||||
|
{anleitung ? (
|
||||||
|
<>
|
||||||
|
<p className="mt-1">{anleitung.pfad}</p>
|
||||||
|
<p className="mt-0.5">{anleitung.ereignisse}</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="mt-1">
|
||||||
|
In den Einstellungen des Repositories unter „Webhooks“, mit den Ausloesern
|
||||||
|
Push und Issues. Ist eine Git-Verbindung hinterlegt, steht hier der genaue
|
||||||
|
Pfad fuer das jeweilige System.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="mt-2 text-slate-500">
|
||||||
|
Nebula wertet nur Push und Issues aus. Alles andere wird angenommen und liegen
|
||||||
|
gelassen — ein Fehler wuerde das Git-System sonst zum Wiederholen bringen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{fehler ? (
|
||||||
|
<p className="rounded-lg border border-rose-500/30 bg-rose-500/10 px-3 py-2 text-sm text-rose-200">
|
||||||
|
{fehler}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2 pt-1">
|
||||||
|
{eingerichtet ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-danger px-3 py-1.5 text-xs"
|
||||||
|
onClick={zuruecknehmen}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
Zuruecknehmen
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button type="button" className="btn btn-ghost" onClick={onClose} disabled={busy}>
|
||||||
|
{geheimnis ? "Fertig" : "Schliessen"}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-primary" onClick={erzeugen} disabled={busy}>
|
||||||
|
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <Radio className="h-4 w-4" />}
|
||||||
|
{eingerichtet ? "Neues Geheimnis" : "Einrichten"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Feld({
|
||||||
|
label,
|
||||||
|
wert,
|
||||||
|
kopiert,
|
||||||
|
onCopy,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
wert: string;
|
||||||
|
kopiert: boolean;
|
||||||
|
onCopy: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<span className="field-label">{label}</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{/* 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. */}
|
||||||
|
<code
|
||||||
|
tabIndex={0}
|
||||||
|
className="min-w-0 flex-1 overflow-x-auto rounded-lg border border-white/9 bg-[rgba(8,10,24,0.6)] px-3 py-2 font-mono text-xs text-slate-200"
|
||||||
|
>
|
||||||
|
{wert}
|
||||||
|
</code>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCopy}
|
||||||
|
className="btn btn-ghost shrink-0 px-2.5 py-2"
|
||||||
|
aria-label={`${label} kopieren`}
|
||||||
|
>
|
||||||
|
{kopiert ? <Check className="h-4 w-4 text-emerald-300" /> : <Copy className="h-4 w-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -41,6 +41,40 @@ export const KIND_META: Record<ChangeKind, { label: string; className: string }>
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const CHANGELOG: Release[] = [
|
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",
|
version: "1.6.0",
|
||||||
date: "2026-08-14",
|
date: "2026-08-14",
|
||||||
|
|||||||
+50
-1
@@ -65,17 +65,66 @@ export const connectionSafeSelect = Prisma.validator<Prisma.GitConnectionSelect>
|
|||||||
updatedAt: true,
|
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<Prisma.RepositorySelect>()({
|
||||||
|
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
|
* Ein Repository mitsamt Verbindung und Stand. Die Reihenfolge ist ueberall
|
||||||
* dieselbe: das fuehrende zuerst, denn auf der Karte ist nur fuer eines Platz.
|
* dieselbe: das fuehrende zuerst, denn auf der Karte ist nur fuer eines Platz.
|
||||||
*/
|
*/
|
||||||
export const repositoryInclude = Prisma.validator<Prisma.ProjectInclude>()({
|
export const repositoryInclude = Prisma.validator<Prisma.ProjectInclude>()({
|
||||||
repositories: {
|
repositories: {
|
||||||
include: { connection: { select: connectionSafeSelect }, snapshot: true },
|
select: repositorySafeSelect,
|
||||||
orderBy: [{ position: "asc" }, { createdAt: "asc" }],
|
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<string[]> {
|
||||||
|
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<Prisma.ProjectInclude>()({
|
export const projectListInclude = Prisma.validator<Prisma.ProjectInclude>()({
|
||||||
...repositoryInclude,
|
...repositoryInclude,
|
||||||
_count: { select: { notes: true, tasks: true } },
|
_count: { select: { notes: true, tasks: true } },
|
||||||
|
|||||||
@@ -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<string, string>): 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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=<hex>", HMAC-SHA256 ueber den Rumpf
|
||||||
|
* Gitea X-Gitea-Signature: "<hex>" 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" };
|
||||||
|
}
|
||||||
+13
-2
@@ -19,7 +19,13 @@ const SESSION_COOKIE = "nebula_session";
|
|||||||
|
|
||||||
/** Ohne Anmeldung erreichbar. */
|
/** Ohne Anmeldung erreichbar. */
|
||||||
const PUBLIC_PATHS = ["/login", "/setup", "/register"];
|
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
|
* Der Docker-Healthcheck hat kein Cookie. Manifest und Symbole braucht der
|
||||||
* Browser, bevor sich jemand angemeldet hat – wuerden sie zur Anmeldeseite
|
* 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";
|
const isDev = process.env.NODE_ENV === "development";
|
||||||
|
|
||||||
// ── 1. CSRF ────────────────────────────────────────────────
|
// ── 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(
|
return NextResponse.json(
|
||||||
{ error: "Anfrage von fremdem Ursprung abgelehnt." },
|
{ error: "Anfrage von fremdem Ursprung abgelehnt." },
|
||||||
{ status: 403 },
|
{ status: 403 },
|
||||||
|
|||||||
Reference in New Issue
Block a user