7 Commits
Author SHA1 Message Date
florian.wildeandClaude Opus 5 47245152a2 docs: Anleitung fuer den Serverbetrieb und Update-Skript
CI / pruefen (push) Has been cancelled
DEPLOYMENT.md fuehrt Schritt fuer Schritt vom leeren Server zur Instanz
hinter TLS: Docker, Konfiguration, Reverse Proxy mit Caddy oder nginx,
Firewall, Sicherungen per Cron. Dazu, was an Haertung schon eingebaut ist -
und was der Betreiber selbst beisteuern muss.

scripts/update.sh macht die Schritte in der richtigen Reihenfolge: pruefen,
holen, sichern, bauen, starten, Gesundheit pruefen. Erst bauen und dann
umschalten, damit ein gescheiterter Bau die laufende Fassung nicht
mitnimmt; die Sicherung liegt vor jeder Schemaaenderung. Antwortet
/api/health nach einer Minute nicht, zeigt es die letzten Logzeilen und
nennt den Weg zurueck.

Es weigert sich zu laufen, solange APP_SECRET leer oder noch der
Entwicklungswert ist - mit diesem Wert liegen die Git-Tokens faktisch offen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 00:14:31 +02:00
florian.wildeandClaude Opus 5 d7176339b8 test: Bereinigung des Markdowns festhalten, Ratenbegrenzung nachziehen
Elf Tests halten fest, was nicht durchkommen darf: script-Elemente,
Ereignis-Attribute aus rohem HTML, javascript: als Link und als Bildquelle,
data:text/html. Dazu die Aufloesung relativer Verweise und der Fall
"Bild im Link", der kein a im a erzeugen darf.

Gerendert wird serverseitig - fuer die Frage nach dem erzeugten HTML
braucht es kein DOM. Damit .tsx ueberhaupt laeuft, muss Vitest das JSX
selbst uebersetzen; die tsconfig setzt jsx auf "preserve", weil sonst Next
zustaendig ist. Vite nimmt inzwischen oxc statt esbuild, die esbuild-Option
wuerde stillschweigend ignoriert.

Umsortieren im Brett und das Anlegen von Vorlagen hatten als einzige
schreibende Endpunkte keine Ratenbegrenzung.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 00:14:31 +02:00
florian.wildeandClaude Opus 5 df94646371 fix(security): Bildoptimierer abschalten
/_next/image war ohne Anmeldung erreichbar und antwortete mit 200. Die
Middleware nimmt den Pfad aus, weil dort sonst nur Rechenzeit verbraucht
wuerde - gedacht war das fuer ausgelieferte Dateien, nicht fuer einen
Endpunkt, der Bilder verarbeitet.

Er reicht jedes Bild durch sharp und damit durch libvips, fuer das es
regelmaessig Schwachstellen gibt; derzeit sind vier offen (CVE-2026-33327,
-33328, -35590, -35591). Ausnutzbar waere das hier nur mit einer Datei, die
schon auf dem Server liegt - hochladen kann man nichts. Aber Nebula benutzt
next/image ohnehin nirgends: eine Angriffsflaeche ohne Gegenwert.

Der Optimierer ist deshalb abgeschaltet, und der Pfad ist nicht mehr von der
Zugangsschranke ausgenommen. Ein Test haelt fest, dass er ohne Anmeldung
nicht mehr mit 200 antwortet.

Die Advisories zu postcss und sharp bleiben in npm audit stehen: Next pinnt
beide fest, auch in 16.2.12 - ein Versionssprung wuerde daran nichts
aendern. postcss laeuft nur zur Bauzeit ueber eigenes CSS.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 00:14:10 +02:00
florian.wildeandClaude Opus 5 27f51b6be8 fix(auth): Einmalkennwoerter nicht zweimal annehmen
Ein TOTP-Code blieb rund 90 Sekunden gueltig und liess sich darin beliebig
oft verwenden. RFC 6238 verlangt in Abschnitt 5.2 ausdruecklich das
Gegenteil: derselbe Code darf kein zweites Mal angenommen werden.

Ohne diese Sperre kann ein abgefangener Code - ueber die Schulter gesehen,
von einer weiterleitenden Phishing-Seite, von Schadsoftware vom Bildschirm
gelesen - die restliche Gueltigkeitsdauer ueber wiederverwendet werden.

verifyTotp gibt dafuer jetzt ueber matchTotp das Zeitfenster zurueck, das
gepasst hat; angenommen wird nur, was neuer ist als das zuletzt eingeloeste.
Der Vergleich bleibt dabei in konstanter Zeit - es wird weiterhin nicht
fruehzeitig abgebrochen.

Auch die Aktivierung verbrennt ihr Fenster: der Code, der eben
scharfgeschaltet hat, soll nicht Sekunden spaeter noch zum Anmelden taugen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 00:14:10 +02:00
florian.wildeandClaude Opus 5 72e5eb43c4 test: README-Hilfen abdecken, Doku, Version 1.4.0
CI / pruefen (push) Has been cancelled
Elf Tests: welche Dateinamen als README gelten, dass Markdown Vorrang hat,
dass an einer Zeilengrenze gekuerzt wird und dass base64 mit Umlauten und
Zeilenumbruechen richtig ankommt.

Im Browser gegen vercel/swr geprueft, dazu ein erfundenes README fuer die
relativen Links, die swr nicht hergibt: aufgeloeste Pfade, unveraenderte
absolute Links, Anker bleiben Anker, kein img im Dokument und kein a im a.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 23:41:42 +02:00
florian.wildeandClaude Opus 5 e97bd43b23 feat(ui): README auf der Projektseite anzeigen
Lange READMEs sind eingeklappt, ein Knopf zeigt alles. Kurze stehen gleich
vollstaendig da - ein Knopf, der nichts freigibt, waere nur Ballast.

Relative Links werden gegen das Verzeichnis der Datei im Repository
aufgeloest. Ohne das liefen sie ins Leere, denn im README steht "docs/x.md"
und nicht die volle Adresse.

Bilder werden nicht geladen, sondern als anklickbare Marke gezeigt. Die
Content-Security-Policy erlaubt img-src nur von der eigenen Instanz; sie
dafuer zu oeffnen hiesse, jedem fremden README das Nachladen beliebiger
Bilder zu gestatten - also auch Zaehlpixeln. Fuer Abzeichen und
Bildschirmfotos, in einer Vorschau ohnehin Beiwerk, ist das kein guter
Tausch.

Der Umbau passiert im Baum und nicht ueber eine Komponente, weil es auf den
Elternknoten ankommt: "[![Abzeichen](bild)](ziel)" ist in READMEs die Regel,
und ein a in einem a ist ungueltiges HTML. Der Browser zieht es beim Parsen
auseinander - der erste Versuch scheiterte prompt an einem
Hydrationsfehler. Innerhalb eines Links bleibt jetzt nur der Text stehen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 23:41:42 +02:00
florian.wildeandClaude Opus 5 f8e6c66876 feat(git): README des Repositories holen
Erspart den Wechsel ins Git, nur um zu sehen, worum ein halbvergessenes
Projekt eigentlich ging.

GitHub hat einen eigenen Endpunkt, der jede Schreibweise findet - ein Aufruf
genuegt. GitLab und Gitea kennen keinen; dort wird erst das
Wurzelverzeichnis aufgelistet und dann die gefundene Datei geholt. Erkannt
werden README.md und die ueblichen Varianten, Gross- und Kleinschreibung
egal; Markdown hat Vorrang, weil nur das wirklich dargestellt werden kann.

Geholt wird hoechstens einmal am Tag, wie der Kalender, und mit eigenem
Zeitstempel: schlaegt das eine fehl, soll das andere nicht mit blockiert
werden.

Bei 60 000 Zeichen wird abgeschnitten, aber an einer Zeilengrenze - sonst
bliebe womoeglich ein halber Codeblock stehen. Dekodiert wird ueber Buffer
und nicht ueber atob, sonst kaemen Umlaute als Kauderwelsch heraus.

