Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
47245152a2 | ||
|
|
d7176339b8 | ||
|
|
df94646371 | ||
|
|
27f51b6be8 |
@@ -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
@@ -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.
|
||||||
@@ -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)**.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -110,6 +110,7 @@ Damit klar ist, was **nicht** kommt und warum:
|
|||||||
| **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.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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "nebula",
|
"name": "nebula",
|
||||||
"version": "1.4.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,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "User" ADD COLUMN "totpLastCounter" INTEGER;
|
||||||
@@ -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[]
|
||||||
|
|||||||
@@ -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'
|
||||||
@@ -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 } }),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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(")");
|
||||||
|
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("");
|
||||||
|
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", () => {
|
||||||
|
// "[](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("[](https://example.com/ziel)", BASIS);
|
||||||
|
expect(ausgabe).toContain("<span");
|
||||||
|
expect(ausgabe.match(/<a /g) ?? []).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -41,6 +41,51 @@ 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",
|
version: "1.4.0",
|
||||||
date: "2026-07-26",
|
date: "2026-07-26",
|
||||||
|
|||||||
+4
-1
@@ -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
@@ -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)),
|
||||||
|
|||||||
Reference in New Issue
Block a user