Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b60667633 | ||
|
|
a122187feb | ||
|
|
1fe41ac2a5 |
+6
-1
@@ -69,9 +69,14 @@ ADMINER_PORT="127.0.0.1:8080"
|
|||||||
# und Passkeys sind davon nicht betroffen.
|
# und Passkeys sind davon nicht betroffen.
|
||||||
APP_SECRET="bitte-aendern-mindestens-32-zeichen-langer-zufallswert"
|
APP_SECRET="bitte-aendern-mindestens-32-zeichen-langer-zufallswert"
|
||||||
|
|
||||||
# Lebensdauer einer Anmeldung in Tagen.
|
# Absolute Lebensdauer einer Anmeldung in Tagen – ab dem Anmelden gerechnet
|
||||||
|
# und nicht verlaengerbar.
|
||||||
SESSION_TTL_DAYS="30"
|
SESSION_TTL_DAYS="30"
|
||||||
|
|
||||||
|
# Leerlauffrist in Stunden: so lange darf eine Sitzung unbenutzt bleiben.
|
||||||
|
# Wird bei jeder Nutzung neu angestossen. 0 schaltet die Pruefung ab.
|
||||||
|
SESSION_IDLE_HOURS="8"
|
||||||
|
|
||||||
# ── Benachrichtigungen ───────────────────────────────────────
|
# ── Benachrichtigungen ───────────────────────────────────────
|
||||||
# ntfy und Webhook brauchen nichts weiter – die Ziele werden in der
|
# ntfy und Webhook brauchen nichts weiter – die Ziele werden in der
|
||||||
# Anwendung unter "Mein Konto" eingerichtet.
|
# Anwendung unter "Mein Konto" eingerichtet.
|
||||||
|
|||||||
+49
-3
@@ -22,8 +22,19 @@ sonst würden Konten ausgesperrt. Nach der Einrichtung ist `/setup` dauerhaft ge
|
|||||||
|
|
||||||
### 0.2 Anmeldung
|
### 0.2 Anmeldung
|
||||||
|
|
||||||
- **Benutzername und Passwort.** Mindestens 10 Zeichen, offensichtliche Passwörter werden
|
- **Benutzername und Passwort.** Mindestens 10 Zeichen. Abgelehnt werden naheliegende
|
||||||
abgelehnt. Hashing mit scrypt.
|
Passwörter, reine Ziffernfolgen, zu wenig verschiedene Zeichen (`abababab`),
|
||||||
|
durchlaufende Folgen (`abcdefgh`) und alles, was den eigenen Benutzernamen enthält.
|
||||||
|
**Keine Zusammensetzungsregeln** — ASVS 2.1.9 rät ausdrücklich davon ab, weil sie zu
|
||||||
|
`Passwort1!` führen statt zu Entropie.
|
||||||
|
- **Hashing mit scrypt**, `N = 2^17, r = 8, p = 1` nach dem OWASP Password Storage Cheat
|
||||||
|
Sheet (rund 0,5 s und 128 MiB je Prüfung). Die Parameter stehen im Hash, ältere bleiben
|
||||||
|
also lesbar — und werden bei der nächsten Anmeldung still auf den aktuellen Stand
|
||||||
|
gehoben. Das ist der einzige Moment, in dem das Klartextpasswort vorliegt.
|
||||||
|
- **Gleiche Antwort für alles Fehlgeschlagene.** Ob ein Konto existiert, gesperrt,
|
||||||
|
deaktiviert oder reines Passkey-Konto ist, sagt die Anmeldung erst, wenn das Passwort
|
||||||
|
stimmt. Davor gibt es genau einen Text und eine vergleichbare Antwortzeit
|
||||||
|
(`fakeVerifyDelay`), damit sich Benutzernamen nicht durchprobieren lassen.
|
||||||
- **Passkey (WebAuthn).** Fingerabdruck, Gesichtserkennung oder Sicherheitsschlüssel. Die
|
- **Passkey (WebAuthn).** Fingerabdruck, Gesichtserkennung oder Sicherheitsschlüssel. Die
|
||||||
Anmeldung läuft über auffindbare Credentials, ein Benutzername ist also nicht nötig.
|
Anmeldung läuft über auffindbare Credentials, ein Benutzername ist also nicht nötig.
|
||||||
Mehrere Passkeys pro Konto, benennbar und einzeln entfernbar.
|
Mehrere Passkeys pro Konto, benennbar und einzeln entfernbar.
|
||||||
@@ -518,7 +529,32 @@ soll jemand gewollt haben. Danach wandert der Zustand allerdings mit — erledig
|
|||||||
das Issue, ein Zurückziehen öffnet es wieder. Schlägt der Aufruf fehl, zieht die Aufgabe
|
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.
|
trotzdem um; ein nicht erreichbares Git-System darf das Abhaken nicht verhindern.
|
||||||
|
|
||||||
**Git → Nebula** über Webhooks, siehe unten.
|
**Git → Nebula** auf zwei Wegen, die sich ergänzen:
|
||||||
|
|
||||||
|
- **Webhook** — sofort, sobald das Issue geschlossen wird. Setzt voraus, dass die Instanz
|
||||||
|
von außen erreichbar ist und der Haken eingetragen wurde (siehe 4.7).
|
||||||
|
- **Beim Abgleich** — verzögert, aber verlässlich. Der Abgleich fragt den Zustand
|
||||||
|
verknüpfter Issues nach und hakt ab, was in Git geschlossen ist. Damit funktioniert der
|
||||||
|
Rückweg auch ohne Webhook, ohne öffentliche Erreichbarkeit und selbst dann, wenn ein
|
||||||
|
Haken einmal ausfällt.
|
||||||
|
|
||||||
|
Gespart wird, wo es geht: Nummern, die der gerade geholte Snapshot als offen führt,
|
||||||
|
werden nicht einzeln nachgefragt. Umgekehrt gilt das nicht — die Liste der offenen
|
||||||
|
Issues ist gedeckelt, aus dem Fehlen einer Nummer folgt also nichts. Pro Durchgang
|
||||||
|
fragt der Abgleich höchstens 25 Issues nach.
|
||||||
|
|
||||||
|
**Der Abgleich hakt nur ab, er nimmt nie zurück.** Er sieht Zustände, keine Ereignisse:
|
||||||
|
ein offenes Issue kann auch bedeuten, dass das Schließen von Nebula aus fehlgeschlagen
|
||||||
|
ist. Ein symmetrischer Abgleich würde dann eine bewusst erledigte Aufgabe wieder
|
||||||
|
aufmachen. Das Zurücknehmen bleibt dem Webhook vorbehalten, der ein echtes „reopened“
|
||||||
|
sieht.
|
||||||
|
|
||||||
|
Ein gelöschtes Issue ist kein erledigtes — die Aufgabe bleibt stehen.
|
||||||
|
|
||||||
|
In beiden Fällen bewirkt ein geschlossenes Issue dasselbe wie ein Haken in der
|
||||||
|
Oberfläche: bei einer wiederkehrenden Aufgabe entsteht die nächste Fassung, und der
|
||||||
|
Vorgang landet im Projektverlauf (ohne Benutzer — das hat nicht jemand in Nebula getan,
|
||||||
|
sondern das Git-System gemeldet).
|
||||||
|
|
||||||
**Die Zustände passen nicht aufeinander, und das bleibt so.** Nebula kennt
|
**Die Zustände passen nicht aufeinander, und das bleibt so.** Nebula kennt
|
||||||
`Offen · In Arbeit · Blockiert · Erledigt`, ein Issue nur offen oder geschlossen. Nach
|
`Offen · In Arbeit · Blockiert · Erledigt`, ein Issue nur offen oder geschlossen. Nach
|
||||||
@@ -760,6 +796,16 @@ nicht verloren gehen. Indizes auf Status, Verbindung und Änderungsdatum.
|
|||||||
`GIT_BLOCK_PRIVATE` und `GIT_ALLOWED_HOSTS`.
|
`GIT_BLOCK_PRIVATE` und `GIT_ALLOWED_HOSTS`.
|
||||||
- **Rate-Limiting** für Anmeldung (je IP und je Konto), ausgehende Git-Aufrufe und
|
- **Rate-Limiting** für Anmeldung (je IP und je Konto), ausgehende Git-Aufrufe und
|
||||||
WebAuthn-Optionen.
|
WebAuthn-Optionen.
|
||||||
|
- **Sitzungen**: Zufallstoken mit 256 Bit, in der Datenbank nur als SHA-256-Hash — wer die
|
||||||
|
Datenbank liest, kann keine Sitzung übernehmen. Geprüft wird bei jeder Anfrage, Abmelden
|
||||||
|
und Sperren wirken also sofort. Zwei Fristen nach ASVS 3.3.2: **absolut** 30 Tage ab
|
||||||
|
Anmeldung (`SESSION_TTL_DAYS`) und **im Leerlauf** 8 Stunden (`SESSION_IDLE_HOURS`).
|
||||||
|
Ein Passwortwechsel entwertet alle Sitzungen und stellt für das eigene Gerät eine neue
|
||||||
|
aus.
|
||||||
|
- **Cookies**: `HttpOnly`, `SameSite=Strict`, `Secure` sobald die App über HTTPS läuft, und
|
||||||
|
dann zusätzlich mit dem Präfix `__Host-`. Der Präfix lässt den Browser selbst erzwingen,
|
||||||
|
dass kein `Domain` gesetzt ist — eine andere Subdomain kann den Cookie damit nicht
|
||||||
|
überschreiben und keine Sitzung unterschieben.
|
||||||
- Access Tokens verschlüsselt mit **AES-256-GCM**, Schlüssel per **scrypt** aus
|
- Access Tokens verschlüsselt mit **AES-256-GCM**, Schlüssel per **scrypt** aus
|
||||||
`APP_SECRET` abgeleitet, Chiffrat versioniert (`v1.<iv>.<tag>.<data>`).
|
`APP_SECRET` abgeleitet, Chiffrat versioniert (`v1.<iv>.<tag>.<data>`).
|
||||||
- Tokens werden nie an den Browser gesendet — alle API-Antworten nutzen eine Feldauswahl
|
- Tokens werden nie an den Browser gesendet — alle API-Antworten nutzen eine Feldauswahl
|
||||||
|
|||||||
@@ -118,7 +118,8 @@ Alle Variablen sind in [`.env.example`](.env.example) dokumentiert.
|
|||||||
| `APP_URL` | Öffentliche Adresse — bestimmt die WebAuthn-RP-ID | `http://localhost:3000` |
|
| `APP_URL` | Öffentliche Adresse — bestimmt die WebAuthn-RP-ID | `http://localhost:3000` |
|
||||||
| `DATABASE_URL` | PostgreSQL-Verbindung (Prisma) | `postgresql://nebula:nebula@localhost:5432/nebula` |
|
| `DATABASE_URL` | PostgreSQL-Verbindung (Prisma) | `postgresql://nebula:nebula@localhost:5432/nebula` |
|
||||||
| `APP_SECRET` | Schlüssel für die Token-Verschlüsselung (AES-256-GCM) | — **unbedingt setzen** |
|
| `APP_SECRET` | Schlüssel für die Token-Verschlüsselung (AES-256-GCM) | — **unbedingt setzen** |
|
||||||
| `SESSION_TTL_DAYS` | Lebensdauer einer Anmeldung in Tagen | `30` |
|
| `SESSION_TTL_DAYS` | Absolute Lebensdauer einer Anmeldung in Tagen | `30` |
|
||||||
|
| `SESSION_IDLE_HOURS` | Nach so vielen Stunden ohne Nutzung läuft die Sitzung ab (`0` = aus) | `8` |
|
||||||
| `PORT` | Port der Anwendung | `3000` |
|
| `PORT` | Port der Anwendung | `3000` |
|
||||||
| `APP_PORT` | Host-Port-Mapping des App-Containers | `127.0.0.1:3000` |
|
| `APP_PORT` | Host-Port-Mapping des App-Containers | `127.0.0.1:3000` |
|
||||||
| `POSTGRES_USER` / `POSTGRES_PASSWORD` / `POSTGRES_DB` | Zugangsdaten des DB-Containers | `nebula` |
|
| `POSTGRES_USER` / `POSTGRES_PASSWORD` / `POSTGRES_DB` | Zugangsdaten des DB-Containers | `nebula` |
|
||||||
|
|||||||
+128
@@ -1,4 +1,13 @@
|
|||||||
import { expect, test, type APIRequestContext } from "@playwright/test";
|
import { expect, test, type APIRequestContext } from "@playwright/test";
|
||||||
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dieselbe Datenbank wie global-setup.ts. Gebraucht fuer den einen Fall, in
|
||||||
|
* dem ein Zustand herzustellen ist, den die API bewusst nicht anbietet.
|
||||||
|
*/
|
||||||
|
const DATABASE_URL =
|
||||||
|
process.env.E2E_DATABASE_URL ??
|
||||||
|
"postgresql://nebula:nebula@localhost:5432/nebula_e2e_test?schema=public";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tests ueber HTTP gegen die laufende Anwendung – also durch Middleware,
|
* Tests ueber HTTP gegen die laufende Anwendung – also durch Middleware,
|
||||||
@@ -279,6 +288,125 @@ test("Mehrere Repositories je Projekt", async ({ request, playwright }) => {
|
|||||||
expect(zumSchluss.repositories[0].repoName).toBe("swr");
|
expect(zumSchluss.repositories[0].repoName).toBe("swr");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("Die Anmeldung verraet nicht, welche Konten es gibt", async ({ request, playwright }) => {
|
||||||
|
await login(request, ADMIN);
|
||||||
|
|
||||||
|
// Ein Konto anlegen und stilllegen.
|
||||||
|
const angelegt = await request.post("/api/admin/users", {
|
||||||
|
data: {
|
||||||
|
username: "stillgelegt",
|
||||||
|
password: "ein-ausreichend-langes-passwort",
|
||||||
|
role: "USER",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(angelegt.status()).toBe(201);
|
||||||
|
const id = (await angelegt.json()).user.id;
|
||||||
|
expect((await request.patch(`/api/admin/users/${id}`, { data: { isActive: false } })).ok())
|
||||||
|
.toBeTruthy();
|
||||||
|
|
||||||
|
const anonym = await playwright.request.newContext({
|
||||||
|
baseURL: test.info().project.use.baseURL,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Falsches Passwort auf dem stillgelegten Konto ...
|
||||||
|
const stillgelegt = await anonym.post("/api/auth/login", {
|
||||||
|
data: { username: "stillgelegt", password: "falsches-passwort-hier" },
|
||||||
|
});
|
||||||
|
// ... und auf einem Konto, das es gar nicht gibt.
|
||||||
|
const erfunden = await anonym.post("/api/auth/login", {
|
||||||
|
data: { username: "gibtesnicht", password: "falsches-passwort-hier" },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Beides muss ununterscheidbar sein – sonst laesst sich durchprobieren,
|
||||||
|
// welche Benutzernamen vergeben sind. Frueher kam hier 403 gegen 401.
|
||||||
|
expect(stillgelegt.status()).toBe(erfunden.status());
|
||||||
|
expect(stillgelegt.status()).toBe(401);
|
||||||
|
expect(await stillgelegt.json()).toEqual(await erfunden.json());
|
||||||
|
|
||||||
|
// Mit dem richtigen Passwort darf die Anwendung dann Klartext reden.
|
||||||
|
const mitPasswort = await anonym.post("/api/auth/login", {
|
||||||
|
data: { username: "stillgelegt", password: "ein-ausreichend-langes-passwort" },
|
||||||
|
});
|
||||||
|
expect(mitPasswort.status()).toBe(403);
|
||||||
|
expect((await mitPasswort.json()).error).toMatch(/deaktiviert/);
|
||||||
|
|
||||||
|
await anonym.dispose();
|
||||||
|
await request.delete(`/api/admin/users/${id}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Abgleich hakt Aufgaben ab, deren Issue geschlossen wurde", async ({ request }) => {
|
||||||
|
await login(request, ADMIN);
|
||||||
|
|
||||||
|
// Ein oeffentliches Repository mit sicher geschlossenen Issues. Der
|
||||||
|
// Abgleich laeuft anonym gegen die GitHub-API – kein Token noetig.
|
||||||
|
const projekt = (
|
||||||
|
await (
|
||||||
|
await request.post("/api/projects", {
|
||||||
|
data: { name: "Rueckweg", repoUrl: "https://github.com/vercel/swr" },
|
||||||
|
})
|
||||||
|
).json()
|
||||||
|
).project;
|
||||||
|
const repoId = projekt.repositories[0].id;
|
||||||
|
|
||||||
|
const angelegt = await request.post(`/api/projects/${projekt.id}/tasks`, {
|
||||||
|
data: { title: "Haengt an einem geschlossenen Issue" },
|
||||||
|
});
|
||||||
|
const taskId = (await angelegt.json()).task.id;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die Verknuepfung direkt in der Datenbank setzen.
|
||||||
|
*
|
||||||
|
* Ueber die API gibt es dafuer keinen Weg, und das ist richtig so: "als
|
||||||
|
* Issue anlegen" wuerde ein neues Issue in einem fremden Repository
|
||||||
|
* erzeugen. Ein Endpunkt nur fuer Tests waere eine Hintertuer im
|
||||||
|
* Produktionscode – dafuer greift der Test lieber selbst zur Datenbank.
|
||||||
|
*
|
||||||
|
* Issue 1 von vercel/swr ist seit Jahren geschlossen.
|
||||||
|
*/
|
||||||
|
const prisma = new PrismaClient({
|
||||||
|
datasources: { db: { url: DATABASE_URL } },
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await prisma.task.update({
|
||||||
|
where: { id: taskId },
|
||||||
|
data: { sourceRepositoryId: repoId, sourceIssue: 1 },
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
const vorher = await (await request.get(`/api/projects/${projekt.id}`)).json();
|
||||||
|
const alsAngelegt = vorher.project.tasks.find(
|
||||||
|
(t: { id: string }) => t.id === taskId,
|
||||||
|
);
|
||||||
|
expect(alsAngelegt.sourceIssue).toBe(1);
|
||||||
|
expect(alsAngelegt.status).toBe("TODO");
|
||||||
|
|
||||||
|
// Abgleich anstossen – er fragt den Zustand des Issues nach.
|
||||||
|
const sync = await request.post(`/api/repositories/${repoId}/sync`);
|
||||||
|
const syncBody = await sync.json();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dieser Test greift als einziger wirklich nach draussen. Ist GitHub nicht
|
||||||
|
* erreichbar oder das anonyme Rate-Limit ausgeschoepft, sagt er das statt
|
||||||
|
* rot zu werden – ein fremder Dienst darf die Testreihe nicht bestimmen.
|
||||||
|
*/
|
||||||
|
if (!sync.ok()) {
|
||||||
|
test.skip(true, `GitHub nicht erreichbar: ${syncBody.message ?? sync.status()}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nachher = await (await request.get(`/api/projects/${projekt.id}`)).json();
|
||||||
|
const abgehakt = nachher.project.tasks.find((t: { id: string }) => t.id === taskId);
|
||||||
|
expect(abgehakt.status).toBe("DONE");
|
||||||
|
expect(abgehakt.doneAt).not.toBeNull();
|
||||||
|
|
||||||
|
// Und ein zweiter Lauf aendert nichts mehr – abgehakt bleibt abgehakt,
|
||||||
|
// ohne dass erneut nachgefragt wird.
|
||||||
|
const nochmal = await request.post(`/api/repositories/${repoId}/sync`);
|
||||||
|
expect((await nochmal.json()).message ?? "").not.toMatch(/abgehakt/);
|
||||||
|
});
|
||||||
|
|
||||||
test("Webhook: Signatur entscheidet, sonst passiert nichts", async ({ request, playwright }) => {
|
test("Webhook: Signatur entscheidet, sonst passiert nichts", async ({ request, playwright }) => {
|
||||||
await login(request, ADMIN);
|
await login(request, ADMIN);
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "nebula",
|
"name": "nebula",
|
||||||
"version": "1.7.0",
|
"version": "1.9.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": {
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ export async function POST(request: Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const weak = validatePasswordStrength(parsed.data.newPassword);
|
const weak = validatePasswordStrength(parsed.data.newPassword, user.username);
|
||||||
if (weak) {
|
if (weak) {
|
||||||
return NextResponse.json({ error: weak, fields: { newPassword: weak } }, { status: 422 });
|
return NextResponse.json({ error: weak, fields: { newPassword: weak } }, { status: 422 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ export async function PATCH(request: Request, { params }: Params) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (password) {
|
if (password) {
|
||||||
const weak = validatePasswordStrength(password);
|
const weak = validatePasswordStrength(password, target.username);
|
||||||
if (weak) {
|
if (weak) {
|
||||||
return NextResponse.json({ error: weak, fields: { password: weak } }, { status: 422 });
|
return NextResponse.json({ error: weak, fields: { password: weak } }, { status: 422 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ export async function POST(request: Request) {
|
|||||||
|
|
||||||
const { username, displayName, email, password, role } = parsed.data;
|
const { username, displayName, email, password, role } = parsed.data;
|
||||||
|
|
||||||
const weak = validatePasswordStrength(password);
|
const weak = validatePasswordStrength(password, username);
|
||||||
if (weak) {
|
if (weak) {
|
||||||
return NextResponse.json({ error: weak, fields: { password: weak } }, { status: 422 });
|
return NextResponse.json({ error: weak, fields: { password: weak } }, { status: 422 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { createPendingLogin, setMfaCookie } from "@/lib/auth/mfa";
|
import { createPendingLogin, setMfaCookie } from "@/lib/auth/mfa";
|
||||||
import { fakeVerifyDelay, verifyPassword } from "@/lib/auth/password";
|
import { fakeVerifyDelay, hashPassword, needsRehash, verifyPassword } from "@/lib/auth/password";
|
||||||
import { createSession, setSessionCookie } from "@/lib/auth/session";
|
import { createSession, setSessionCookie } from "@/lib/auth/session";
|
||||||
import { lockRemainingSeconds, noteFailedLogin, noteSuccessfulLogin } from "@/lib/auth/users";
|
import { lockRemainingSeconds, noteFailedLogin, noteSuccessfulLogin } from "@/lib/auth/users";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
@@ -42,6 +42,30 @@ export async function POST(request: Request) {
|
|||||||
return jsonError(GENERIC, 401);
|
return jsonError(GENERIC, 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erst das Passwort pruefen, dann ueber den Kontozustand reden.
|
||||||
|
*
|
||||||
|
* Frueher standen die Abfragen auf gesperrt, deaktiviert und
|
||||||
|
* "kein Passwort hinterlegt" **vor** der Pruefung und beantworteten sich
|
||||||
|
* damit ohne jede Kenntnis des Passworts. Wer einen Benutzernamen riet,
|
||||||
|
* bekam an der Antwort gesagt, ob es ihn gibt – genau die Aufzaehlbarkeit,
|
||||||
|
* die der einheitliche Text GENERIC sonst verhindert.
|
||||||
|
*
|
||||||
|
* Jetzt gilt: falsches Passwort → immer dieselbe Antwort. Die
|
||||||
|
* hilfreichere Auskunft bekommt nur, wer das Passwort kennt.
|
||||||
|
*/
|
||||||
|
const valid = user.passwordHash
|
||||||
|
? await verifyPassword(password, user.passwordHash)
|
||||||
|
: // Kein Passwort hinterlegt (reines Passkey-Konto): trotzdem Zeit
|
||||||
|
// verbrennen, damit sich der Fall nicht an der Antwortdauer zeigt.
|
||||||
|
await fakeVerifyDelay().then(() => false);
|
||||||
|
|
||||||
|
if (!valid) {
|
||||||
|
// Fehlversuche nur zaehlen, wo es ein Passwort zu erraten gibt.
|
||||||
|
if (user.passwordHash) await noteFailedLogin(user.id);
|
||||||
|
return jsonError(GENERIC, 401);
|
||||||
|
}
|
||||||
|
|
||||||
const locked = lockRemainingSeconds(user.lockedUntil);
|
const locked = lockRemainingSeconds(user.lockedUntil);
|
||||||
if (locked > 0) {
|
if (locked > 0) {
|
||||||
return jsonError(
|
return jsonError(
|
||||||
@@ -51,22 +75,20 @@ export async function POST(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!user.isActive) {
|
if (!user.isActive) {
|
||||||
await fakeVerifyDelay();
|
|
||||||
return jsonError("Dieses Konto ist deaktiviert.", 403);
|
return jsonError("Dieses Konto ist deaktiviert.", 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!user.passwordHash) {
|
/**
|
||||||
await fakeVerifyDelay();
|
* Der Aufwand beim Hashen ist gestiegen. Wer sich mit einem Passwort
|
||||||
return jsonError(
|
* anmeldet, dessen Hash noch nach den alten Vorgaben erzeugt wurde,
|
||||||
"Fuer dieses Konto ist kein Passwort hinterlegt – bitte mit Passkey anmelden.",
|
* bekommt hier still einen neuen – es ist der einzige Moment, in dem das
|
||||||
401,
|
* Klartextpasswort vorliegt. Nebenlaeufig, damit die Anmeldung nicht auf
|
||||||
);
|
* die halbe Sekunde wartet.
|
||||||
}
|
*/
|
||||||
|
if (needsRehash(user.passwordHash)) {
|
||||||
const valid = await verifyPassword(password, user.passwordHash);
|
void hashPassword(password)
|
||||||
if (!valid) {
|
.then((neu) => prisma.user.update({ where: { id: user.id }, data: { passwordHash: neu } }))
|
||||||
await noteFailedLogin(user.id);
|
.catch(() => undefined);
|
||||||
return jsonError(GENERIC, 401);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Das Passwort stimmt. Ist ein zweiter Faktor eingerichtet, entsteht hier
|
// Das Passwort stimmt. Ist ein zweiter Faktor eingerichtet, entsteht hier
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export async function POST(request: Request) {
|
|||||||
|
|
||||||
const { username, displayName, email, password } = parsed.data;
|
const { username, displayName, email, password } = parsed.data;
|
||||||
|
|
||||||
const weak = validatePasswordStrength(password);
|
const weak = validatePasswordStrength(password, username);
|
||||||
if (weak) {
|
if (weak) {
|
||||||
return NextResponse.json({ error: weak, fields: { password: weak } }, { status: 422 });
|
return NextResponse.json({ error: weak, fields: { password: weak } }, { status: 422 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export async function POST(request: Request) {
|
|||||||
|
|
||||||
const { username, displayName, email, password, mode } = parsed.data;
|
const { username, displayName, email, password, mode } = parsed.data;
|
||||||
|
|
||||||
const weak = validatePasswordStrength(password);
|
const weak = validatePasswordStrength(password, username);
|
||||||
if (weak) {
|
if (weak) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: weak, fields: { password: weak } },
|
{ error: weak, fields: { password: weak } },
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { tryDecryptSecret } from "@/lib/crypto";
|
|||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { rateLimit } from "@/lib/security/rateLimit";
|
import { rateLimit } from "@/lib/security/rateLimit";
|
||||||
import { syncRepository } from "@/lib/sync";
|
import { syncRepository } from "@/lib/sync";
|
||||||
import { statusForIssueState } from "@/lib/tasks/issues";
|
import { completeTaskFromIssue, statusForIssueState } from "@/lib/tasks/issues";
|
||||||
import { refreshDerivedProgress } from "@/lib/tasks";
|
import { refreshDerivedProgress } from "@/lib/tasks";
|
||||||
import { parseEvent, verifySignature } from "@/lib/webhooks/verify";
|
import { parseEvent, verifySignature } from "@/lib/webhooks/verify";
|
||||||
|
|
||||||
@@ -118,13 +118,19 @@ export async function POST(request: Request, { params }: Params) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ziel = statusForIssueState(ereignis.open, task.status);
|
const ziel = statusForIssueState(ereignis.open, task.status);
|
||||||
if (ziel !== task.status) {
|
|
||||||
|
if (ziel === "DONE" && task.status !== "DONE") {
|
||||||
|
// Ueber die gemeinsame Funktion, damit ein geschlossenes Issue
|
||||||
|
// dasselbe bewirkt wie ein Haken in der Oberflaeche: naechste Fassung
|
||||||
|
// einer wiederkehrenden Aufgabe und ein Eintrag im Verlauf.
|
||||||
|
await completeTaskFromIssue(task.id, "webhook");
|
||||||
|
} else if (ziel !== task.status) {
|
||||||
|
// Wieder geoeffnet. Das darf nur der Webhook, weil hier ein echtes
|
||||||
|
// "reopened" vorliegt – der Abgleich sieht nur Zustaende und wuerde
|
||||||
|
// sonst eine bewusst erledigte Aufgabe aufmachen.
|
||||||
await prisma.task.update({
|
await prisma.task.update({
|
||||||
where: { id: task.id },
|
where: { id: task.id },
|
||||||
data: {
|
data: { status: ziel, doneAt: null },
|
||||||
status: ziel,
|
|
||||||
doneAt: ziel === "DONE" ? new Date() : null,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
await refreshDerivedProgress(task.projectId);
|
await refreshDerivedProgress(task.projectId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,8 +85,16 @@ export default async function ProjectPage({ params }: PageProps) {
|
|||||||
progressFromTasks={detail.progressFromTasks}
|
progressFromTasks={detail.progressFromTasks}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/*
|
||||||
|
min-w-0 an beiden Spalten ist hier nicht Kosmetik, sondern der Kern:
|
||||||
|
ein Rasterkind hat "min-width: auto" und weigert sich damit, unter
|
||||||
|
die Inhaltsbreite seines breitesten Kindes zu schrumpfen. Die
|
||||||
|
Heatmap ist ueber 700px breit – auf dem Telefon zog sie damit die
|
||||||
|
ganze Spalte und mit ihr die Seite auseinander. Erst mit min-w-0
|
||||||
|
greift das overflow-x-auto darin ueberhaupt.
|
||||||
|
*/}
|
||||||
<div className="grid gap-6 lg:grid-cols-12">
|
<div className="grid gap-6 lg:grid-cols-12">
|
||||||
<div className="space-y-6 lg:col-span-5">
|
<div className="min-w-0 space-y-6 lg:col-span-5">
|
||||||
<section className="glass p-5">
|
<section className="glass p-5">
|
||||||
<h2 className="flex items-center gap-2 text-sm font-semibold uppercase tracking-wider text-slate-300">
|
<h2 className="flex items-center gap-2 text-sm font-semibold uppercase tracking-wider text-slate-300">
|
||||||
<FileText className="h-4 w-4 text-cyan-300" />
|
<FileText className="h-4 w-4 text-cyan-300" />
|
||||||
@@ -115,7 +123,7 @@ export default async function ProjectPage({ params }: PageProps) {
|
|||||||
<NotesPanel projectId={detail.id} notes={detail.notes} />
|
<NotesPanel projectId={detail.id} notes={detail.notes} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-6 lg:col-span-7">
|
<div className="min-w-0 space-y-6 lg:col-span-7">
|
||||||
<GitPanel
|
<GitPanel
|
||||||
project={detail}
|
project={detail}
|
||||||
connections={connections as unknown as ConnectionSafe[]}
|
connections={connections as unknown as ConnectionSafe[]}
|
||||||
|
|||||||
+2
-2
@@ -46,7 +46,7 @@ export function setMfaCookie(response: NextResponse, token: string) {
|
|||||||
name: MFA_COOKIE,
|
name: MFA_COOKIE,
|
||||||
value: token,
|
value: token,
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
sameSite: "lax",
|
sameSite: "strict",
|
||||||
secure: COOKIES_SECURE,
|
secure: COOKIES_SECURE,
|
||||||
path: "/",
|
path: "/",
|
||||||
maxAge: Math.floor(MFA_TTL_MS / 1000),
|
maxAge: Math.floor(MFA_TTL_MS / 1000),
|
||||||
@@ -58,7 +58,7 @@ export function clearMfaCookie(response: NextResponse) {
|
|||||||
name: MFA_COOKIE,
|
name: MFA_COOKIE,
|
||||||
value: "",
|
value: "",
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
sameSite: "lax",
|
sameSite: "strict",
|
||||||
secure: COOKIES_SECURE,
|
secure: COOKIES_SECURE,
|
||||||
path: "/",
|
path: "/",
|
||||||
maxAge: 0,
|
maxAge: 0,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { PASSWORD_MIN_LENGTH } from "./constants";
|
import { PASSWORD_MIN_LENGTH } from "./constants";
|
||||||
import { hashPassword, validatePasswordStrength, verifyPassword } from "./password";
|
import { hashPassword, needsRehash, validatePasswordStrength, verifyPassword } from "./password";
|
||||||
|
|
||||||
describe("hashPassword und verifyPassword", () => {
|
describe("hashPassword und verifyPassword", () => {
|
||||||
it("erkennt das richtige Passwort wieder", async () => {
|
it("erkennt das richtige Passwort wieder", async () => {
|
||||||
@@ -64,3 +64,82 @@ describe("validatePasswordStrength", () => {
|
|||||||
expect(validatePasswordStrength("a".repeat(300))).toMatch(/hoechstens/);
|
expect(validatePasswordStrength("a".repeat(300))).toMatch(/hoechstens/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Aufwand nach OWASP", () => {
|
||||||
|
it("hasht mit den heute vorgesehenen Parametern", async () => {
|
||||||
|
// OWASP Password Storage Cheat Sheet: N = 2^17, r = 8, p = 1.
|
||||||
|
const hash = await hashPassword("ein-langes-testpasswort");
|
||||||
|
const [, N, r, p] = hash.split("$");
|
||||||
|
expect(Number(N)).toBe(131072);
|
||||||
|
expect(Number(r)).toBe(8);
|
||||||
|
expect(Number(p)).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prueft alte Hashes weiterhin, weil die Parameter im Hash stehen", async () => {
|
||||||
|
// Von Hand mit den frueheren Werten erzeugt – muss gueltig bleiben,
|
||||||
|
// sonst waere ein Anheben des Aufwands eine Aussperrung.
|
||||||
|
const alt =
|
||||||
|
"scrypt$32768$8$1$" +
|
||||||
|
Buffer.from("salzsalzsalzsalz").toString("base64url") +
|
||||||
|
"$";
|
||||||
|
// Echten Hash mit alten Parametern bauen:
|
||||||
|
const { scrypt } = await import("node:crypto");
|
||||||
|
const { promisify } = await import("node:util");
|
||||||
|
const s = promisify(scrypt) as (a: string, b: Buffer, c: number, d: object) => Promise<Buffer>;
|
||||||
|
const salt = Buffer.from("salzsalzsalzsalz");
|
||||||
|
const derived = await s("ein-langes-testpasswort", salt, 64, {
|
||||||
|
N: 32768,
|
||||||
|
r: 8,
|
||||||
|
p: 1,
|
||||||
|
maxmem: 96 * 1024 * 1024,
|
||||||
|
});
|
||||||
|
const alterHash = `scrypt$32768$8$1$${salt.toString("base64url")}$${derived.toString("base64url")}`;
|
||||||
|
expect(alt.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
await expect(verifyPassword("ein-langes-testpasswort", alterHash)).resolves.toBe(true);
|
||||||
|
// ... und wird als erneuerungsbeduerftig erkannt.
|
||||||
|
expect(needsRehash(alterHash)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("laesst frische Hashes in Ruhe", async () => {
|
||||||
|
expect(needsRehash(await hashPassword("ein-langes-testpasswort"))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("erkennt gleichwertige Kombinationen als ausreichend", () => {
|
||||||
|
// N=2^16 mit p=2 ergibt denselben Gesamtaufwand wie N=2^17 mit p=1 und
|
||||||
|
// ist nach OWASP ebenso zulaessig.
|
||||||
|
expect(needsRehash("scrypt$65536$8$2$c2FsdA$aGFzaA")).toBe(false);
|
||||||
|
// N=2^15 mit p=1 dagegen nicht – das war der alte Zustand.
|
||||||
|
expect(needsRehash("scrypt$32768$8$1$c2FsdA$aGFzaA")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("haelt Unsinn fuer erneuerungsbeduerftig", () => {
|
||||||
|
expect(needsRehash("voelliger-unsinn")).toBe(true);
|
||||||
|
expect(needsRehash(null)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Schwache Muster", () => {
|
||||||
|
it("weist Wiederholungen ab", () => {
|
||||||
|
expect(validatePasswordStrength("aaaaaaaaaaaa")).toBeTruthy();
|
||||||
|
expect(validatePasswordStrength("abababababab")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("weist Zeichenfolgen ab", () => {
|
||||||
|
expect(validatePasswordStrength("abcdefghijkl")).toBeTruthy();
|
||||||
|
expect(validatePasswordStrength("87654321abcd")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("weist Passwoerter mit dem Benutzernamen ab", () => {
|
||||||
|
expect(validatePasswordStrength("chefin-geheimnis", "chefin")).toMatch(/Benutzernamen/);
|
||||||
|
// Ohne Benutzernamen ist dasselbe Passwort in Ordnung.
|
||||||
|
expect(validatePasswordStrength("chefin-geheimnis")).toBeNull();
|
||||||
|
// Sehr kurze Namen zaehlen nicht – sonst scheitert fast alles.
|
||||||
|
expect(validatePasswordStrength("ab-langes-passwort", "ab")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("laesst ein gutes Passwort mit Wiederholung durch", () => {
|
||||||
|
// Ein "ll" ist kein Grund zur Ablehnung, solange genug Vielfalt da ist.
|
||||||
|
expect(validatePasswordStrength("Rollladen-Fenster-42")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+122
-10
@@ -17,11 +17,24 @@ const scrypt = promisify(scryptCallback) as (
|
|||||||
options: { N: number; r: number; p: number; maxmem: number },
|
options: { N: number; r: number; p: number; maxmem: number },
|
||||||
) => Promise<Buffer>;
|
) => Promise<Buffer>;
|
||||||
|
|
||||||
const PARAMS = { N: 32768, r: 8, p: 1 };
|
/**
|
||||||
|
* Arbeitsaufwand nach der Empfehlung des OWASP Password Storage Cheat Sheet:
|
||||||
|
* N = 2^17, r = 8, p = 1. Frueher stand hier N = 2^15 mit p = 1 – das liegt
|
||||||
|
* unter *jeder* der dort genannten zulaessigen Kombinationen (zu 2^15
|
||||||
|
* gehoerte p = 3) und war damit rund dreimal billiger zu knacken als
|
||||||
|
* vorgesehen.
|
||||||
|
*
|
||||||
|
* Gemessen kostet das rund 0,5 s je Pruefung und 128 MiB. Der Speicher
|
||||||
|
* klingt nach viel, ist aber gedeckelt: Node rechnet scrypt im
|
||||||
|
* libuv-Threadpool, standardmaessig vier Faeden parallel. Mehr als rund
|
||||||
|
* 512 MiB koennen also nicht gleichzeitig belegt sein, und davor liegen
|
||||||
|
* noch die Ratenbegrenzungen je IP und je Benutzername.
|
||||||
|
*/
|
||||||
|
const PARAMS = { N: 131072, r: 8, p: 1 };
|
||||||
const KEY_LENGTH = 64;
|
const KEY_LENGTH = 64;
|
||||||
// scrypt braucht rund 128 * N * r Bytes; der Standardwert von 32 MB reicht
|
// scrypt braucht rund 128 * N * r Bytes – bei diesen Werten 128 MiB.
|
||||||
// dafuer nicht ganz.
|
// Etwas Luft nach oben, damit die Pruefung nicht am Deckel scheitert.
|
||||||
const MAX_MEM = 96 * 1024 * 1024;
|
const MAX_MEM = 192 * 1024 * 1024;
|
||||||
|
|
||||||
export async function hashPassword(password: string): Promise<string> {
|
export async function hashPassword(password: string): Promise<string> {
|
||||||
const salt = randomBytes(16);
|
const salt = randomBytes(16);
|
||||||
@@ -72,6 +85,30 @@ export async function verifyPassword(
|
|||||||
return timingSafeEqual(derived, expected);
|
return timingSafeEqual(derived, expected);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ist der gespeicherte Hash mit schwaecheren Parametern erzeugt worden als
|
||||||
|
* heute ueblich?
|
||||||
|
*
|
||||||
|
* Der Aufwand laesst sich nur fuer neue Passwoerter anheben – bestehende
|
||||||
|
* Hashes bleiben sonst fuer immer auf dem alten Stand. OWASP empfiehlt
|
||||||
|
* deshalb, bei der naechsten erfolgreichen Anmeldung still neu zu hashen.
|
||||||
|
* Das geht nur dort, weil das Klartextpasswort ausschliesslich in diesem
|
||||||
|
* Moment vorliegt.
|
||||||
|
*/
|
||||||
|
export function needsRehash(stored: string | null | undefined): boolean {
|
||||||
|
if (!stored) return false;
|
||||||
|
|
||||||
|
const parts = stored.split("$");
|
||||||
|
if (parts.length !== 6 || parts[0] !== "scrypt") return true;
|
||||||
|
|
||||||
|
const [N, r, p] = [Number(parts[1]), Number(parts[2]), Number(parts[3])];
|
||||||
|
if (!Number.isFinite(N) || !Number.isFinite(r) || !Number.isFinite(p)) return true;
|
||||||
|
|
||||||
|
// Verglichen wird der Gesamtaufwand, nicht Feld fuer Feld: N=2^16 mit p=2
|
||||||
|
// ist nach OWASP genauso zulaessig wie N=2^17 mit p=1.
|
||||||
|
return N * r * p < PARAMS.N * PARAMS.r * PARAMS.p;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Verbrennt vergleichbar viel Zeit wie eine echte Pruefung. Wird aufgerufen,
|
* Verbrennt vergleichbar viel Zeit wie eine echte Pruefung. Wird aufgerufen,
|
||||||
* wenn es den Benutzernamen gar nicht gibt – sonst liesse sich an der
|
* wenn es den Benutzernamen gar nicht gibt – sonst liesse sich an der
|
||||||
@@ -84,27 +121,102 @@ export async function fakeVerifyDelay(): Promise<void> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Naheliegende Passwoerter.
|
||||||
|
*
|
||||||
|
* ASVS 2.1.7 verlangt einen Abgleich gegen bekannte geleakte Passwoerter.
|
||||||
|
* Ein Dienst wie Have-I-Been-Pwned schiede dafuer aus – Nebula ruft
|
||||||
|
* grundsaetzlich nichts von aussen, und ein Passwort-Praefix nach draussen
|
||||||
|
* zu geben widerspraeche dem erst recht. Diese Liste ist der Kompromiss:
|
||||||
|
* die haeufigsten deutschen und englischen Kandidaten, dazu weiter unten
|
||||||
|
* Muster statt Einzelwoerter, was mehr abdeckt als jede Liste dieser Groesse.
|
||||||
|
*/
|
||||||
const OBVIOUS = new Set([
|
const OBVIOUS = new Set([
|
||||||
"password", "passwort", "12345678", "123456789", "1234567890",
|
// Klassiker
|
||||||
"qwertzuiop", "qwertyuiop", "administrator", "changeme", "letmein",
|
"password", "password1", "password123", "passwort", "passwort1",
|
||||||
"willkommen", "nebula123", "geheim123",
|
"passwort123", "kennwort", "geheim", "geheim123", "changeme", "letmein",
|
||||||
|
"welcome", "willkommen", "willkommen1", "administrator", "admin1234",
|
||||||
|
"iloveyou", "sunshine", "princess", "dragon", "monkey", "football",
|
||||||
|
"baseball", "superman", "trustno1", "starwars", "whatever", "freedom",
|
||||||
|
// Zahlen und Tastaturlaeufe
|
||||||
|
"12345678", "123456789", "1234567890", "87654321", "11111111",
|
||||||
|
"qwertzuiop", "qwertyuiop", "asdfghjkl", "qwertz123", "qwerty123",
|
||||||
|
"1qay2wsx", "1q2w3e4r", "1q2w3e4r5t", "asdf1234", "qwer1234",
|
||||||
|
// Deutsch
|
||||||
|
"sommer2024", "sommer2025", "sommer2026", "winter2024", "winter2025",
|
||||||
|
"winter2026", "fussball", "deutschland", "hallowelt", "sonnenschein",
|
||||||
|
"schatz123", "liebling", "blumen123", "internet", "computer",
|
||||||
|
// Projektnah
|
||||||
|
"nebula", "nebula123", "nebula2026", "dashboard", "vibecoding",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export { PASSWORD_MIN_LENGTH };
|
export { PASSWORD_MIN_LENGTH };
|
||||||
|
|
||||||
/** Gibt eine Fehlermeldung zurueck oder null, wenn das Passwort taugt. */
|
/**
|
||||||
export function validatePasswordStrength(password: string): string | null {
|
* Laenge der laengsten auf- oder absteigenden Zeichenfolge ("abcd", "4321").
|
||||||
|
*
|
||||||
|
* Zurueckgegeben wird die Laenge und nicht nur ein Ja/Nein: entscheidend ist,
|
||||||
|
* *wie viel* des Passworts aus der Folge besteht. Ein "de" mitten in einem
|
||||||
|
* Wort ist harmlos, "abcdefghijkl" ist das ganze Passwort.
|
||||||
|
*/
|
||||||
|
function laengsteFolge(wert: string): number {
|
||||||
|
if (wert.length < 2) return wert.length;
|
||||||
|
let beste = 1;
|
||||||
|
let auf = 1;
|
||||||
|
let ab = 1;
|
||||||
|
for (let i = 1; i < wert.length; i += 1) {
|
||||||
|
const schritt = wert.charCodeAt(i) - wert.charCodeAt(i - 1);
|
||||||
|
auf = schritt === 1 ? auf + 1 : 1;
|
||||||
|
ab = schritt === -1 ? ab + 1 : 1;
|
||||||
|
beste = Math.max(beste, auf, ab);
|
||||||
|
}
|
||||||
|
return beste;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gibt eine Fehlermeldung zurueck oder null, wenn das Passwort taugt.
|
||||||
|
*
|
||||||
|
* Bewusst **keine** Zusammensetzungsregeln ("mindestens ein Sonderzeichen").
|
||||||
|
* ASVS 2.1.9 raet ausdruecklich davon ab: sie treiben Leute zu "Passwort1!"
|
||||||
|
* und senken die Entropie, statt sie zu heben. Geprueft wird stattdessen
|
||||||
|
* auf Laenge und auf das, was nachweislich schwach ist.
|
||||||
|
*/
|
||||||
|
export function validatePasswordStrength(
|
||||||
|
password: string,
|
||||||
|
/** Benutzername des Kontos, falls bekannt – er darf nicht im Passwort stehen. */
|
||||||
|
username?: string | null,
|
||||||
|
): string | null {
|
||||||
if (password.length < PASSWORD_MIN_LENGTH) {
|
if (password.length < PASSWORD_MIN_LENGTH) {
|
||||||
return `Das Passwort muss mindestens ${PASSWORD_MIN_LENGTH} Zeichen haben.`;
|
return `Das Passwort muss mindestens ${PASSWORD_MIN_LENGTH} Zeichen haben.`;
|
||||||
}
|
}
|
||||||
if (password.length > 200) {
|
if (password.length > 200) {
|
||||||
return "Das Passwort darf hoechstens 200 Zeichen haben.";
|
return "Das Passwort darf hoechstens 200 Zeichen haben.";
|
||||||
}
|
}
|
||||||
if (OBVIOUS.has(password.toLowerCase())) {
|
|
||||||
|
const klein = password.toLowerCase();
|
||||||
|
|
||||||
|
if (OBVIOUS.has(klein)) {
|
||||||
return "Dieses Passwort ist zu naheliegend.";
|
return "Dieses Passwort ist zu naheliegend.";
|
||||||
}
|
}
|
||||||
if (/^\d+$/.test(password)) {
|
if (/^\d+$/.test(password)) {
|
||||||
return "Nur Ziffern reichen nicht – bitte Buchstaben oder Zeichen ergaenzen.";
|
return "Nur Ziffern reichen nicht – bitte Buchstaben oder Zeichen ergaenzen.";
|
||||||
}
|
}
|
||||||
|
// Zu wenig verschiedene Zeichen – deckt "aaaaaaaa" ebenso ab wie
|
||||||
|
// "abababab" oder "12121212", ohne dafuer je ein eigenes Muster zu
|
||||||
|
// brauchen.
|
||||||
|
if (new Set(klein).size <= 4) {
|
||||||
|
return "Das Passwort benutzt zu wenige verschiedene Zeichen.";
|
||||||
|
}
|
||||||
|
// Und wenn der groesste Teil des Passworts eine durchlaufende Folge ist.
|
||||||
|
if (laengsteFolge(klein) >= Math.max(6, Math.ceil(password.length * 0.6))) {
|
||||||
|
return "Zeichenfolgen wie „abcd“ oder „4321“ sind zu leicht zu raten.";
|
||||||
|
}
|
||||||
|
// Ein Passwort, das den Benutzernamen enthaelt, ist der erste Versuch
|
||||||
|
// jedes Angreifers.
|
||||||
|
const name = username?.trim().toLowerCase();
|
||||||
|
if (name && name.length >= 3 && klein.includes(name)) {
|
||||||
|
return "Das Passwort darf den Benutzernamen nicht enthalten.";
|
||||||
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
+33
-6
@@ -2,7 +2,12 @@ import { createHash, randomBytes } from "node:crypto";
|
|||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
import type { NextResponse } from "next/server";
|
import type { NextResponse } from "next/server";
|
||||||
import type { User } from "@prisma/client";
|
import type { User } from "@prisma/client";
|
||||||
import { COOKIES_SECURE, SESSION_COOKIE, SESSION_TTL_MS } from "@/lib/config";
|
import {
|
||||||
|
COOKIES_SECURE,
|
||||||
|
SESSION_COOKIE,
|
||||||
|
SESSION_IDLE_MS,
|
||||||
|
SESSION_TTL_MS,
|
||||||
|
} from "@/lib/config";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -68,7 +73,10 @@ export function setSessionCookie(response: NextResponse, token: string, expiresA
|
|||||||
name: SESSION_COOKIE,
|
name: SESSION_COOKIE,
|
||||||
value: token,
|
value: token,
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
sameSite: "lax",
|
// Strict statt Lax: Nebula hat keinen Einstieg von aussen, den eine
|
||||||
|
// fremde Seite anstossen duerfte. Der Preis ist, dass ein Klick von
|
||||||
|
// einer anderen Seite auf die Anmeldung fuehrt – ein Neuladen genuegt.
|
||||||
|
sameSite: "strict",
|
||||||
secure: COOKIES_SECURE,
|
secure: COOKIES_SECURE,
|
||||||
path: "/",
|
path: "/",
|
||||||
expires: expiresAt,
|
expires: expiresAt,
|
||||||
@@ -80,7 +88,10 @@ export function clearSessionCookie(response: NextResponse) {
|
|||||||
name: SESSION_COOKIE,
|
name: SESSION_COOKIE,
|
||||||
value: "",
|
value: "",
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
sameSite: "lax",
|
// Strict statt Lax: Nebula hat keinen Einstieg von aussen, den eine
|
||||||
|
// fremde Seite anstossen duerfte. Der Preis ist, dass ein Klick von
|
||||||
|
// einer anderen Seite auf die Anmeldung fuehrt – ein Neuladen genuegt.
|
||||||
|
sameSite: "strict",
|
||||||
secure: COOKIES_SECURE,
|
secure: COOKIES_SECURE,
|
||||||
path: "/",
|
path: "/",
|
||||||
maxAge: 0,
|
maxAge: 0,
|
||||||
@@ -109,11 +120,27 @@ export async function getSessionUser(): Promise<SessionUser | null> {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Leerlaufgrenze. Vorher wurde lastSeenAt zwar gefuehrt, aber nie
|
||||||
|
// ausgewertet – ein abgegriffener Cookie galt damit bis zum absoluten
|
||||||
|
// Ablauf, auch wenn das Konto wochenlang unberuehrt war.
|
||||||
|
const untaetig = Date.now() - session.lastSeenAt.getTime();
|
||||||
|
if (SESSION_IDLE_MS > 0 && untaetig > SESSION_IDLE_MS) {
|
||||||
|
await prisma.session.delete({ where: { id: session.id } }).catch(() => undefined);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
if (!session.user.isActive) return null;
|
if (!session.user.isActive) return null;
|
||||||
|
|
||||||
// Hoechstens einmal pro Stunde schreiben, damit nicht jede Anfrage die
|
/**
|
||||||
// Datenbank anfasst.
|
* lastSeenAt nachfuehren, aber nicht bei jeder Anfrage schreiben.
|
||||||
if (Date.now() - session.lastSeenAt.getTime() > 60 * 60 * 1000) {
|
*
|
||||||
|
* Das Intervall muss deutlich unter der Leerlaufgrenze liegen, sonst
|
||||||
|
* verkuerzt die Schreibbremse sie: bei stuendlichem Schreiben und einer
|
||||||
|
* Grenze von einer Stunde waere die Sitzung schon nach Sekunden faellig.
|
||||||
|
* Ein Zwanzigstel der Frist, hoechstens fuenf Minuten.
|
||||||
|
*/
|
||||||
|
const intervall = SESSION_IDLE_MS > 0 ? Math.min(SESSION_IDLE_MS / 20, 5 * 60 * 1000) : 60 * 60 * 1000;
|
||||||
|
if (untaetig > intervall) {
|
||||||
void prisma.session
|
void prisma.session
|
||||||
.update({ where: { id: session.id }, data: { lastSeenAt: new Date() } })
|
.update({ where: { id: session.id }, data: { lastSeenAt: new Date() } })
|
||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
|
|||||||
@@ -41,6 +41,66 @@ export const KIND_META: Record<ChangeKind, { label: string; className: string }>
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const CHANGELOG: Release[] = [
|
export const CHANGELOG: Release[] = [
|
||||||
|
{
|
||||||
|
version: "1.9.0",
|
||||||
|
date: "2026-08-17",
|
||||||
|
headline: "Geschlossene Issues haken ihre Aufgabe auch ohne Webhook ab.",
|
||||||
|
changes: [
|
||||||
|
{
|
||||||
|
kind: "neu",
|
||||||
|
text:
|
||||||
|
"Der Abgleich prüft jetzt den Zustand verknüpfter Issues: was in Git geschlossen " +
|
||||||
|
"wurde, wird in Nebula abgehakt. Bisher konnte das nur der Webhook — war keiner " +
|
||||||
|
"eingerichtet, blieb der Rückweg ganz aus.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "besser",
|
||||||
|
text:
|
||||||
|
"Ein über Git geschlossenes Issue bewirkt jetzt dasselbe wie ein Haken in der " +
|
||||||
|
"Oberfläche: bei wiederkehrenden Aufgaben entsteht die nächste Fassung, und der " +
|
||||||
|
"Vorgang steht im Projektverlauf.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: "1.8.0",
|
||||||
|
date: "2026-08-15",
|
||||||
|
headline: "Durchgang durch Anmeldung und Sitzungen nach OWASP.",
|
||||||
|
changes: [
|
||||||
|
{
|
||||||
|
kind: "besser",
|
||||||
|
text:
|
||||||
|
"Passwörter werden deutlich aufwendiger gehasht (scrypt mit N=2^17 statt 2^15) — " +
|
||||||
|
"das entspricht der Empfehlung des OWASP Password Storage Cheat Sheet. Bestehende " +
|
||||||
|
"Passwörter werden bei der nächsten Anmeldung still auf den neuen Stand gebracht.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "behoben",
|
||||||
|
text:
|
||||||
|
"Die Anmeldung verriet, welche Benutzernamen existieren: gesperrte, deaktivierte " +
|
||||||
|
"und reine Passkey-Konten antworteten anders als unbekannte — und das schon vor " +
|
||||||
|
"der Passwortprüfung. Jetzt gibt es die Auskunft erst, wenn das Passwort stimmt.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "neu",
|
||||||
|
text:
|
||||||
|
"Sitzungen laufen nach acht Stunden ohne Nutzung ab, zusätzlich zur bestehenden " +
|
||||||
|
"Frist von 30 Tagen. Einstellbar über SESSION_IDLE_HOURS; 0 schaltet es ab.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "besser",
|
||||||
|
text:
|
||||||
|
"Das Sitzungscookie trägt bei HTTPS den Präfix „__Host-“ und gilt nur noch " +
|
||||||
|
"SameSite=Strict. Beides muss einmalig neu angemeldet werden.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "besser",
|
||||||
|
text:
|
||||||
|
"Die Passwortprüfung erkennt jetzt Wiederholungen, Zeichenfolgen und Passwörter, " +
|
||||||
|
"die den eigenen Benutzernamen enthalten.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
version: "1.7.0",
|
version: "1.7.0",
|
||||||
date: "2026-08-15",
|
date: "2026-08-15",
|
||||||
|
|||||||
+32
-3
@@ -73,18 +73,47 @@ export const WEBAUTHN_AVAILABLE =
|
|||||||
/** Cookies nur ueber TLS ausliefern, wenn die App per https laeuft. */
|
/** Cookies nur ueber TLS ausliefern, wenn die App per https laeuft. */
|
||||||
export const COOKIES_SECURE = APP_URL_PARSED.protocol === "https:";
|
export const COOKIES_SECURE = APP_URL_PARSED.protocol === "https:";
|
||||||
|
|
||||||
/** Lebensdauer einer Sitzung. */
|
/**
|
||||||
|
* Absolute Lebensdauer einer Sitzung – ab Anmeldung, nicht verlaengerbar.
|
||||||
|
* Nach dieser Frist ist Schluss, egal wie aktiv jemand war.
|
||||||
|
*/
|
||||||
export const SESSION_TTL_DAYS = Number(process.env.SESSION_TTL_DAYS ?? 30);
|
export const SESSION_TTL_DAYS = Number(process.env.SESSION_TTL_DAYS ?? 30);
|
||||||
export const SESSION_TTL_MS = SESSION_TTL_DAYS * 24 * 60 * 60 * 1000;
|
export const SESSION_TTL_MS = SESSION_TTL_DAYS * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
export const SESSION_COOKIE = "nebula_session";
|
/**
|
||||||
|
* Leerlauffrist: so lange darf eine Sitzung unbenutzt bleiben.
|
||||||
|
*
|
||||||
|
* ASVS 3.3.2 verlangt beides – eine absolute *und* eine Leerlaufgrenze.
|
||||||
|
* Vorher gab es nur die absolute, ein abgegriffener Cookie galt also bis zu
|
||||||
|
* 30 Tage, unabhaengig davon, ob das Konto benutzt wurde.
|
||||||
|
*
|
||||||
|
* Acht Stunden sind der Kompromiss: ein Arbeitstag am Stueck bleibt
|
||||||
|
* angemeldet, ueber Nacht laeuft die Sitzung ab. Wem das zu streng ist,
|
||||||
|
* setzt SESSION_IDLE_HOURS hoch; 0 schaltet die Leerlaufgrenze ab.
|
||||||
|
*/
|
||||||
|
export const SESSION_IDLE_HOURS = Number(process.env.SESSION_IDLE_HOURS ?? 8);
|
||||||
|
export const SESSION_IDLE_MS =
|
||||||
|
SESSION_IDLE_HOURS > 0 ? SESSION_IDLE_HOURS * 60 * 60 * 1000 : 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Name des Sitzungscookies.
|
||||||
|
*
|
||||||
|
* Mit dem Praefix "__Host-" erzwingt der Browser selbst, was wir ohnehin
|
||||||
|
* setzen: Secure, Pfad "/" und kein Domain-Attribut. Letzteres ist der
|
||||||
|
* eigentliche Gewinn – eine andere Subdomain kann den Cookie dann nicht
|
||||||
|
* ueberschreiben und damit keine Sitzung unterschieben.
|
||||||
|
*
|
||||||
|
* Nur bei https, denn der Praefix verlangt zwingend Secure; ohne TLS wuerde
|
||||||
|
* der Browser den Cookie schlicht verwerfen.
|
||||||
|
*/
|
||||||
|
export const SESSION_COOKIE = COOKIES_SECURE ? "__Host-nebula_session" : "nebula_session";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cookie fuer die halbfertige Anmeldung zwischen Passwort und zweitem Faktor.
|
* Cookie fuer die halbfertige Anmeldung zwischen Passwort und zweitem Faktor.
|
||||||
* Kurzlebig – wer den Code nicht binnen fuenf Minuten eingibt, faengt von
|
* Kurzlebig – wer den Code nicht binnen fuenf Minuten eingibt, faengt von
|
||||||
* vorne an.
|
* vorne an.
|
||||||
*/
|
*/
|
||||||
export const MFA_COOKIE = "nebula_mfa";
|
export const MFA_COOKIE = COOKIES_SECURE ? "__Host-nebula_mfa" : "nebula_mfa";
|
||||||
export const MFA_TTL_MS = 5 * 60 * 1000;
|
export const MFA_TTL_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
/** Wird beim Start einmal geloggt, damit Fehlkonfiguration frueh auffaellt. */
|
/** Wird beim Start einmal geloggt, damit Fehlkonfiguration frueh auffaellt. */
|
||||||
|
|||||||
@@ -71,6 +71,15 @@ export class GiteaClient implements GitClient {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getIssueState(ref: RepoRef, number: number): Promise<"open" | "closed" | null> {
|
||||||
|
const issue = await getJson<{ state?: string } | null>(
|
||||||
|
`${this.repoBase(ref)}/issues/${number}`,
|
||||||
|
{ headers: this.headers(), allow404: true },
|
||||||
|
);
|
||||||
|
if (!issue?.state) return null;
|
||||||
|
return issue.state === "closed" ? "closed" : "open";
|
||||||
|
}
|
||||||
|
|
||||||
async check(): Promise<ConnectionCheck> {
|
async check(): Promise<ConnectionCheck> {
|
||||||
try {
|
try {
|
||||||
const version = await getJsonSafe<{ version?: string }>(
|
const version = await getJsonSafe<{ version?: string }>(
|
||||||
|
|||||||
@@ -70,6 +70,15 @@ export class GitHubClient implements GitClient {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getIssueState(ref: RepoRef, number: number): Promise<"open" | "closed" | null> {
|
||||||
|
const issue = await getJson<{ state?: string } | null>(
|
||||||
|
`${this.repoBase(ref)}/issues/${number}`,
|
||||||
|
{ headers: this.headers(), allow404: true },
|
||||||
|
);
|
||||||
|
if (!issue?.state) return null;
|
||||||
|
return issue.state === "closed" ? "closed" : "open";
|
||||||
|
}
|
||||||
|
|
||||||
async check(): Promise<ConnectionCheck> {
|
async check(): Promise<ConnectionCheck> {
|
||||||
try {
|
try {
|
||||||
if (!this.token) {
|
if (!this.token) {
|
||||||
|
|||||||
@@ -70,6 +70,16 @@ export class GitLabClient implements GitClient {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getIssueState(ref: RepoRef, number: number): Promise<"open" | "closed" | null> {
|
||||||
|
const issue = await getJson<{ state?: string } | null>(
|
||||||
|
`${this.apiBase}/projects/${this.projectPath(ref)}/issues/${number}`,
|
||||||
|
{ headers: this.headers(), allow404: true },
|
||||||
|
);
|
||||||
|
if (!issue?.state) return null;
|
||||||
|
// GitLab nennt den offenen Zustand "opened".
|
||||||
|
return issue.state === "closed" ? "closed" : "open";
|
||||||
|
}
|
||||||
|
|
||||||
async check(): Promise<ConnectionCheck> {
|
async check(): Promise<ConnectionCheck> {
|
||||||
try {
|
try {
|
||||||
if (!this.token) {
|
if (!this.token) {
|
||||||
|
|||||||
@@ -190,6 +190,17 @@ export interface GitClient {
|
|||||||
* geoeffnet – daher der Schalter statt zweier Methoden.
|
* geoeffnet – daher der Schalter statt zweier Methoden.
|
||||||
*/
|
*/
|
||||||
setIssueState(ref: RepoRef, number: number, open: boolean): Promise<void>;
|
setIssueState(ref: RepoRef, number: number, open: boolean): Promise<void>;
|
||||||
|
/**
|
||||||
|
* Liest den Zustand eines einzelnen Issues.
|
||||||
|
*
|
||||||
|
* Gebraucht fuer den Abgleich: der Snapshot fuehrt nur die *offenen*
|
||||||
|
* Issues und ist ausserdem gedeckelt – aus dem Fehlen einer Nummer laesst
|
||||||
|
* sich deshalb nicht schliessen, dass sie geschlossen wurde. Sie koennte
|
||||||
|
* ebenso geloescht oder einfach jenseits des Deckels sein.
|
||||||
|
*
|
||||||
|
* null heisst: gibt es nicht (mehr).
|
||||||
|
*/
|
||||||
|
getIssueState(ref: RepoRef, number: number): Promise<"open" | "closed" | null>;
|
||||||
/** Repos, auf die der Token Zugriff hat (fuer die Auswahl beim Verknuepfen). */
|
/** Repos, auf die der Token Zugriff hat (fuer die Auswahl beim Verknuepfen). */
|
||||||
listRepos(query?: string): Promise<RepoListItem[]>;
|
listRepos(query?: string): Promise<RepoListItem[]>;
|
||||||
/** Vollstaendiger Stand eines Repositories. */
|
/** Vollstaendiger Stand eines Repositories. */
|
||||||
|
|||||||
+22
-1
@@ -5,6 +5,7 @@ import { clientForConnection, createClient, parseRepoUrl } from "@/lib/git";
|
|||||||
import type { GitClient } from "@/lib/git/types";
|
import type { GitClient } from "@/lib/git/types";
|
||||||
import { notifyAfterSync } from "@/lib/notify";
|
import { notifyAfterSync } from "@/lib/notify";
|
||||||
import { repoTitle } from "@/lib/repositories";
|
import { repoTitle } from "@/lib/repositories";
|
||||||
|
import { reconcileLinkedIssues } from "@/lib/tasks/issues";
|
||||||
import type { RepoSnapshotData } from "@/lib/git/types";
|
import type { RepoSnapshotData } from "@/lib/git/types";
|
||||||
|
|
||||||
export interface SyncResult {
|
export interface SyncResult {
|
||||||
@@ -62,11 +63,31 @@ export async function syncRepository(repositoryId: string): Promise<SyncResult>
|
|||||||
// README hineingeschrieben werden koennen.
|
// README hineingeschrieben werden koennen.
|
||||||
await maybeRefreshCalendar(repository.id, client, ref);
|
await maybeRefreshCalendar(repository.id, client, ref);
|
||||||
await maybeRefreshReadme(repository.id, client, ref);
|
await maybeRefreshReadme(repository.id, client, ref);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verknuepfte Aufgaben nachziehen: was in Git geschlossen wurde, wird
|
||||||
|
* hier abgehakt. Der Webhook macht das sofort – ist keiner eingerichtet
|
||||||
|
* oder faellt er aus, greift der Abgleich.
|
||||||
|
*
|
||||||
|
* Die offenen Issues aus dem gerade geholten Snapshot gehen mit hinein:
|
||||||
|
* fuer jede Nummer, die dort steht, ist die Nachfrage gespart.
|
||||||
|
*/
|
||||||
|
const abgehakt = await reconcileLinkedIssues(
|
||||||
|
repository.id,
|
||||||
|
client,
|
||||||
|
ref,
|
||||||
|
data.issues.map((issue) => issue.number),
|
||||||
|
);
|
||||||
|
|
||||||
// Nach dem Speichern, damit die Meldung den neuen Stand sieht.
|
// Nach dem Speichern, damit die Meldung den neuen Stand sieht.
|
||||||
await notifyAfterSync(repository.id);
|
await notifyAfterSync(repository.id);
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
message: `Snapshot aktualisiert: ${data.branches.length} Branches, ${data.tags.length} Tags.`,
|
message:
|
||||||
|
`Snapshot aktualisiert: ${data.branches.length} Branches, ${data.tags.length} Tags.` +
|
||||||
|
(abgehakt > 0
|
||||||
|
? ` ${abgehakt} Aufgabe(n) abgehakt, weil das Issue geschlossen wurde.`
|
||||||
|
: ""),
|
||||||
fetchedAt: new Date().toISOString(),
|
fetchedAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import type { TaskStatus } from "@prisma/client";
|
import type { TaskStatus } from "@prisma/client";
|
||||||
|
import { recordActivity, shorten } from "@/lib/activity";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { clientForConnection } from "@/lib/git";
|
import { clientForConnection } from "@/lib/git";
|
||||||
|
import type { GitClient, RepoRef } from "@/lib/git/types";
|
||||||
|
import { nextPosition, refreshDerivedProgress, spawnRecurrence } from "@/lib/tasks";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Die Bruecke zwischen Nebula-Aufgabe und Git-Issue.
|
* Die Bruecke zwischen Nebula-Aufgabe und Git-Issue.
|
||||||
@@ -68,3 +71,122 @@ export async function mirrorStatusToIssue(
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hakt eine Aufgabe ab, weil ihr Issue geschlossen wurde.
|
||||||
|
*
|
||||||
|
* Bewusst an einer Stelle fuer beide Wege – Webhook und Abgleich. Vorher
|
||||||
|
* setzte der Webhook nur den Status und liess dabei aus, was die Oberflaeche
|
||||||
|
* tut: die naechste Fassung einer wiederkehrenden Aufgabe anlegen und den
|
||||||
|
* Vorgang in den Verlauf schreiben.
|
||||||
|
*
|
||||||
|
* Gibt false zurueck, wenn nichts zu tun war (Aufgabe fehlt oder ist schon
|
||||||
|
* erledigt) – der Aufrufer kann daran zaehlen, was er bewirkt hat.
|
||||||
|
*/
|
||||||
|
export async function completeTaskFromIssue(
|
||||||
|
taskId: string,
|
||||||
|
herkunft: "webhook" | "abgleich",
|
||||||
|
): Promise<boolean> {
|
||||||
|
const task = await prisma.task.findUnique({
|
||||||
|
where: { id: taskId },
|
||||||
|
select: { id: true, title: true, status: true, projectId: true, sourceIssue: true },
|
||||||
|
});
|
||||||
|
if (!task || task.status === "DONE") return false;
|
||||||
|
|
||||||
|
await prisma.task.update({
|
||||||
|
where: { id: task.id },
|
||||||
|
data: {
|
||||||
|
status: "DONE",
|
||||||
|
doneAt: new Date(),
|
||||||
|
position: await nextPosition(task.projectId, "DONE"),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wie beim Abhaken von Hand: eine wiederkehrende Aufgabe erzeugt ihre
|
||||||
|
// naechste Fassung.
|
||||||
|
const folge = await spawnRecurrence(task.id);
|
||||||
|
await refreshDerivedProgress(task.projectId);
|
||||||
|
|
||||||
|
await recordActivity({
|
||||||
|
projectId: task.projectId,
|
||||||
|
// Kein userId: das hat nicht ein Mensch in Nebula getan, sondern das
|
||||||
|
// Git-System gemeldet.
|
||||||
|
kind: "TASK_MOVED",
|
||||||
|
summary:
|
||||||
|
`Issue #${task.sourceIssue} geschlossen → „${shorten(task.title, 40)}“ erledigt` +
|
||||||
|
(herkunft === "abgleich" ? " (beim Abgleich bemerkt)" : "") +
|
||||||
|
(folge ? ", nächste angelegt" : ""),
|
||||||
|
meta: { taskId: task.id, issue: task.sourceIssue, source: herkunft },
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wie viele Issues ein Abgleich hoechstens einzeln nachfragt. */
|
||||||
|
const MAX_NACHFRAGEN = 25;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gleicht beim Abgleich die verknuepften Issues ab: was in Git geschlossen
|
||||||
|
* ist, wird in Nebula abgehakt.
|
||||||
|
*
|
||||||
|
* Warum zusaetzlich zum Webhook: der Webhook wirkt sofort, setzt aber voraus,
|
||||||
|
* dass die Instanz von aussen erreichbar ist und der Haken eingetragen wurde.
|
||||||
|
* Ist er das nicht – oder faellt er einmal aus –, bliebe der Rueckweg sonst
|
||||||
|
* ganz aus. Hier kommt er verzoegert, aber verlaesslich.
|
||||||
|
*
|
||||||
|
* **Nur abhaken, nie zuruecknehmen.** Der Abgleich sieht Zustaende, keine
|
||||||
|
* Ereignisse. Ein offenes Issue kann auch bedeuten, dass das Schliessen von
|
||||||
|
* Nebula aus fehlgeschlagen ist – dann wuerde ein symmetrischer Abgleich eine
|
||||||
|
* bewusst erledigte Aufgabe wieder aufmachen. Das Zuruecknehmen bleibt dem
|
||||||
|
* Webhook vorbehalten, der ein echtes "reopened" sieht.
|
||||||
|
*
|
||||||
|
* Fehler werden geschluckt: ein nicht erreichbares Issue darf den Abgleich
|
||||||
|
* des Repositories nicht scheitern lassen.
|
||||||
|
*/
|
||||||
|
export async function reconcileLinkedIssues(
|
||||||
|
repositoryId: string,
|
||||||
|
client: GitClient,
|
||||||
|
ref: RepoRef,
|
||||||
|
/** Nummern, die der Snapshot gerade als offen gemeldet hat. */
|
||||||
|
offeneNummern: number[] = [],
|
||||||
|
): Promise<number> {
|
||||||
|
const offen = new Set(offeneNummern);
|
||||||
|
|
||||||
|
const kandidaten = await prisma.task.findMany({
|
||||||
|
where: {
|
||||||
|
sourceRepositoryId: repositoryId,
|
||||||
|
sourceIssue: { not: null },
|
||||||
|
status: { not: "DONE" },
|
||||||
|
},
|
||||||
|
select: { id: true, sourceIssue: true },
|
||||||
|
orderBy: { createdAt: "asc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
let abgehakt = 0;
|
||||||
|
let nachfragen = 0;
|
||||||
|
|
||||||
|
for (const task of kandidaten) {
|
||||||
|
const nummer = task.sourceIssue;
|
||||||
|
if (nummer === null) continue;
|
||||||
|
|
||||||
|
// Steht die Nummer in der Liste der offenen Issues, ist nichts zu tun –
|
||||||
|
// und der Aufruf gespart. Umgekehrt gilt das nicht: die Liste ist
|
||||||
|
// gedeckelt, aus dem Fehlen folgt also nichts.
|
||||||
|
if (offen.has(nummer)) continue;
|
||||||
|
|
||||||
|
if (nachfragen >= MAX_NACHFRAGEN) break;
|
||||||
|
nachfragen += 1;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const zustand = await client.getIssueState(ref, nummer);
|
||||||
|
// null heisst "gibt es nicht mehr" – geloescht ist nicht erledigt,
|
||||||
|
// also bleibt die Aufgabe stehen.
|
||||||
|
if (zustand !== "closed") continue;
|
||||||
|
if (await completeTaskFromIssue(task.id, "abgleich")) abgehakt += 1;
|
||||||
|
} catch {
|
||||||
|
// Nichts erreichbar, nichts geaendert – der naechste Lauf versucht es.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return abgehakt;
|
||||||
|
}
|
||||||
|
|||||||
+12
-2
@@ -15,7 +15,17 @@ import { NextResponse, type NextRequest } from "next/server";
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
|
const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
|
||||||
const SESSION_COOKIE = "nebula_session";
|
|
||||||
|
/**
|
||||||
|
* Beide moeglichen Namen des Sitzungscookies.
|
||||||
|
*
|
||||||
|
* Bei https traegt er den Praefix "__Host-", sonst nicht (der Praefix
|
||||||
|
* verlangt zwingend Secure). Welcher gilt, entscheidet lib/config anhand
|
||||||
|
* der APP_URL – hier im Edge-Runtime wird schlicht auf beide geschaut. Es
|
||||||
|
* geht ohnehin nur um die grobe Vorpruefung; verbindlich entscheidet
|
||||||
|
* requireUserPage bzw. requireUserApi.
|
||||||
|
*/
|
||||||
|
const SESSION_COOKIES = ["__Host-nebula_session", "nebula_session"];
|
||||||
|
|
||||||
/** Ohne Anmeldung erreichbar. */
|
/** Ohne Anmeldung erreichbar. */
|
||||||
const PUBLIC_PATHS = ["/login", "/setup", "/register"];
|
const PUBLIC_PATHS = ["/login", "/setup", "/register"];
|
||||||
@@ -110,7 +120,7 @@ export function middleware(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── 2. Zugangsschranke ─────────────────────────────────────
|
// ── 2. Zugangsschranke ─────────────────────────────────────
|
||||||
const hasSession = Boolean(request.cookies.get(SESSION_COOKIE)?.value);
|
const hasSession = SESSION_COOKIES.some((name) => Boolean(request.cookies.get(name)?.value));
|
||||||
if (!hasSession && !isPublicPath(pathname)) {
|
if (!hasSession && !isPublicPath(pathname)) {
|
||||||
if (pathname.startsWith("/api/")) {
|
if (pathname.startsWith("/api/")) {
|
||||||
return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 });
|
return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 });
|
||||||
|
|||||||
Reference in New Issue
Block a user