Kein README ist eine Auskunft, kein Fehlschlag: der Zeitstempel wird dann
trotzdem gesetzt, damit nicht jeder Abgleich erneut sucht.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 23:41:18 +02:00
35 changed files with 1428 additions and 43 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ Dashboard für Vibe-Coding-Projekte. Next.js 15 (App Router), TypeScript,
Tailwind 4, Prisma, PostgreSQL. Oberfläche und Texte sind **deutsch**. Tailwind 4, Prisma, PostgreSQL. Oberfläche und Texte sind **deutsch**.
Funktionsumfang: [FEATURES.md](FEATURES.md) · Geplantes: [ROADMAP.md](ROADMAP.md) · Funktionsumfang: [FEATURES.md](FEATURES.md) · Geplantes: [ROADMAP.md](ROADMAP.md) ·
Betrieb: [README.md](README.md) Betrieb: [README.md](README.md) · Server: [DEPLOYMENT.md](DEPLOYMENT.md)
## Womit man arbeitet ## Womit man arbeitet
+342
View File
@@ -0,0 +1,342 @@
# Nebula auf einem Server betreiben
Schritt für Schritt: von der leeren Maschine bis zur laufenden Instanz hinter
TLS — und wie Updates danach ablaufen.
Für Installation zum Ausprobieren und die Entwicklungsvarianten siehe
[README.md](README.md). Hier geht es um den Dauerbetrieb.
---
## Was du brauchst
| | |
| --- | --- |
| Server | Linux mit Docker Engine ≥ 24 und Compose v2 |
| Arbeitsspeicher | 1 GB reichen; beim Bauen kurzzeitig ~2 GB |
| Plattenplatz | ~2 GB für Abbilder, dazu die Datenbank |
| Domain | Eine (Unter-)Domain, die auf den Server zeigt |
| TLS | Pflicht, wenn du Passkeys willst — siehe Schritt 6 |
> **Warum TLS Pflicht ist:** Passkeys verlangen einen sicheren Kontext.
> Über `http://` an einer LAN-IP funktionieren sie nicht. Passwort und
> Zwei-Faktor gehen auch ohne, aber dann läuft dein Passwort im Klartext
> übers Netz — betreibe Nebula nicht ohne TLS.
---
## 1 · Docker installieren
```bash
curl -fsSL https://get.docker.com | sh
sudo usermod -aG docker "$USER"
newgrp docker # oder ab- und wieder anmelden
docker --version && docker compose version
```
## 2 · Nebula holen
```bash
sudo mkdir -p /opt/nebula && sudo chown "$USER" /opt/nebula
git clone https://git.example.com/dein-name/nebula.git /opt/nebula
cd /opt/nebula
```
## 3 · Konfigurieren
```bash
node scripts/setup.mjs # legt .env an und würfelt ein APP_SECRET
```
Ohne Node auf dem Server: `cp .env.example .env` und das Geheimnis von Hand
setzen —
```bash
openssl rand -base64 48
```
Dann die `.env` öffnen und mindestens diese vier Werte anpassen:
```env
# Die Adresse, unter der du Nebula im Browser aufrufst.
# Bestimmt die WebAuthn-rpID — passt sie nicht, scheitern Passkeys.
APP_URL="https://nebula.deine-domain.de"
# Verschlüsselt die Git-Tokens. Lang, zufällig, niemals teilen.
APP_SECRET="…dein-langer-zufallswert…"
# Das Datenbankpasswort. Die Voreinstellung „nebula" ist für Entwicklung.
POSTGRES_PASSWORD="…noch-ein-langer-zufallswert…"
# Nur lokal lauschen — nach außen geht es über den Reverse Proxy (Schritt 6).
APP_PORT=127.0.0.1:3000
```
> **`APP_SECRET` später zu ändern** macht gespeicherte Git-Tokens unlesbar.
> Sie müssen dann neu hinterlegt werden. Konten, Passwörter und Passkeys
> bleiben unberührt.
>
> **`APP_URL` später zu ändern** macht bestehende **Passkeys ungültig**, weil
> sie an die rpID gebunden sind. Passwörter funktionieren weiter.
### Datenbankport schließen
`docker-compose.yml` bildet Postgres standardmäßig auf `5432` ab, damit man
in der Entwicklung von außen drankommt. Auf einem Server hat die Datenbank
nichts im Internet zu suchen:
```env
POSTGRES_PORT=127.0.0.1:5432
```
Noch besser: die `ports:`-Zeile des `db`-Dienstes ganz entfernen. Die
Anwendung erreicht die Datenbank über das interne Docker-Netz, nicht über
den Host.
## 4 · Starten
```bash
docker compose --profile full up -d --build
```
Der erste Bau dauert ein paar Minuten. Der Container spielt beim Start
selbst die Migrationen ein und wartet dabei auf die Datenbank.
```bash
docker compose --profile full logs -f app # mitlesen, Strg+C beendet nur das Lesen
curl -s localhost:3000/api/health # {"status":"ok","database":"up",…}
```
## 5 · Ersten Zugang anlegen
Ruf `https://nebula.deine-domain.de/setup` auf (nach Schritt 6) oder
vorübergehend über einen SSH-Tunnel:
```bash
ssh -L 3000:127.0.0.1:3000 benutzer@server
# dann lokal http://localhost:3000/setup öffnen
```
Dort legst du das Administratorkonto an und wählst die Betriebsart.
**Danach ist `/setup` dauerhaft gesperrt.**
Anschließend gleich unter *Mein Konto*:
1. **Passkey hinzufügen** — geht erst, wenn TLS steht
2. **Zwei-Faktor einrichten** und die Wiederherstellungscodes wegschreiben
## 6 · Reverse Proxy mit TLS
Nebula bringt keinen eigenen TLS-Abschluss mit. Beispiel mit **Caddy**, das
Zertifikate von selbst besorgt und erneuert:
```caddyfile
# /etc/caddy/Caddyfile
nebula.deine-domain.de {
reverse_proxy 127.0.0.1:3000
}
```
```bash
sudo systemctl reload caddy
```
Das genügt. Caddy setzt `X-Forwarded-*` von selbst, holt das Zertifikat bei
Let's Encrypt und leitet `http` auf `https` um.
<details>
<summary>Dasselbe mit nginx</summary>
```nginx
server {
listen 443 ssl http2;
server_name nebula.deine-domain.de;
ssl_certificate /etc/letsencrypt/live/nebula.deine-domain.de/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/nebula.deine-domain.de/privkey.pem;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
Zertifikat mit `certbot --nginx -d nebula.deine-domain.de`.
</details>
> Steht `APP_URL` auf `https://…`, setzt Nebula das `Secure`-Flag auf die
> Sitzungscookies. Läuft der Proxy auf `https`, die `APP_URL` aber noch auf
> `http`, funktioniert die Anmeldung — nur ohne dieses Flag. Also beides
> gleichziehen.
## 7 · Firewall
```bash
sudo ufw allow 22/tcp
sudo ufw allow 80,443/tcp
sudo ufw enable
```
Port 3000 und 5432 bleiben **zu**. Beide sind nach Schritt 3 ohnehin nur an
`127.0.0.1` gebunden; die Firewall ist der zweite Riegel.
## 8 · Sicherungen automatisieren
`scripts/backup.sh` legt einen `pg_dump` unter `backups/` ab. Täglich per
Cron:
```bash
crontab -e
```
```cron
# Jede Nacht um 3:15 sichern, 30 Tage aufheben
15 3 * * * cd /opt/nebula && ./scripts/backup.sh >> /var/log/nebula-backup.log 2>&1
30 3 * * * find /opt/nebula/backups -name '*.sql.gz' -mtime +30 -delete
```
Zurückspielen:
```bash
./scripts/restore.sh backups/nebula-2026-07-27-03-15-00.sql.gz
```
> Die Sicherungen liegen auf **demselben Server**. Das hilft gegen einen
> verpatzten Import, nicht gegen einen Plattenausfall. Zieh sie regelmäßig
> woandershin — `rsync`, `restic`, was du ohnehin nutzt.
---
## Updates
Dafür gibt es `scripts/update.sh`. Es macht alles in der richtigen
Reihenfolge und bricht ab, sobald ein Schritt scheitert:
```bash
cd /opt/nebula
./scripts/update.sh
```
Was es tut:
| Schritt | |
| --- | --- |
| **1 Prüfen** | docker, compose, `.env` da? Ist `APP_SECRET` gesetzt und nicht mehr der Entwicklungswert? |
| **2 Holen** | `git pull --ff-only`; zeigt die neuen Commits und fragt nach |
| **3 Sichern** | `pg_dump` **vor** jeder Schemaänderung |
| **4 Bauen** | Erst bauen, dann umschalten — ein gescheiterter Bau lässt die alte Fassung laufen |
| **5 Starten** | Container neu; die Migrationen laufen im Entrypoint |
| **6 Prüfen** | Bis zu 60 Sekunden auf `/api/health` warten |
Scheitert die Gesundheitsprüfung, zeigt das Skript die letzten Logzeilen und
nennt den Weg zurück.
```bash
./scripts/update.sh --yes # ohne Rückfrage, für Cron
./scripts/update.sh --ref v1.4.0 # auf eine bestimmte Fassung
./scripts/update.sh --no-pull # nur neu bauen, ohne git pull
```
### Wenn doch etwas schiefgeht
```bash
# Zurück auf den vorigen Stand (die Kennung nennt das Skript beim Abbruch)
git checkout <kennung>
./scripts/update.sh --no-pull --yes
# Nur wenn eine Migration die Daten beschädigt hat:
./scripts/restore.sh backups/<die-sicherung-von-vorhin>.sql.gz
```
> **Migrationen werden nicht zurückgerollt.** Eine Migration, die eine Spalte
> entfernt, ist nicht umkehrbar — deshalb sichert das Skript vorher. Steht
> ein Update mit Datenmigration an, lies vorher den Änderungsverlauf
> (Versionsnummer in der Fußzeile).
---
## Betrieb
### Zustand ansehen
```bash
docker compose --profile full ps
docker compose --profile full logs -f app
curl -s localhost:3000/api/health
```
### Neu starten
```bash
docker compose --profile full restart app
```
### Speicher- und Plattenverbrauch
```bash
docker stats --no-stream nebula-app nebula-db
docker system df
docker image prune -f # alte Abbilder nach mehreren Updates
```
### Automatischer Abgleich
Standardmäßig **aus**. Einzuschalten unter *Admin-Einstellungen →
Automatischer Abgleich*: Abstand, Repositories je Durchlauf, ob fertige
Projekte ausgelassen werden. Der Zeitgeber läuft im Serverprozess selbst —
kein zusätzlicher Container, kein Cron.
Wer lieber von außen taktet, lässt ihn aus und ruft
`POST /api/admin/sync/run` auf.
---
## Härtung, die schon eingebaut ist
Damit du weißt, worum du dich **nicht** kümmern musst:
- Der Container läuft als **unprivilegierter Benutzer** (`nextjs`, UID 1001),
nicht als root.
- **Content-Security-Policy** mit Nonce, `strict-dynamic` und ohne
`unsafe-inline` für Skripte.
- **CSRF-Prüfung** über `Sec-Fetch-Site`/`Origin` bei jedem schreibenden
Zugriff.
- **SSRF-Schutz** bei allen ausgehenden Aufrufen: jede Adresse wird
aufgelöst und gegen Loopback, Link-Local und Metadaten-Endpunkte geprüft,
Weiterleitungen eingeschlossen.
- **Ratenbegrenzung** auf Anmeldung, ausgehende Abrufe und schreibende
Zugriffe; Kontosperre nach acht Fehlversuchen.
- **Git-Tokens** liegen AES-256-GCM-verschlüsselt in der Datenbank und
verlassen den Server nie — auch nicht im Export.
- Der **Bildoptimierer** von Next ist abgeschaltet: Nebula benutzt ihn nicht,
und `/_next/image` wäre sonst eine Angriffsfläche ohne Gegenwert.
### Was du beisteuern musst
- **TLS** über den Reverse Proxy
- **`APP_SECRET`** lang und zufällig
- **Datenbankport** nicht nach außen öffnen
- **Sicherungen** wegschaffen
- **Systemupdates**: `unattended-upgrades` für das Betriebssystem,
`./scripts/update.sh` für Nebula
---
## Mehrbenutzerbetrieb
Nebula kann beides. Im Einzelbetrieb gibt es genau ein Konto und keine
Registrierung. Umschalten unter *Admin-Einstellungen*.
Wichtig zu wissen: **Projekte sind zwischen Konten hart getrennt.** Auch ein
Administrator sieht fremde Projekte nicht — er verwaltet Konten, keine
Inhalte. Wer gemeinsam an einem Projekt arbeiten will, ist mit Nebula falsch
beraten; das ist [bewusst nicht](ROADMAP.md) vorgesehen.
Die Selbstregistrierung ist standardmäßig aus. Lass sie aus, wenn die
Instanz aus dem Internet erreichbar ist und du nicht willst, dass sich
Fremde Konten anlegen.
+23
View File
@@ -299,6 +299,29 @@ App und Infrastruktur. Sie gehören trotzdem in **eine** Karte.
Der Projektdialog bearbeitet weiterhin nur das führende Repository; weitere verwaltet das Der Projektdialog bearbeitet weiterhin nur das führende Repository; weitere verwaltet das
Git-Panel. Beim Anlegen eines Projekts entsteht aus den Git-Feldern das erste. Git-Panel. Beim Anlegen eines Projekts entsteht aus den Git-Feldern das erste.
### 4.0a README-Vorschau
Das README des führenden Repositories steht auf der Projektseite — man muss nicht ins Git
wechseln, nur um zu sehen, worum ein halbvergessenes Projekt eigentlich ging. Lange
READMEs sind eingeklappt, ein Knopf zeigt alles.
- Geholt wird **höchstens einmal am Tag**, wie die Heatmap. GitHub hat einen eigenen
Endpunkt, der jede Schreibweise findet; bei GitLab und Gitea braucht es zwei Aufrufe
(Wurzelverzeichnis auflisten, dann die Datei holen).
- Erkannt werden `README.md` und die üblichen Varianten, Groß- und Kleinschreibung egal.
Markdown hat Vorrang — nur das lässt sich wirklich darstellen.
- Bei 60 000 Zeichen wird an einer **Zeilengrenze** abgeschnitten, damit kein halber
Codeblock stehen bleibt.
- **Relative Links** werden gegen das Verzeichnis der Datei im Repository aufgelöst, sonst
liefen sie ins Leere.
- **Bilder werden nicht geladen.** Die Content-Security-Policy erlaubt `img-src` nur von
der eigenen Instanz; sie dafür zu öffnen hieße, jedem fremden README das Nachladen
beliebiger Bilder zu gestatten — also auch Zählpixeln. Für Abzeichen und
Bildschirmfotos, die in einer Vorschau ohnehin Beiwerk sind, ist das kein guter Tausch.
Stattdessen steht ein anklickbarer Verweis mit dem Alternativtext.
- Der Inhalt stammt aus einem fremden Repository und läuft deshalb durch dieselbe
Bereinigung wie Notizen (`rehype-sanitize`).
### 4.1 Unterstützte Systeme ### 4.1 Unterstützte Systeme
| System | API | Authentifizierung | Besonderheit | | System | API | Authentifizierung | Besonderheit |
+2 -1
View File
@@ -31,7 +31,8 @@ Alle Daten liegen in **deiner lokalen PostgreSQL**. Es gibt keinen Cloud-Dienst
Access Tokens werden verschlüsselt abgelegt. Access Tokens werden verschlüsselt abgelegt.
Eine vollständige Auflistung aller Funktionen steht in **[FEATURES.md](FEATURES.md)**, Eine vollständige Auflistung aller Funktionen steht in **[FEATURES.md](FEATURES.md)**,
geplante Erweiterungen in **[ROADMAP.md](ROADMAP.md)**. geplante Erweiterungen in **[ROADMAP.md](ROADMAP.md)**. Für den Dauerbetrieb auf einem
Server — TLS, Firewall, Sicherungen und Updates — gibt es **[DEPLOYMENT.md](DEPLOYMENT.md)**.
--- ---
+4 -8
View File
@@ -20,20 +20,14 @@ erreichbar ist. Der Zeitplan bleibt als Rückfalllösung.
--- ---
### 1.2 README-Vorschau `S` ### 1.2 Läuft es noch? `M`
Das README aus dem Repo holen und auf der Projektseite anzeigen. Ein
zusätzlicher API-Aufruf, Markdown kann Nebula schon. Erspart den Wechsel ins
Git, nur um zu sehen, worum ein halbvergessenes Projekt eigentlich ging.
### 1.3 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.
Nebula wüsste dann, ob ein Projekt nicht nur existiert, sondern auch läuft. Nebula wüsste dann, ob ein Projekt nicht nur existiert, sondern auch läuft.
Nutzt Zeitgeber und Benachrichtigungen, die es schon gibt. Der SSRF-Schutz Nutzt Zeitgeber und Benachrichtigungen, die es schon gibt. Der SSRF-Schutz
greift dabei wie bei den Git-Aufrufen. greift dabei wie bei den Git-Aufrufen.
### 1.4 Persönliche API-Token `M` ### 1.3 Persönliche API-Token `M`
Derzeit geht die API nur mit Sitzungscookie. Ein Token je Konto würde Derzeit geht die API nur mit Sitzungscookie. Ein Token je Konto würde
`curl`-Aufrufe und Handy-Shortcuts erlauben — Idee aus dem Terminal anlegen, `curl`-Aufrufe und Handy-Shortcuts erlauben — Idee aus dem Terminal anlegen,
@@ -115,6 +109,8 @@ Damit klar ist, was **nicht** kommt und warum:
| **v1.1.0** Freie Reihenfolge im Brett, Mehrfachauswahl, installierbar als App, Änderungsverlauf | ✓ | | **v1.1.0** Freie Reihenfolge im Brett, Mehrfachauswahl, installierbar als App, Änderungsverlauf | ✓ |
| **v1.2.0** Commit-Heatmap über ein Jahr, Projektvorlagen | ✓ | | **v1.2.0** Commit-Heatmap über ein Jahr, Projektvorlagen | ✓ |
| **v1.3.0** Wiederkehrende Aufgaben, Barrierefreiheit nach WCAG 2.1 AA | ✓ | | **v1.3.0** Wiederkehrende Aufgaben, Barrierefreiheit nach WCAG 2.1 AA | ✓ |
| **v1.4.0** README-Vorschau auf der Projektseite | ✓ |
| **v1.5.0** Sicherheitsdurchsicht, Server-Anleitung und Update-Skript | ✓ |
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
+13
View File
@@ -532,6 +532,19 @@ test("Projektvorlagen: aus Projekt sichern und anwenden", async ({ request, play
expect((await request.delete(`/api/templates/${vorlage.id}`)).ok()).toBeTruthy(); expect((await request.delete(`/api/templates/${vorlage.id}`)).ok()).toBeTruthy();
}); });
test("Der Bildoptimierer ist zu", async ({ playwright }) => {
// /_next/image reicht jedes Bild durch sharp und damit durch libvips, fuer
// das es regelmaessig Schwachstellen gibt. Nebula benutzt next/image
// nirgends – der Endpunkt darf ohne Anmeldung nicht antworten.
const anonym = await playwright.request.newContext({
baseURL: test.info().project.use.baseURL,
maxRedirects: 0,
});
const antwort = await anonym.get("/_next/image?url=%2Ficon-192.png&w=64&q=75");
expect(antwort.status(), "kein 200 ohne Anmeldung").not.toBe(200);
await anonym.dispose();
});
test("Export enthaelt keine Geheimnisse, Import beachtet die Strategie", async ({ request }) => { test("Export enthaelt keine Geheimnisse, Import beachtet die Strategie", async ({ request }) => {
await login(request, ADMIN); await login(request, ADMIN);
+15
View File
@@ -9,6 +9,21 @@ const nextConfig: NextConfig = {
poweredByHeader: false, poweredByHeader: false,
// Prisma darf nicht gebundlet werden, sonst findet der Client seine Engine nicht. // Prisma darf nicht gebundlet werden, sonst findet der Client seine Engine nicht.
serverExternalPackages: ["@prisma/client", "prisma"], serverExternalPackages: ["@prisma/client", "prisma"],
images: {
/**
* Der Bildoptimierer ist abgeschaltet.
*
* Nebula benutzt next/image nirgends – die paar Symbole sind SVG und
* PNG in fester Groesse. Der Endpunkt /_next/image waere aber trotzdem
* da, und zwar ohne Anmeldung: die Middleware nimmt ihn aus, weil dort
* sonst nur Rechenzeit verbraucht wuerde.
*
* Er reicht jedes Bild durch sharp und damit durch libvips, fuer das es
* regelmaessig Schwachstellen gibt (aktuell CVE-2026-33327 und drei
* weitere). Eine Angriffsflaeche ohne Gegenwert – also weg damit.
*/
unoptimized: true,
},
}; };
export default nextConfig; export default nextConfig;
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "nebula", "name": "nebula",
"version": "1.3.0", "version": "1.5.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,5 @@
-- AlterTable
ALTER TABLE "RepoSnapshot" ADD COLUMN "readme" TEXT,
ADD COLUMN "readmeAt" TIMESTAMP(3),
ADD COLUMN "readmeBaseUrl" TEXT,
ADD COLUMN "readmePath" TEXT;
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "totpLastCounter" INTEGER;
+14
View File
@@ -128,6 +128,10 @@ model User {
/// sperrt sich aus, wer die Einrichtung abbricht. /// sperrt sich aus, wer die Einrichtung abbricht.
totpSecretCipher String? totpSecretCipher String?
totpEnabledAt DateTime? totpEnabledAt DateTime?
/// Zuletzt eingeloestes Zeitfenster. RFC 6238 verlangt, denselben Code
/// kein zweites Mal anzunehmen – ohne diesen Merker liesse sich ein
/// abgefangener Code die restliche Gueltigkeit ueber wiederverwenden.
totpLastCounter Int?
sessions Session[] sessions Session[]
credentials Credential[] credentials Credential[]
@@ -612,6 +616,16 @@ model RepoSnapshot {
commitCalendar Json @default("{}") commitCalendar Json @default("{}")
commitCalendarAt DateTime? commitCalendarAt DateTime?
/// Das README des Repositories, roh als Markdown. Wie der Kalender
/// hoechstens einmal am Tag geholt – es aendert sich selten, und der
/// Abgleich laeuft im Zweifel stuendlich.
readme String?
/// Dateiname, wie er im Repository steht ("README.md", "readme.rst" …).
readmePath String?
/// Wogegen relative Links im README aufzuloesen sind.
readmeBaseUrl String?
readmeAt DateTime?
// ── Letzter CI-Lauf auf dem Default-Branch ───────────────── // ── Letzter CI-Lauf auf dem Default-Branch ─────────────────
/// success | failure | running | pending | cancelled /// success | failure | running | pending | cancelled
ciState String? ciState String?
+163
View File
@@ -0,0 +1,163 @@
#!/usr/bin/env bash
# ─────────────────────────────────────────────────────────────
# Nebula auf einem Server aktualisieren.
#
# Holt den neuen Stand, sichert die Datenbank, baut das Abbild, spielt die
# Migrationen ein und startet neu. Bricht ab, sobald ein Schritt scheitert –
# und sagt dann, wie zurueckzukommen ist.
#
# Aufruf: ./scripts/update.sh
# ./scripts/update.sh --no-pull (nur neu bauen und starten)
# ./scripts/update.sh --ref v1.4.0 (auf einen Stand festlegen)
# ./scripts/update.sh --yes (ohne Rueckfrage)
#
# Rueckgabe 0 heisst: die neue Fassung laeuft und antwortet gesund.
# ─────────────────────────────────────────────────────────────
set -euo pipefail
root="$(cd "$(dirname "$0")/.." && pwd)"
cd "$root"
pull=1
ref=""
zusage=0
while [ $# -gt 0 ]; do
case "$1" in
--no-pull) pull=0 ;;
--ref) ref="${2:-}"; shift ;;
--yes|-y) zusage=1 ;;
-h|--help) sed -n '3,15p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
*) echo "Unbekannte Option: $1" >&2; exit 2 ;;
esac
shift
done
# ── Farben, aber nur wenn ein Terminal zusieht ───────────────
if [ -t 1 ]; then
rot=$'\033[31m'; gruen=$'\033[32m'; gelb=$'\033[33m'; fett=$'\033[1m'; aus=$'\033[0m'
else
rot=""; gruen=""; gelb=""; fett=""; aus=""
fi
schritt() { printf '\n%s▸ %s%s\n' "$fett" "$1" "$aus"; }
ok() { printf ' %s✓%s %s\n' "$gruen" "$aus" "$1"; }
warnung() { printf ' %s!%s %s\n' "$gelb" "$aus" "$1"; }
abbruch() { printf '\n%s✗ %s%s\n' "$rot" "$1" "$aus" >&2; exit 1; }
# ── Voraussetzungen ──────────────────────────────────────────
schritt "Voraussetzungen"
command -v docker >/dev/null || abbruch "docker nicht gefunden."
docker compose version >/dev/null 2>&1 || abbruch "docker compose (v2) nicht gefunden."
[ -f "$root/.env" ] || abbruch "Keine .env in $root. Zuerst 'node scripts/setup.mjs' ausfuehren."
[ -f "$root/docker-compose.yml" ] || abbruch "Kein docker-compose.yml in $root."
ok "docker, compose und .env vorhanden"
# APP_SECRET muss gesetzt sein – sonst liegen die Git-Tokens faktisch offen.
if grep -qE '^\s*APP_SECRET\s*=\s*("?)(nebula-dev-secret[^"]*|)\1\s*$' "$root/.env"; then
abbruch "APP_SECRET ist leer oder noch der Entwicklungswert. Bitte in der .env setzen."
fi
ok "APP_SECRET gesetzt"
vorher="$(git rev-parse --short HEAD 2>/dev/null || echo "unbekannt")"
version_vorher="$(node -p "require('./package.json').version" 2>/dev/null || echo "?")"
printf ' Aktuell: v%s (%s)\n' "$version_vorher" "$vorher"
# ── Neuen Stand holen ────────────────────────────────────────
if [ "$pull" -eq 1 ]; then
schritt "Neuen Stand holen"
if [ -n "$(git status --porcelain)" ]; then
abbruch "Das Arbeitsverzeichnis ist nicht sauber. Bitte erst committen oder verwerfen."
fi
git fetch --all --tags --quiet
if [ -n "$ref" ]; then
git checkout --quiet "$ref"
ok "Auf $ref gewechselt"
else
zweig="$(git rev-parse --abbrev-ref HEAD)"
git pull --ff-only --quiet origin "$zweig"
ok "$zweig aktualisiert"
fi
nachher="$(git rev-parse --short HEAD)"
version_nachher="$(node -p "require('./package.json').version" 2>/dev/null || echo "?")"
if [ "$vorher" = "$nachher" ]; then
warnung "Kein neuer Stand – es wird trotzdem neu gebaut."
else
printf ' Neu: v%s (%s)\n' "$version_nachher" "$nachher"
printf '\n Was dazukam:\n'
git --no-pager log --oneline --no-decorate "$vorher..$nachher" 2>/dev/null | sed 's/^/ /' || true
fi
else
warnung "Ohne --pull: der vorhandene Stand wird neu gebaut."
fi
# ── Rueckfrage ───────────────────────────────────────────────
if [ "$zusage" -eq 0 ] && [ -t 0 ]; then
printf '\n%sWeiter? Die Datenbank wird vorher gesichert. [j/N] %s' "$fett" "$aus"
read -r antwort
case "$antwort" in [jJyY]*) ;; *) abbruch "Abgebrochen." ;; esac
fi
# ── Datenbank sichern ────────────────────────────────────────
schritt "Datenbank sichern"
# Die Datenbank muss laufen, sonst gibt es nichts zu sichern.
docker compose up -d db >/dev/null
sicherung=""
if [ -x "$root/scripts/backup.sh" ]; then
ausgabe="$("$root/scripts/backup.sh" 2>&1)" || abbruch "Sicherung fehlgeschlagen:\n$ausgabe"
sicherung="$(printf '%s' "$ausgabe" | grep -oE '[^ ]*backups/[^ ]*\.sql[^ ]*' | tail -n 1 || true)"
ok "Gesichert${sicherung:+: $sicherung}"
else
warnung "scripts/backup.sh fehlt – es wird ohne Sicherung fortgefahren."
fi
# ── Bauen ────────────────────────────────────────────────────
schritt "Abbild bauen"
# Erst bauen, dann umschalten: ein Fehlschlag laesst die alte Fassung laufen.
docker compose --profile full build app || abbruch "Der Bau ist gescheitert. Die laufende Fassung ist unveraendert."
ok "Abbild gebaut"
# ── Starten ──────────────────────────────────────────────────
schritt "Starten"
# Die Migrationen laufen im entrypoint.sh, bevor der Server hochkommt.
docker compose --profile full up -d || abbruch "Der Start ist gescheitert."
ok "Container gestartet"
# ── Gesundheitspruefung ──────────────────────────────────────
schritt "Gesundheitspruefung"
port="$(sed -n 's/^[[:space:]]*APP_PORT[[:space:]]*=[[:space:]]*"\{0,1\}\([^"#]*\)"\{0,1\}[[:space:]]*$/\1/p' "$root/.env" | tail -n 1)"
port="${port:-3000}"
gesund=0
for versuch in $(seq 1 30); do
if curl -fsS "http://127.0.0.1:${port}/api/health" >/dev/null 2>&1; then
gesund=1
break
fi
sleep 2
done
if [ "$gesund" -ne 1 ]; then
printf '\n%sLetzte Zeilen aus dem Log:%s\n' "$fett" "$aus"
docker compose --profile full logs --tail 40 app || true
printf '\n%sZurueck zur vorigen Fassung:%s\n' "$fett" "$aus"
printf ' git checkout %s && ./scripts/update.sh --no-pull --yes\n' "$vorher"
[ -n "$sicherung" ] && printf ' Datenbank notfalls: ./scripts/restore.sh %s\n' "$sicherung"
abbruch "Die Anwendung antwortet nach 60 Sekunden nicht gesund."
fi
antwort_json="$(curl -fsS "http://127.0.0.1:${port}/api/health")"
ok "Gesund: $antwort_json"
# ── Fertig ───────────────────────────────────────────────────
version_jetzt="$(node -p "require('./package.json').version" 2>/dev/null || echo "?")"
printf '\n%s%s✓ Nebula v%s laeuft auf Port %s%s\n' "$gruen" "$fett" "$version_jetzt" "$port" "$aus"
[ -n "$sicherung" ] && printf ' Sicherung vor dem Update: %s\n' "$sicherung"
printf ' Log mitlesen: docker compose --profile full logs -f app\n\n'
+7 -4
View File
@@ -4,7 +4,7 @@ import { z } from "zod";
import { requireUserApi } from "@/lib/auth/guard"; import { requireUserApi } from "@/lib/auth/guard";
import { verifyPassword } from "@/lib/auth/password"; import { verifyPassword } from "@/lib/auth/password";
import { countUnusedRecoveryCodes, issueRecoveryCodes } from "@/lib/auth/recovery"; import { countUnusedRecoveryCodes, issueRecoveryCodes } from "@/lib/auth/recovery";
import { generateTotpSecret, groupSecret, otpauthUri, verifyTotp } from "@/lib/auth/totp"; import { generateTotpSecret, groupSecret, matchTotp, otpauthUri } from "@/lib/auth/totp";
import { APP_NAME } from "@/lib/config"; import { APP_NAME } from "@/lib/config";
import { encryptSecret, tryDecryptSecret } from "@/lib/crypto"; import { encryptSecret, tryDecryptSecret } from "@/lib/crypto";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
@@ -109,7 +109,8 @@ export async function PUT(request: Request) {
return jsonError("Es liegt kein Schluessel vor. Bitte die Einrichtung neu starten.", 409); return jsonError("Es liegt kein Schluessel vor. Bitte die Einrichtung neu starten.", 409);
} }
if (!verifyTotp(secret, parsed.data.code)) { const fenster = matchTotp(secret, parsed.data.code);
if (fenster === null) {
return jsonError( return jsonError(
"Der Code stimmt nicht. Geht die Uhr des Geraets richtig?", "Der Code stimmt nicht. Geht die Uhr des Geraets richtig?",
401, 401,
@@ -118,7 +119,9 @@ export async function PUT(request: Request) {
await prisma.user.update({ await prisma.user.update({
where: { id: auth.user.id }, where: { id: auth.user.id },
data: { totpEnabledAt: new Date() }, // Das Fenster gleich mitverbrennen: der Code, der eben aktiviert hat,
// soll nicht Sekunden spaeter noch zum Anmelden taugen.
data: { totpEnabledAt: new Date(), totpLastCounter: fenster },
}); });
return NextResponse.json({ return NextResponse.json({
@@ -157,7 +160,7 @@ export async function DELETE(request: Request) {
await prisma.$transaction([ await prisma.$transaction([
prisma.user.update({ prisma.user.update({
where: { id: auth.user.id }, where: { id: auth.user.id },
data: { totpSecretCipher: null, totpEnabledAt: null }, data: { totpSecretCipher: null, totpEnabledAt: null, totpLastCounter: null },
}), }),
prisma.recoveryCode.deleteMany({ where: { userId: auth.user.id } }), prisma.recoveryCode.deleteMany({ where: { userId: auth.user.id } }),
prisma.pendingLogin.deleteMany({ where: { userId: auth.user.id } }), prisma.pendingLogin.deleteMany({ where: { userId: auth.user.id } }),
+23 -4
View File
@@ -8,8 +8,9 @@ import {
} from "@/lib/auth/mfa"; } from "@/lib/auth/mfa";
import { redeemRecoveryCode } from "@/lib/auth/recovery"; import { redeemRecoveryCode } from "@/lib/auth/recovery";
import { createSession, setSessionCookie } from "@/lib/auth/session"; import { createSession, setSessionCookie } from "@/lib/auth/session";
import { verifyTotp } from "@/lib/auth/totp"; import { matchTotp } from "@/lib/auth/totp";
import { noteSuccessfulLogin } from "@/lib/auth/users"; import { noteSuccessfulLogin } from "@/lib/auth/users";
import { prisma } from "@/lib/db";
import { LIMITS, rateLimit } from "@/lib/security/rateLimit"; import { LIMITS, rateLimit } from "@/lib/security/rateLimit";
import { clientIp, jsonError, readJsonBody, tooManyRequests } from "@/lib/security/request"; import { clientIp, jsonError, readJsonBody, tooManyRequests } from "@/lib/security/request";
@@ -63,9 +64,19 @@ export async function POST(request: Request) {
// Wiederherstellungscode. Beide Wege zu probieren waere unnoetig – die // Wiederherstellungscode. Beide Wege zu probieren waere unnoetig – die
// Formate ueberschneiden sich nicht. // Formate ueberschneiden sich nicht.
const istZiffern = /^[\d\s]+$/.test(eingabe); const istZiffern = /^[\d\s]+$/.test(eingabe);
const erfolg = istZiffern
? Boolean(pending.user.totpSecret) && verifyTotp(pending.user.totpSecret!, eingabe) // Bei den Ziffern zaehlt zusaetzlich, dass das Zeitfenster neuer ist als
: await redeemRecoveryCode(pending.user.id, eingabe); // das zuletzt eingeloeste. Sonst liesse sich ein abgefangener Code die
// restliche Gueltigkeitsdauer ueber wiederverwenden – RFC 6238, 5.2.
const fenster =
istZiffern && pending.user.totpSecret
? matchTotp(pending.user.totpSecret, eingabe)
: null;
const frisch =
fenster !== null &&
(pending.user.totpLastCounter === null || fenster > pending.user.totpLastCounter);
const erfolg = istZiffern ? frisch : await redeemRecoveryCode(pending.user.id, eingabe);
if (!erfolg) { if (!erfolg) {
const weiterMoeglich = await noteMfaFailure(pending.id); const weiterMoeglich = await noteMfaFailure(pending.id);
@@ -83,6 +94,14 @@ export async function POST(request: Request) {
await discardPendingLogin(pending.id); await discardPendingLogin(pending.id);
await noteSuccessfulLogin(pending.user.id); await noteSuccessfulLogin(pending.user.id);
// Das eingeloeste Fenster merken, damit derselbe Code verbrannt ist.
if (fenster !== null) {
await prisma.user.update({
where: { id: pending.user.id },
data: { totpLastCounter: fenster },
});
}
const session = await createSession(pending.user.id, { const session = await createSession(pending.user.id, {
userAgent: request.headers.get("user-agent"), userAgent: request.headers.get("user-agent"),
ip, ip,
+9 -1
View File
@@ -3,7 +3,8 @@ import type { Prisma, ProjectStatus } from "@prisma/client";
import { describeStatus, recordActivities } from "@/lib/activity"; import { describeStatus, recordActivities } from "@/lib/activity";
import { requireUserApi } from "@/lib/auth/guard"; import { requireUserApi } from "@/lib/auth/guard";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { readJsonBody } from "@/lib/security/request"; import { LIMITS, rateLimit } from "@/lib/security/rateLimit";
import { clientIp, readJsonBody, tooManyRequests } from "@/lib/security/request";
import { formatZodError, projectReorderSchema } from "@/lib/validation"; import { formatZodError, projectReorderSchema } from "@/lib/validation";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -20,6 +21,13 @@ export async function PATCH(request: Request) {
const auth = await requireUserApi(); const auth = await requireUserApi();
if (!auth.ok) return auth.response; if (!auth.ok) return auth.response;
const limited = rateLimit(
`mutate:${clientIp(request)}`,
LIMITS.mutation.limit,
LIMITS.mutation.windowMs,
);
if (!limited.ok) return tooManyRequests(limited.retryAfter);
const body = await readJsonBody(request); const body = await readJsonBody(request);
if (!body.ok) return body.response; if (!body.ok) return body.response;
+9 -1
View File
@@ -1,7 +1,8 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { requireUserApi } from "@/lib/auth/guard"; import { requireUserApi } from "@/lib/auth/guard";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { jsonError, readJsonBody } from "@/lib/security/request"; import { LIMITS, rateLimit } from "@/lib/security/rateLimit";
import { clientIp, jsonError, readJsonBody, tooManyRequests } from "@/lib/security/request";
import { formatZodError, templateFromProjectSchema } from "@/lib/validation"; import { formatZodError, templateFromProjectSchema } from "@/lib/validation";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -21,6 +22,13 @@ export async function POST(request: Request) {
const auth = await requireUserApi(); const auth = await requireUserApi();
if (!auth.ok) return auth.response; if (!auth.ok) return auth.response;
const limited = rateLimit(
`mutate:${clientIp(request)}`,
LIMITS.mutation.limit,
LIMITS.mutation.windowMs,
);
if (!limited.ok) return tooManyRequests(limited.retryAfter);
const body = await readJsonBody(request); const body = await readJsonBody(request);
if (!body.ok) return body.response; if (!body.ok) return body.response;
+9 -1
View File
@@ -1,7 +1,8 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { requireUserApi } from "@/lib/auth/guard"; import { requireUserApi } from "@/lib/auth/guard";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { jsonError, readJsonBody } from "@/lib/security/request"; import { LIMITS, rateLimit } from "@/lib/security/rateLimit";
import { clientIp, jsonError, readJsonBody, tooManyRequests } from "@/lib/security/request";
import { formatZodError, templateCreateSchema } from "@/lib/validation"; import { formatZodError, templateCreateSchema } from "@/lib/validation";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -25,6 +26,13 @@ export async function POST(request: Request) {
const auth = await requireUserApi(); const auth = await requireUserApi();
if (!auth.ok) return auth.response; if (!auth.ok) return auth.response;
const limited = rateLimit(
`mutate:${clientIp(request)}`,
LIMITS.mutation.limit,
LIMITS.mutation.windowMs,
);
if (!limited.ok) return tooManyRequests(limited.retryAfter);
const body = await readJsonBody(request); const body = await readJsonBody(request);
if (!body.ok) return body.response; if (!body.ok) return body.response;
+24
View File
@@ -124,6 +124,30 @@
} }
.md a:hover { color: #ddd6fe; } .md a:hover { color: #ddd6fe; }
/* Bilder werden nicht geladen, sondern als Marke gezeigt – siehe die
Begruendung in Markdown.tsx. Damit sie nicht wie Fliesstext aussieht,
bekommt sie einen Rahmen und ein Symbol davor. */
.md .md-image {
display: inline-flex;
align-items: center;
gap: 0.3em;
border: 1px dashed rgba(255, 255, 255, 0.16);
border-radius: 0.4rem;
padding: 0.05em 0.45em;
font-size: 0.85em;
color: #8a98b0;
text-decoration: none;
vertical-align: baseline;
}
.md .md-image::before {
content: "▣";
opacity: 0.6;
}
.md a.md-image:hover {
color: #c4b5fd;
border-color: rgba(196, 181, 253, 0.4);
}
.md strong { color: #f1f5f9; font-weight: 600; } .md strong { color: #f1f5f9; font-weight: 600; }
.md ul, .md ol { margin: 0.75em 0; padding-left: 1.35em; } .md ul, .md ol { margin: 0.75em 0; padding-left: 1.35em; }
+26
View File
@@ -5,6 +5,7 @@ import { ActivityFeed } from "@/components/ActivityFeed";
import { GitPanel } from "@/components/GitPanel"; import { GitPanel } from "@/components/GitPanel";
import { Markdown } from "@/components/Markdown"; import { Markdown } from "@/components/Markdown";
import { NotesPanel } from "@/components/NotesPanel"; import { NotesPanel } from "@/components/NotesPanel";
import { ReadmePanel } from "@/components/ReadmePanel";
import { ProjectHeader } from "@/components/ProjectHeader"; import { ProjectHeader } from "@/components/ProjectHeader";
import { TaskBoard } from "@/components/tasks/TaskBoard"; import { TaskBoard } from "@/components/tasks/TaskBoard";
import { requireUserPage } from "@/lib/auth/guard"; import { requireUserPage } from "@/lib/auth/guard";
@@ -54,6 +55,20 @@ export default async function ProjectPage({ params }: PageProps) {
const detail = project as unknown as ProjectDetail; const detail = project as unknown as ProjectDetail;
const fuehrend = detail.repositories[0]?.snapshot;
const readme = fuehrend?.readme
? {
content: fuehrend.readme,
path: fuehrend.readmePath,
baseUrl: fuehrend.readmeBaseUrl,
// Die Basis endet auf dem Verzeichnis; angehaengt ergibt das die Datei.
fileUrl:
fuehrend.readmeBaseUrl && fuehrend.readmePath
? `${fuehrend.readmeBaseUrl}${fuehrend.readmePath.split("/").pop()}`
: (fuehrend.htmlUrl ?? null),
}
: null;
return ( return (
<div className="space-y-6 pt-4"> <div className="space-y-6 pt-4">
<ProjectHeader <ProjectHeader
@@ -83,6 +98,17 @@ export default async function ProjectPage({ params }: PageProps) {
)} )}
</section> </section>
{/* Das README des fuehrenden Repositories – bei mehreren waere eine
zweite Vorschau untereinander mehr Laenge als Nutzen. */}
{readme ? (
<ReadmePanel
content={readme.content}
path={readme.path}
baseUrl={readme.baseUrl}
fileUrl={readme.fileUrl}
/>
) : null}
<NotesPanel projectId={detail.id} notes={detail.notes} /> <NotesPanel projectId={detail.id} notes={detail.notes} />
</div> </div>
+90
View File
@@ -0,0 +1,90 @@
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { Markdown } from "./Markdown";
/**
* Markdown kommt aus zwei Quellen, denen nicht zu trauen ist: eigenen
* Notizen (die zwar der Benutzer schreibt, aber ohne Bereinigung ein
* XSS-Loch waeren) und dem README eines fremden Repositories.
*
* Diese Tests halten die Bereinigung fest. Sie rendern serverseitig, was
* fuer die Frage genuegt – es geht um das erzeugte HTML, nicht um
* Verhalten im Browser.
*/
const html = (text: string, baseUrl?: string) =>
renderToStaticMarkup(<Markdown baseUrl={baseUrl}>{text}</Markdown>);
describe("Bereinigung", () => {
it("laesst kein script durch", () => {
const ausgabe = html("<script>alert(1)</script>\n\nText danach.");
expect(ausgabe).not.toContain("<script");
expect(ausgabe).not.toContain("alert(1)");
expect(ausgabe).toContain("Text danach.");
});
it("entfernt Ereignis-Attribute aus rohem HTML", () => {
const ausgabe = html('<img src="x" onerror="alert(1)">');
expect(ausgabe).not.toContain("onerror");
expect(ausgabe).not.toContain("alert");
});
it("laesst kein javascript: als Link durch", () => {
const ausgabe = html("[klick](javascript:alert(1))");
expect(ausgabe).not.toContain("javascript:");
});
it("laesst kein javascript: ueber ein Bild durch", () => {
// Das Bild wird zu einem Link umgebaut – die Quelle darf dabei nicht
// unbesehen zum href werden.
const ausgabe = html("![x](javascript:alert(1))");
expect(ausgabe).not.toContain("javascript:");
});
it("laesst kein data: mit HTML durch", () => {
const ausgabe = html("[klick](data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==)");
expect(ausgabe).not.toContain("data:text/html");
});
it("haengt an jeden Link noopener und noreferrer", () => {
const ausgabe = html("[Ziel](https://example.com)");
expect(ausgabe).toContain('target="_blank"');
expect(ausgabe).toContain("noopener");
expect(ausgabe).toContain("noreferrer");
});
it("laedt keine Bilder, sondern zeigt eine Marke", () => {
// Die Content-Security-Policy erlaubt img-src nur von der eigenen
// Instanz; ein fremdes README duerfte sonst Zaehlpixel nachladen.
const ausgabe = html("![Abzeichen](https://example.com/badge.svg)");
expect(ausgabe).not.toContain("<img");
expect(ausgabe).toContain("md-image");
expect(ausgabe).toContain("Abzeichen");
});
});
describe("Relative Verweise", () => {
const BASIS = "https://git.example.com/o/r/blob/main/";
it("loest relative Links gegen die Basis auf", () => {
expect(html("[Doku](docs/x.md)", BASIS)).toContain(`${BASIS}docs/x.md`);
});
it("laesst absolute Links und Anker in Ruhe", () => {
expect(html("[Extern](https://example.com/a)", BASIS)).toContain("https://example.com/a");
expect(html("[Anker](#titel)", BASIS)).toContain('href="#titel"');
});
it("laesst ohne Basis alles unveraendert", () => {
expect(html("[Doku](docs/x.md)")).toContain('href="docs/x.md"');
});
it("erzeugt aus einem Bild im Link keinen Link im Link", () => {
// "[![Abzeichen](bild)](ziel)" ist in READMEs die Regel. Ein a im a ist
// ungueltiges HTML; der Browser zieht es auseinander, was zu einem
// Hydrationsfehler fuehrt – genau daran ist der erste Versuch gescheitert.
const ausgabe = html("[![Abzeichen](img/b.svg)](https://example.com/ziel)", BASIS);
expect(ausgabe).toContain("<span");
expect(ausgabe.match(/<a /g) ?? []).toHaveLength(1);
});
});
+94 -3
View File
@@ -24,12 +24,93 @@ const schema = {
}, },
}; };
/** Loest relative Verweise gegen die Basis auf; laesst absolute in Ruhe. */
function absolut(href: string | undefined, base: string | null | undefined): string | undefined {
if (!href || !base) return href;
if (/^[a-z][a-z0-9+.-]*:/i.test(href) || href.startsWith("//") || href.startsWith("#")) {
return href;
}
try {
return new URL(href, base).toString();
} catch {
return href;
}
}
type HastNode = {
type: string;
tagName?: string;
properties?: Record<string, unknown>;
children?: HastNode[];
value?: string;
};
/**
* Ersetzt Bilder durch eine anklickbare Marke – siehe die Begruendung
* unten am `.md-image`-Stil.
*
* Das passiert im Baum und nicht ueber eine Komponente, weil es auf den
* Elternknoten ankommt: `[![Abzeichen](bild)](ziel)` ist in READMEs die
* Regel, und ein `<a>` in einem `<a>` ist ungueltiges HTML. Der Browser
* zieht es beim Parsen auseinander, was zu einem Hydrationsfehler fuehrt –
* genau daran ist es beim ersten Versuch gescheitert. Innerhalb eines Links
* bleibt deshalb nur der Text stehen; der umgebende Link ist ohnehin das,
* was man anklicken will.
*/
function bilderAlsMarke(base: string | null | undefined) {
return () => (tree: HastNode) => {
const gehe = (knoten: HastNode, inLink: boolean) => {
const kinder = knoten.children;
if (!kinder) return;
for (let index = 0; index < kinder.length; index += 1) {
const kind = kinder[index];
if (kind.type === "element" && kind.tagName === "img") {
const quelle = typeof kind.properties?.src === "string" ? kind.properties.src : undefined;
const alt = typeof kind.properties?.alt === "string" ? kind.properties.alt.trim() : "";
const text: HastNode = { type: "text", value: alt || "Bild" };
const ziel = absolut(quelle, base);
kinder[index] =
inLink || !ziel
? { type: "element", tagName: "span", properties: { className: ["md-image"] }, children: [text] }
: {
type: "element",
tagName: "a",
properties: {
className: ["md-image"],
href: ziel,
title: ziel,
target: "_blank",
rel: "noreferrer noopener nofollow",
},
children: [text],
};
continue;
}
gehe(kind, inLink || (kind.type === "element" && kind.tagName === "a"));
}
};
gehe(tree, false);
};
}
export function Markdown({ export function Markdown({
children, children,
className, className,
baseUrl,
}: { }: {
children: string | null | undefined; children: string | null | undefined;
className?: string; className?: string;
/**
* Wogegen relative Verweise aufzuloesen sind. Gesetzt beim README aus
* einem Repository – dort zeigen Links auf Dateien daneben, die ohne
* Basis ins Leere liefen.
*/
baseUrl?: string | null;
}) { }) {
if (!children?.trim()) return null; if (!children?.trim()) return null;
@@ -37,12 +118,22 @@ export function Markdown({
<div className={cn("md", className)}> <div className={cn("md", className)}>
<ReactMarkdown <ReactMarkdown
remarkPlugins={[remarkGfm]} remarkPlugins={[remarkGfm]}
rehypePlugins={[[rehypeSanitize, schema]]} // Erst bereinigen, dann umbauen: die Marke soll nicht selbst durch
// die Bereinigung laufen und dabei ihre Klassen verlieren.
rehypePlugins={[[rehypeSanitize, schema], bilderAlsMarke(baseUrl)]}
components={{ components={{
// Links aus fremdem Inhalt immer in neuem Tab und ohne // Links aus fremdem Inhalt immer in neuem Tab und ohne
// Zugriff auf das oeffnende Fenster. // Zugriff auf das oeffnende Fenster.
a: ({ href, children: content }) => ( // className muss durchgereicht werden, sonst verlieren die
<a href={href} target="_blank" rel="noreferrer noopener nofollow"> // Bildmarken aus dem Umbau oben ihre Gestalt.
a: ({ href, className: klasse, title, children: content }) => (
<a
href={absolut(href, baseUrl)}
className={klasse}
title={title}
target="_blank"
rel="noreferrer noopener nofollow"
>
{content} {content}
</a> </a>
), ),
+86
View File
@@ -0,0 +1,86 @@
"use client";
import { useState } from "react";
import { BookOpen, ChevronDown, ExternalLink } from "lucide-react";
import { Markdown } from "@/components/Markdown";
import { cn } from "@/lib/utils";
/**
* Das README des verknuepften Repositories.
*
* Erspart den Wechsel ins Git, nur um zu sehen, worum ein halbvergessenes
* Projekt eigentlich ging. Nicht zum Lesen ganzer Handbuecher gedacht:
* eingeklappt zeigt es die ersten Zeilen, ausgeklappt alles.
*/
export function ReadmePanel({
content,
path,
baseUrl,
fileUrl,
}: {
content: string;
path: string | null;
baseUrl: string | null;
/** Direktlink auf die Datei im Git-System. */
fileUrl: string | null;
}) {
const [offen, setOffen] = useState(false);
// Kurze READMEs vollstaendig zu zeigen ist einfacher, als sie mit einem
// Knopf zu versehen, der nichts freigibt.
const kurz = content.length < 900;
return (
<section className="glass p-5">
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<h2 className="flex items-center gap-2 text-sm font-semibold uppercase tracking-wider text-slate-300">
<BookOpen className="h-4 w-4 text-cyan-300" />
{path ?? "README"}
</h2>
{fileUrl ? (
<a
href={fileUrl}
target="_blank"
rel="noreferrer noopener"
className="inline-flex items-center gap-1.5 text-xs text-slate-500 transition-colors hover:text-violet-300"
>
Im Git öffnen
<ExternalLink className="h-3 w-3" />
</a>
) : null}
</div>
<div className="relative">
<div
className={cn(
"overflow-hidden transition-[max-height] duration-300",
!kurz && !offen && "max-h-72",
)}
>
<Markdown baseUrl={baseUrl}>{content}</Markdown>
</div>
{/* Verlauf ueber den abgeschnittenen Rand, damit erkennbar ist, dass
es weitergeht. Nur wenn wirklich etwas verdeckt wird. */}
{!kurz && !offen ? (
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 bottom-0 h-16 bg-gradient-to-t from-[#0b0d1c] to-transparent"
/>
) : null}
</div>
{!kurz ? (
<button
type="button"
onClick={() => setOffen((wert) => !wert)}
className="btn btn-ghost mt-2 w-full text-xs"
aria-expanded={offen}
>
<ChevronDown className={cn("h-3.5 w-3.5 transition-transform", offen && "rotate-180")} />
{offen ? "Weniger" : "Ganzes README anzeigen"}
</button>
) : null}
</section>
);
}
+3
View File
@@ -75,6 +75,8 @@ export interface PendingLoginState {
isActive: boolean; isActive: boolean;
/** Entschluesselter TOTP-Schluessel; null, wenn er nicht lesbar ist. */ /** Entschluesselter TOTP-Schluessel; null, wenn er nicht lesbar ist. */
totpSecret: string | null; totpSecret: string | null;
/** Zuletzt eingeloestes Zeitfenster – gegen die Wiederverwendung. */
totpLastCounter: number | null;
}; };
} }
@@ -104,6 +106,7 @@ export async function readPendingLogin(): Promise<PendingLoginState | null> {
role: pending.user.role, role: pending.user.role,
isActive: pending.user.isActive, isActive: pending.user.isActive,
totpSecret: tryDecryptSecret(pending.user.totpSecretCipher), totpSecret: tryDecryptSecret(pending.user.totpSecretCipher),
totpLastCounter: pending.user.totpLastCounter,
}, },
}; };
} }
+31
View File
@@ -6,6 +6,7 @@ import {
counterFor, counterFor,
generateTotpSecret, generateTotpSecret,
groupSecret, groupSecret,
matchTotp,
otpauthUri, otpauthUri,
totpCode, totpCode,
verifyTotp, verifyTotp,
@@ -124,3 +125,33 @@ describe("Einrichtung", () => {
expect(groupSecret("ABCDEFGH")).toBe("ABCD EFGH"); expect(groupSecret("ABCDEFGH")).toBe("ABCD EFGH");
}); });
}); });
describe("matchTotp", () => {
const SECRET = base32Encode(Buffer.from("12345678901234567890", "utf8"));
const JETZT = new Date(1111111109_000);
/**
* Der Zaehler ist noetig, um einen verbrauchten Code zu entwerten. RFC 6238
* verlangt das in Abschnitt 5.2 ausdruecklich – sonst laesst sich ein
* abgefangener Code die restliche Gueltigkeitsdauer ueber wiederverwenden.
*/
it("gibt das Zeitfenster zurueck, das gepasst hat", () => {
expect(matchTotp(SECRET, "081804", JETZT)).toBe(37037036);
});
it("nennt bei den Nachbarfenstern deren eigene Nummer", () => {
// Genau daran haengt die Entwertung: der Code aus dem vorigen Fenster
// darf den Merker nicht auf einen hoeheren Stand setzen.
const naechstes = new Date(JETZT.getTime() + TOTP_PERIOD * 1000);
expect(matchTotp(SECRET, "081804", naechstes)).toBe(37037036);
});
it("liefert null bei falschem Code", () => {
expect(matchTotp(SECRET, "000000", JETZT)).toBeNull();
});
it("stimmt mit verifyTotp ueberein", () => {
expect(matchTotp(SECRET, "081804", JETZT) !== null).toBe(verifyTotp(SECRET, "081804", JETZT));
expect(matchTotp(SECRET, "000000", JETZT) !== null).toBe(verifyTotp(SECRET, "000000", JETZT));
});
});
+31 -13
View File
@@ -102,26 +102,44 @@ export function counterFor(at: Date = new Date()): number {
} }
/** /**
* Prueft einen eingegebenen Code. Vergleicht in konstanter Zeit und probiert * Prueft einen eingegebenen Code und gibt das Zeitfenster zurueck, das
* die Nachbarfenster mit, damit eine leicht abweichende Uhr nicht stoert. * gepasst hat – oder null.
*
* Das Fenster wird gebraucht, um einen verbrauchten Code zu entwerten:
* RFC 6238 verlangt in Abschnitt 5.2 ausdruecklich, dass derselbe Code kein
* zweites Mal angenommen wird. Ohne diese Zahl liesse sich ein abgefangener
* Code die restliche Gueltigkeitsdauer ueber wiederverwenden.
*
* Verglichen wird in konstanter Zeit, und die Nachbarfenster werden
* mitprobiert, damit eine leicht abweichende Uhr nicht stoert.
*/ */
export function matchTotp(
secret: string,
code: string,
at: Date = new Date(),
window: number = TOTP_WINDOW,
): number | null {
const eingabe = code.replace(/\s/g, "");
if (!/^\d+$/.test(eingabe) || eingabe.length !== TOTP_DIGITS) return null;
const jetzt = counterFor(at);
let treffer: number | null = null;
for (let schritt = -window; schritt <= window; schritt += 1) {
// Nicht fruehzeitig abbrechen: sonst haengt die Laufzeit davon ab,
// welches Fenster gepasst hat.
if (constantEquals(totpCode(secret, jetzt + schritt), eingabe)) treffer = jetzt + schritt;
}
return treffer;
}
/** Wie matchTotp, wenn nur interessiert *ob* der Code stimmt. */
export function verifyTotp( export function verifyTotp(
secret: string, secret: string,
code: string, code: string,
at: Date = new Date(), at: Date = new Date(),
window: number = TOTP_WINDOW, window: number = TOTP_WINDOW,
): boolean { ): boolean {
const eingabe = code.replace(/\s/g, ""); return matchTotp(secret, code, at, window) !== null;
if (!/^\d+$/.test(eingabe) || eingabe.length !== TOTP_DIGITS) return false;
const jetzt = counterFor(at);
let treffer = false;
for (let schritt = -window; schritt <= window; schritt += 1) {
// Nicht fruehzeitig abbrechen: sonst haengt die Laufzeit davon ab,
// welches Fenster gepasst hat.
if (constantEquals(totpCode(secret, jetzt + schritt), eingabe)) treffer = true;
}
return treffer;
} }
function constantEquals(a: string, b: string): boolean { function constantEquals(a: string, b: string): boolean {
+71
View File
@@ -41,6 +41,77 @@ export const KIND_META: Record<ChangeKind, { label: string; className: string }>
}; };
export const CHANGELOG: Release[] = [ export const CHANGELOG: Release[] = [
{
version: "1.5.0",
date: "2026-07-27",
headline: "Sicherheitsdurchsicht und eine Anleitung für den Server.",
changes: [
{
kind: "behoben",
text:
"Ein Einmalkennwort ließ sich innerhalb seiner Gültigkeit mehrfach verwenden. " +
"Das eingelöste Zeitfenster wird jetzt gemerkt — RFC 6238 verlangt das " +
"ausdrücklich.",
},
{
kind: "behoben",
text:
"Der Bildoptimierer von Next war ohne Anmeldung erreichbar und reichte Bilder " +
"durch libvips, für das es offene Schwachstellen gibt. Nebula benutzt ihn " +
"nirgends — jetzt ist er abgeschaltet.",
},
{
kind: "neu",
text:
"DEPLOYMENT.md: Schritt für Schritt vom leeren Server bis zur Instanz hinter " +
"TLS, mit Firewall, Sicherungen und Betrieb.",
},
{
kind: "neu",
text:
"scripts/update.sh rollt ein Update aus: prüfen, holen, sichern, bauen, " +
"starten, Gesundheit prüfen — und nennt bei einem Fehlschlag den Weg zurück.",
},
{
kind: "besser",
text:
"Umsortieren im Brett und das Anlegen von Vorlagen sind jetzt ebenfalls " +
"ratenbegrenzt.",
},
{
kind: "besser",
text:
"Die Bereinigung des Markdowns ist mit elf Tests festgehalten: Skripte, " +
"Ereignis-Attribute, javascript: und data: kommen nicht durch.",
},
],
},
{
version: "1.4.0",
date: "2026-07-26",
headline: "Worum ging es hier nochmal?",
changes: [
{
kind: "neu",
text:
"README-Vorschau auf der Projektseite. Erspart den Wechsel ins Git, nur um zu " +
"sehen, worum ein halbvergessenes Projekt eigentlich ging.",
},
{
kind: "besser",
text:
"Relative Links im README zeigen dorthin, wo sie hingehören — aufgelöst gegen " +
"das Verzeichnis der Datei im Repository.",
},
{
kind: "besser",
text:
"Bilder im README werden bewusst nicht geladen, sondern als anklickbarer " +
"Verweis gezeigt. Die Content-Security-Policy dafür zu öffnen hieße, jedem " +
"fremden README das Nachladen beliebiger Bilder zu erlauben — auch Zählpixeln.",
},
],
},
{ {
version: "1.3.0", version: "1.3.0",
date: "2026-07-26", date: "2026-07-26",
+38
View File
@@ -5,6 +5,7 @@ import {
calendarSince, calendarSince,
type CommitCalendar, type CommitCalendar,
} from "./calendar"; } from "./calendar";
import { capReadme, decodeBase64, pickReadme, type RepoReadme } from "./readme";
import type { import type {
BranchInfo, BranchInfo,
CiInfo, CiInfo,
@@ -246,6 +247,43 @@ export class GiteaClient implements GitClient {
return commits.map((commit) => toIso(commit.commit?.author?.date)); return commits.map((commit) => toIso(commit.commit?.author?.date));
}); });
} }
/**
* Gitea kennt keinen README-Endpunkt. Erst das Wurzelverzeichnis
* auflisten, dann die gefundene Datei holen – zwei Aufrufe.
*/
async fetchReadme(ref: RepoRef): Promise<RepoReadme | null> {
const base = `${this.apiBase}/repos/${encodeURIComponent(ref.owner)}/${encodeURIComponent(ref.name)}`;
const headers = this.headers();
const eintraege = await getJsonSafe<Array<{ name: string; type: string }>>(
`${base}/contents`,
[],
{ headers },
);
const name = pickReadme(
eintraege.filter((eintrag) => eintrag.type === "file").map((eintrag) => eintrag.name),
);
if (!name) return null;
const datei = await getJson<{
content?: string;
encoding?: string;
path?: string;
html_url?: string | null;
}>(`${base}/contents/${encodeURIComponent(name)}`, { headers });
if (!datei?.content || datei.encoding !== "base64") return null;
const { content, truncated } = capReadme(decodeBase64(datei.content));
return {
content,
path: datei.path ?? name,
// html_url zeigt auf die Datei; fuer relative Links braucht es das
// Verzeichnis darum herum.
baseUrl: datei.html_url ? datei.html_url.replace(/\/[^/]*$/, "/") : null,
truncated,
};
}
} }
function mapCommitStatus(state: string): CiState { function mapCommitStatus(state: string): CiState {
+38
View File
@@ -16,6 +16,7 @@ import type {
TicketInfo, TicketInfo,
} from "./types"; } from "./types";
import { dayKey } from "./calendar"; import { dayKey } from "./calendar";
import { capReadme, decodeBase64, type RepoReadme } from "./readme";
import { GitApiError, bucketCommitActivity } from "./types"; import { GitApiError, bucketCommitActivity } from "./types";
/** /**
@@ -252,6 +253,43 @@ export class GitHubClient implements GitClient {
return { from: von, to: heute, days, source: "stats", truncated: false }; return { from: von, to: heute, days, source: "stats", truncated: false };
} }
/**
* GitHub hat einen eigenen Endpunkt, der jede Schreibweise findet – ein
* Aufruf genuegt. Ohne README antwortet er mit 404; das ist kein Fehler,
* sondern die Auskunft "gibt es nicht".
*/
async fetchReadme(ref: RepoRef): Promise<RepoReadme | null> {
const base = `${this.apiBase}/repos/${encodeURIComponent(ref.owner)}/${encodeURIComponent(ref.name)}`;
let datei: GitHubContent;
try {
datei = await getJson<GitHubContent>(`${base}/readme`, { headers: this.headers() });
} catch (error) {
if (error instanceof GitApiError && error.status === 404) return null;
throw error;
}
if (!datei?.content || datei.encoding !== "base64") return null;
const { content, truncated } = capReadme(decodeBase64(datei.content));
return {
content,
path: datei.path ?? datei.name ?? "README",
// html_url zeigt auf die Datei; fuer relative Links braucht es das
// Verzeichnis darum herum.
baseUrl: datei.html_url ? datei.html_url.replace(/\/[^/]*$/, "/") : null,
truncated,
};
}
}
interface GitHubContent {
name?: string;
path?: string;
content?: string;
encoding?: string;
html_url?: string | null;
} }
interface GitHubWeek { interface GitHubWeek {
+39
View File
@@ -5,6 +5,7 @@ import {
calendarSince, calendarSince,
type CommitCalendar, type CommitCalendar,
} from "./calendar"; } from "./calendar";
import { capReadme, decodeBase64, pickReadme, type RepoReadme } from "./readme";
import type { import type {
BranchInfo, BranchInfo,
CiInfo, CiInfo,
@@ -227,6 +228,44 @@ export class GitLabClient implements GitClient {
return commits.map((commit) => toIso(commit.committed_date ?? commit.created_at)); return commits.map((commit) => toIso(commit.committed_date ?? commit.created_at));
}); });
} }
/**
* GitLab kennt keinen README-Endpunkt. Erst das Wurzelverzeichnis
* auflisten, dann die gefundene Datei holen – zwei Aufrufe.
*/
async fetchReadme(ref: RepoRef): Promise<RepoReadme | null> {
const base = `${this.apiBase}/projects/${this.projectPath(ref)}`;
const headers = this.headers();
const eintraege = await getJsonSafe<Array<{ name: string; type: string }>>(
`${base}/repository/tree?per_page=100`,
[],
{ headers },
);
const name = pickReadme(
eintraege.filter((eintrag) => eintrag.type === "blob").map((eintrag) => eintrag.name),
);
if (!name) return null;
const datei = await getJson<{ content?: string; encoding?: string; file_path?: string }>(
`${base}/repository/files/${encodeURIComponent(name)}?ref=HEAD`,
{ headers },
);
if (!datei?.content || datei.encoding !== "base64") return null;
const { content, truncated } = capReadme(decodeBase64(datei.content));
const projekt = await getJsonSafe<GitLabProject>(base, {} as GitLabProject, { headers });
return {
content,
path: datei.file_path ?? name,
baseUrl:
projekt?.web_url && projekt.default_branch
? `${projekt.web_url}/-/blob/${encodeURIComponent(projekt.default_branch)}/`
: null,
truncated,
};
}
} }
function mapPipelineState(status: string | null): CiState { function mapPipelineState(status: string | null): CiState {
+72
View File
@@ -0,0 +1,72 @@
import { describe, expect, it } from "vitest";
import { README_MAX_CHARS, capReadme, decodeBase64, pickReadme } from "./readme";
describe("pickReadme", () => {
it("findet die uebliche Schreibweise", () => {
expect(pickReadme(["src", "README.md", "package.json"])).toBe("README.md");
});
it("ist bei der Gross- und Kleinschreibung nachsichtig", () => {
expect(pickReadme(["readme.md"])).toBe("readme.md");
expect(pickReadme(["ReadMe.MD"])).toBe("ReadMe.MD");
});
it("zieht Markdown den uebrigen Formaten vor", () => {
// Beides kommt vor; nur das erste laesst sich wirklich darstellen.
expect(pickReadme(["README.rst", "README.md"])).toBe("README.md");
});
it("nimmt auch ein README ohne Endung", () => {
expect(pickReadme(["LICENSE", "README"])).toBe("README");
});
it("liefert null, wenn keines dabei ist", () => {
expect(pickReadme(["src", "package.json"])).toBeNull();
expect(pickReadme([])).toBeNull();
});
it("verwechselt Aehnliches nicht mit dem README", () => {
expect(pickReadme(["README-CONTRIBUTING.md", "readme.md.bak"])).toBeNull();
});
});
describe("capReadme", () => {
it("laesst Kurzes unangetastet", () => {
const { content, truncated } = capReadme("# Titel\n\nEin Satz.");
expect(content).toBe("# Titel\n\nEin Satz.");
expect(truncated).toBe(false);
});
it("kuerzt Langes an einer Zeilengrenze", () => {
// Sonst bliebe womoeglich ein halber Codeblock stehen.
const zeile = "x".repeat(80);
const lang = Array.from({ length: 2000 }, () => zeile).join("\n");
const { content, truncated } = capReadme(lang);
expect(truncated).toBe(true);
expect(content.length).toBeLessThanOrEqual(README_MAX_CHARS);
expect(content.endsWith("x")).toBe(true);
expect(content.split("\n").at(-1)).toBe(zeile);
});
it("kuerzt notfalls hart, wenn es gar keine Zeilengrenze gibt", () => {
// Eine einzige riesige Zeile: hier waere der letzte Umbruch bei -1.
const { content, truncated } = capReadme("y".repeat(README_MAX_CHARS + 500));
expect(truncated).toBe(true);
expect(content).toHaveLength(README_MAX_CHARS);
});
});
describe("decodeBase64", () => {
it("dekodiert UTF-8 richtig", () => {
// Ueber Bytes gedacht kaeme hier Kauderwelsch heraus.
const kodiert = Buffer.from("# Nebula – Übersicht", "utf8").toString("base64");
expect(decodeBase64(kodiert)).toBe("# Nebula – Übersicht");
});
it("uebergeht Zeilenumbrueche in der Kodierung", () => {
// GitHub bricht den base64-Block alle 60 Zeichen um.
const roh = Buffer.from("Hallo Welt", "utf8").toString("base64");
const umgebrochen = `${roh.slice(0, 4)}\n${roh.slice(4)}`;
expect(decodeBase64(umgebrochen)).toBe("Hallo Welt");
});
});
+78
View File
@@ -0,0 +1,78 @@
/**
* Das README eines Repositories – gemeinsame Teile fuer alle drei Anbieter.
*
* Gezeigt wird es auf der Projektseite, damit man nicht ins Git wechseln
* muss, nur um zu sehen, worum ein halbvergessenes Projekt eigentlich ging.
*/
export interface RepoReadme {
/** Roher Markdown-Text. */
content: string;
/** Dateiname im Repository, etwa "README.md". */
path: string;
/**
* Basis, gegen die relative Links im Text aufzuloesen sind – die
* Web-Ansicht des Repositories im Verzeichnis des READMEs.
*/
baseUrl: string | null;
/** Wurde am Deckel abgeschnitten? */
truncated: boolean;
}
/**
* Hoechstlaenge. Ein README von einem Megabyte gibt es, und es waere in
* einer Vorschau ohnehin unlesbar – abgeschnitten wird an einer
* Zeilengrenze, damit kein halber Codeblock stehen bleibt.
*/
export const README_MAX_CHARS = 60_000;
/**
* Dateinamen, die als README gelten – in dieser Reihenfolge. Markdown zuerst,
* weil nur das dargestellt werden kann; die uebrigen landen als Klartext in
* einem Codeblock, was immer noch besser ist als nichts.
*/
const KANDIDATEN = [
"readme.md",
"readme.markdown",
"readme.mdown",
"readme",
"readme.txt",
"readme.rst",
];
/**
* Sucht aus einer Verzeichnisauflistung die README-Datei heraus.
* Gross- und Kleinschreibung ist egal; die Reihenfolge oben entscheidet.
*/
export function pickReadme(namen: string[]): string | null {
const klein = new Map(namen.map((name) => [name.toLowerCase(), name]));
for (const kandidat of KANDIDATEN) {
const treffer = klein.get(kandidat);
if (treffer) return treffer;
}
return null;
}
/** Kuerzt am Deckel, aber an einer Zeilengrenze. */
export function capReadme(text: string): { content: string; truncated: boolean } {
if (text.length <= README_MAX_CHARS) return { content: text, truncated: false };
const roh = text.slice(0, README_MAX_CHARS);
const letzterUmbruch = roh.lastIndexOf("\n");
return {
// Nur wenn der Umbruch nicht ganz am Anfang liegt – sonst bliebe fast
// nichts uebrig.
content: letzterUmbruch > README_MAX_CHARS / 2 ? roh.slice(0, letzterUmbruch) : roh,
truncated: true,
};
}
/**
* Dekodiert, was die Anbieter als base64 liefern.
*
* Bewusst ueber Buffer und nicht ueber atob: der Inhalt ist UTF-8, und atob
* gaebe Bytes zurueck, aus denen Umlaute als Kauderwelsch herauskaemen.
*/
export function decodeBase64(value: string): string {
return Buffer.from(value.replace(/\s/g, ""), "base64").toString("utf8");
}
+8 -1
View File
@@ -1,7 +1,8 @@
import type { GitProvider } from "@prisma/client"; import type { GitProvider } from "@prisma/client";
import type { CommitCalendar } from "./calendar"; import type { CommitCalendar } from "./calendar";
import type { RepoReadme } from "./readme";
export type { CommitCalendar }; export type { CommitCalendar, RepoReadme };
export type ProviderKind = GitProvider; export type ProviderKind = GitProvider;
@@ -181,6 +182,12 @@ export interface GitClient {
* hoechstens einmal am Tag noetig ist. null heisst: nichts zu holen. * hoechstens einmal am Tag noetig ist. null heisst: nichts zu holen.
*/ */
fetchCommitCalendar(ref: RepoRef): Promise<CommitCalendar | null>; fetchCommitCalendar(ref: RepoRef): Promise<CommitCalendar | null>;
/**
* Das README des Repositories. Wie der Kalender getrennt, weil es
* zusaetzliche Aufrufe kostet und sich selten aendert. null heisst: es
* gibt keins.
*/
fetchReadme(ref: RepoRef): Promise<RepoReadme | null>;
} }
/** Fehler mit HTTP-Kontext, damit die UI verstaendliche Meldungen zeigen kann. */ /** Fehler mit HTTP-Kontext, damit die UI verstaendliche Meldungen zeigen kann. */
+44 -2
View File
@@ -58,9 +58,10 @@ export async function syncRepository(repositoryId: string): Promise<SyncResult>
try { try {
const data = await client.fetchRepo(ref); const data = await client.fetchRepo(ref);
await persistSnapshot(repository, data); await persistSnapshot(repository, data);
// Erst nach dem Snapshot: die Zeile muss existieren, bevor der Kalender // Erst nach dem Snapshot: die Zeile muss existieren, bevor Kalender und
// hineingeschrieben werden kann. // README hineingeschrieben werden koennen.
await maybeRefreshCalendar(repository.id, client, ref); await maybeRefreshCalendar(repository.id, client, ref);
await maybeRefreshReadme(repository.id, client, ref);
// 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 {
@@ -196,6 +197,47 @@ async function maybeRefreshCalendar(
} }
} }
/**
* Holt das README – wie der Kalender hoechstens einmal am Tag.
*
* Ein eigener Zeitstempel und nicht der des Kalenders: schlaegt das eine
* fehl, soll das andere nicht mit blockiert werden.
*/
async function maybeRefreshReadme(
repositoryId: string,
client: GitClient,
ref: { owner: string; name: string },
): Promise<void> {
try {
const snapshot = await prisma.repoSnapshot.findUnique({
where: { repositoryId },
select: { readmeAt: true },
});
const zuletzt = snapshot?.readmeAt?.getTime() ?? 0;
if (Date.now() - zuletzt < CALENDAR_TTL_MS) return;
const readme = await client.fetchReadme(ref);
// Kein README im Repository: das ist eine Auskunft, kein Fehlschlag.
// Der Zeitstempel wird gesetzt, damit nicht jeder Abgleich erneut sucht,
// und ein etwaiger alter Stand verschwindet.
await prisma.repoSnapshot.update({
where: { repositoryId },
data: {
readme: readme?.content ?? null,
readmePath: readme?.path ?? null,
readmeBaseUrl: readme?.baseUrl ?? null,
readmeAt: new Date(),
},
});
} catch (error) {
// Wie beim Kalender: Beiwerk. Ohne Zeitstempel versucht es der naechste
// Durchgang von selbst wieder.
console.error("[nebula] README konnte nicht geholt werden:", error);
}
}
/** Owner/Name aus den Feldern oder ersatzweise aus der URL bestimmen. */ /** Owner/Name aus den Feldern oder ersatzweise aus der URL bestimmen. */
export function resolveRepoRef( export function resolveRepoRef(
repository: Pick<Repository, "repoOwner" | "repoName" | "repoUrl">, repository: Pick<Repository, "repoOwner" | "repoName" | "repoUrl">,
+4 -1
View File
@@ -147,7 +147,10 @@ export const config = {
* weder CSRF-Pruefung noch Nonce und wuerden nur Rechenzeit kosten. * weder CSRF-Pruefung noch Nonce und wuerden nur Rechenzeit kosten.
*/ */
{ {
source: "/((?!_next/static|_next/image|favicon.ico|icon.svg).*)", // _next/image ist bewusst *nicht* ausgenommen: der Bildoptimierer ist
// abgeschaltet (siehe next.config.ts), und was von ihm uebrig bleibt,
// soll nicht ohne Sitzung erreichbar sein.
source: "/((?!_next/static|favicon.ico|icon.svg).*)",
missing: [ missing: [
{ type: "header", key: "next-router-prefetch" }, { type: "header", key: "next-router-prefetch" },
{ type: "header", key: "purpose", value: "prefetch" }, { type: "header", key: "purpose", value: "prefetch" },
+9 -1
View File
@@ -5,9 +5,17 @@ export default defineConfig({
test: { test: {
// Reine Logik, kein DOM und keine Datenbank – die Tests laufen in // Reine Logik, kein DOM und keine Datenbank – die Tests laufen in
// Millisekunden und brauchen weder Server noch Container. // Millisekunden und brauchen weder Server noch Container.
//
// .tsx ist dabei, weil die Bereinigung des Markdowns nur am erzeugten
// HTML zu pruefen ist. Dafuer genuegt renderToStaticMarkup; ein DOM
// braucht es dazu nicht.
environment: "node", environment: "node",
include: ["src/**/*.test.ts"], include: ["src/**/*.test.ts", "src/**/*.test.tsx"],
}, },
// Die tsconfig setzt jsx auf "preserve", weil Next das selbst uebersetzt.
// Hier gibt es kein Next, also muss der Transformer es tun. Vite nimmt
// inzwischen oxc statt esbuild – die esbuild-Option wuerde ignoriert.
oxc: { jsx: { runtime: "automatic" } },
resolve: { resolve: {
alias: { alias: {
"@": fileURLToPath(new URL("./src", import.meta.url)), "@": fileURLToPath(new URL("./src", import.meta.url)),