fix(security): demo sends nothing, hidden SMTP password, body limits (FLAW-33..38)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m52s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m52s
- Demo: senders for mail, Discord and webhooks refuse, no alarm ticker; test alarm, SMTP test, server settings, backups, account and session changes of the shared demo accounts answer 403; the session list only shows the own device. The UI shows the notices and disables the controls. - SMTP password is never sent back (smtpPassSet), empty keeps it. - ?token= on webhook ingest accepts only API keys. - Request bodies capped at 2 MB (sync 8 MB, imports 101 MB). - Deleting an account removes its server backups. - Backup downloads, exports and import uploads get 30 minutes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3709282a4f
commit
9d9698186c
21 files changed
+631
-304
No files matched your search
@@ -14,8 +14,14 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V
|
|||||||
- 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).
|
- 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).
|
- 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).
|
- 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).
|
||||||
|
|
||||||
### Fixed
|
### 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.
|
- 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.
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -24,6 +30,8 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V
|
|||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
- **Achtung:** Nach dem Update sind alle Benutzer einmal abgemeldet (neue Sitzungsverwaltung, die alte Sperrliste `revoked_tokens` entfällt).
|
- **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`.
|
- **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.
|
- 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.
|
- Das Mengenfeld in Listen öffnet auf dem Handy die Zahlentastatur.
|
||||||
|
|||||||
+3
-3
@@ -1,6 +1,6 @@
|
|||||||
# REST-API
|
# 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
|
## Öffentlich
|
||||||
|
|
||||||
@@ -15,7 +15,7 @@ Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}`
|
|||||||
| 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/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/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 | `/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 |
|
| 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 | `/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 |
|
| GET | `/events` | SSE-Fallback; `?ticket=` oder `Authorization`-Header |
|
||||||
@@ -190,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 |
|
| PATCH/DELETE | `/admin/users/{id}` | `displayName`, `email`, `role`, `isActive`, `discordWebhookUrl`; löschen |
|
||||||
| PUT | `/admin/users/{id}/password` | Passwort zurücksetzen |
|
| PUT | `/admin/users/{id}/password` | Passwort zurücksetzen |
|
||||||
| GET/POST/DELETE | `/admin/invites[/{id}]` | Einladungen (`email`, `role`, `organizationId`, `validDays`) |
|
| 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`) |
|
| 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`) |
|
| POST | `/admin/smtp/test` | Testmail (`toEmail`) |
|
||||||
| GET/POST | `/admin/backups` | Datenbank-Abzüge auflisten bzw. sofort sichern (Abzug + alle Benutzer-Backups) |
|
| GET/POST | `/admin/backups` | Datenbank-Abzüge auflisten bzw. sofort sichern (Abzug + alle Benutzer-Backups) |
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ Das Frontend liegt per `go:embed` im Binary ([cmd/server/main.go](../cmd/server/
|
|||||||
| `TZ` | `Europe/Berlin` | Zeitzone für Fälligkeiten und Alarme; die Zeitzonendaten sind ins Binary eingebettet (`time/tzdata`) |
|
| `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 |
|
| `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` |
|
| `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`. |
|
| `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_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) |
|
| `SMTP_SKIP_VERIFY` | `false` | Zertifikat des Mailservers nicht prüfen (nur interne Server mit selbstsigniertem Zertifikat) |
|
||||||
|
|||||||
+12
-6
@@ -15,12 +15,12 @@ Schweregrade: **Hoch** (Kontoübernahme oder Datenabfluss möglich), **Mittel**
|
|||||||
| FLAW-30 | Konto | ~~Passwort und E-Mail lassen sich ohne das aktuelle Passwort ändern~~ | **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-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-32 | Daten | ~~IDs und Einladungscodes haben nur 40 Bit Zufall~~ | **Behoben** |
|
||||||
| FLAW-33 | Demo | Öffentliche Demo als Absender missbrauchbar (Alarm-Webhooks, Testmails, Server-Einstellungen) | Mittel |
|
| 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 | Niedrig |
|
| 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=` | Niedrig |
|
| 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 | Niedrig |
|
| 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 | Niedrig |
|
| 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 | Niedrig |
|
| 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-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-40 | Auth | ~~Einladungscode bei gleichzeitiger Nutzung zweimal einlösbar~~ | **Behoben** |
|
||||||
| FLAW-41 | Qualität | Sehr große Frontend-Dateien | Niedrig |
|
| FLAW-41 | Qualität | Sehr große Frontend-Dateien | Niedrig |
|
||||||
@@ -64,31 +64,37 @@ Schweregrade: **Hoch** (Kontoübernahme oder Datenabfluss möglich), **Mittel**
|
|||||||
- **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.
|
- **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.
|
||||||
- **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.
|
- **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.
|
- **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
|
### FLAW-34: SMTP-Passwort im Klartext
|
||||||
- **Fundstelle:** `handleAdminGetSettings` ([handlers_admin.go](../internal/api/handlers_admin.go)) liefert `smtpPass` aus; Speicherung in `system_settings`.
|
- **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.
|
- **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.
|
- **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
|
### FLAW-35: `?token=` beim Webhook-Ingest
|
||||||
- **Fundstelle:** `handleWebhookIngest` in [handlers_integrations.go](../internal/api/handlers_integrations.go).
|
- **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.
|
- **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.
|
- **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
|
### 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).
|
- **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.
|
- **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.
|
- **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
|
### FLAW-37: Server-Backups überleben das gelöschte Konto
|
||||||
- **Fundstelle:** `DeleteOwnAccount` / `handleAdminDeleteUser` und `backups/users/<id>/` ([backups.go](../internal/api/backups.go)).
|
- **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.
|
- **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.
|
- **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
|
### 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`).
|
- **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).
|
- **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.
|
- **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
|
### FLAW-39: Kein Rate-Limit außerhalb des Logins
|
||||||
- **Fundstelle:** `handleRegister`, `handleForgotPassword` (Sperre nur je Konto), Einladungscode-Prüfung.
|
- **Fundstelle:** `handleRegister`, `handleForgotPassword` (Sperre nur je Konto), Einladungscode-Prüfung.
|
||||||
|
|||||||
@@ -146,8 +146,14 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Read-only in the public demo (FLAW-33)
|
||||||
|
const [demoMode, setDemoMode] = useState(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadAll();
|
loadAll();
|
||||||
|
api
|
||||||
|
.getAuthStatus()
|
||||||
|
.then((st) => setDemoMode(Boolean(st.demoMode)))
|
||||||
|
.catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -519,6 +525,15 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
|||||||
|
|
||||||
{/* Right Content Area */}
|
{/* Right Content Area */}
|
||||||
<div className="md:col-span-3 panel-surface p-5 text-xs">
|
<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 */}
|
{/* SECTION 1: BENUTZER, ROLLEN & EINLADUNGEN */}
|
||||||
{/* ================================================================= */}
|
{/* ================================================================= */}
|
||||||
@@ -1013,300 +1028,305 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
|||||||
{/* SECTION 3: SERVER & SHARING-ISOLATION (SERVER-ADMIN ONLY) */}
|
{/* SECTION 3: SERVER & SHARING-ISOLATION (SERVER-ADMIN ONLY) */}
|
||||||
{/* ================================================================= */}
|
{/* ================================================================= */}
|
||||||
{section === 'server' && isServerAdmin && (
|
{section === 'server' && isServerAdmin && (
|
||||||
<div className="space-y-6">
|
<fieldset disabled={demoMode} className="min-w-0 border-0 p-0 m-0">
|
||||||
<div className="border-b border-border pb-3">
|
<div className="space-y-6">
|
||||||
<h2 className="text-sm font-bold text-main">
|
<div className="border-b border-border pb-3">
|
||||||
Server-Grundeinstellungen & Benutzer-Sichtbarkeit
|
<h2 className="text-sm font-bold text-main">
|
||||||
</h2>
|
Server-Grundeinstellungen & Benutzer-Sichtbarkeit
|
||||||
<p className="text-muted mt-0.5">
|
</h2>
|
||||||
Als Server-Admin bestimmst du zentral, ob Nutzer komplett isoliert arbeiten, sich nur
|
<p className="text-muted mt-0.5">
|
||||||
innerhalb ihrer Organisation sehen oder ob alle Nutzer für alle sichtbar sind.
|
Als Server-Admin bestimmst du zentral, ob Nutzer komplett isoliert arbeiten, sich nur
|
||||||
</p>
|
innerhalb ihrer Organisation sehen oder ob alle Nutzer für alle sichtbar sind.
|
||||||
</div>
|
</p>
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-muted font-semibold mb-1">
|
|
||||||
Name der MindSift-Instanz
|
|
||||||
</label>
|
|
||||||
<div className="flex gap-2 max-w-md">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={settings.instanceName}
|
|
||||||
onChange={(e) =>
|
|
||||||
setSettings((prev) => ({ ...prev, instanceName: e.target.value }))
|
|
||||||
}
|
|
||||||
className="flex-1 h-9 px-3 input-field"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleSaveSystemSettings()}
|
|
||||||
className="btn-primary h-9 px-4 text-xs"
|
|
||||||
>
|
|
||||||
Speichern
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Sharing Isolation Cards */}
|
<div className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div>
|
||||||
<label className="block text-muted font-semibold flex items-center gap-1.5">
|
<label className="block text-muted font-semibold mb-1">
|
||||||
<Users className="w-4 h-4 text-accent" />
|
Name der MindSift-Instanz
|
||||||
<span>Globale Sharing- & Sichtbarkeits-Isolation</span>
|
</label>
|
||||||
</label>
|
<div className="flex gap-2 max-w-md">
|
||||||
|
<input
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
type="text"
|
||||||
{SHARING_MODES.map((m) => (
|
value={settings.instanceName}
|
||||||
|
onChange={(e) =>
|
||||||
|
setSettings((prev) => ({ ...prev, instanceName: e.target.value }))
|
||||||
|
}
|
||||||
|
className="flex-1 h-9 px-3 input-field"
|
||||||
|
/>
|
||||||
<button
|
<button
|
||||||
key={m.id}
|
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleSelectSharingMode(m.id)}
|
onClick={() => handleSaveSystemSettings()}
|
||||||
className={`p-4 text-left border rounded-card transition-colors ${
|
className="btn-primary h-9 px-4 text-xs"
|
||||||
settings.sharingMode === m.id
|
|
||||||
? 'bg-canvas border-accent text-main shadow-panel'
|
|
||||||
: 'bg-canvas/50 border-border text-muted hover:text-main'
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<p className="font-bold text-xs text-main">{m.title}</p>
|
Speichern
|
||||||
<p className="text-[11px] text-muted mt-1 leading-relaxed">{m.text}</p>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{pendingMode && (
|
|
||||||
<div className="p-3 border border-status-warn/40 bg-status-warn/5 rounded-card flex flex-wrap items-center gap-2">
|
|
||||||
<AlertCircle className="w-4 h-4 text-status-warn shrink-0" />
|
|
||||||
<span className="flex-1 min-w-0 text-main">
|
|
||||||
Beim Wechsel zu „{SHARING_MODES.find((m) => m.id === pendingMode.mode)?.title.replace(/^\d\.\s*/, '')}“
|
|
||||||
fallen {pendingMode.affected} Freigaben und Gruppen-Mitgliedschaften weg, die danach nicht mehr erlaubt sind.
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
const mode = pendingMode.mode;
|
|
||||||
setPendingMode(null);
|
|
||||||
handleSaveSystemSettings({ sharingMode: mode });
|
|
||||||
}}
|
|
||||||
className="btn-primary h-7 px-3 text-[11px]"
|
|
||||||
>
|
|
||||||
Trotzdem wechseln
|
|
||||||
</button>
|
|
||||||
<button onClick={() => setPendingMode(null)} className="btn-action h-7 px-2 text-[11px]">
|
|
||||||
Abbrechen
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<BackupAdminCard settings={settings} onSave={(patch) => handleSaveSystemSettings(patch)} />
|
|
||||||
|
|
||||||
{/* Alarm webhooks into the local network (SSRF protection) */}
|
|
||||||
<div className="p-4 bg-canvas border border-border rounded-card flex items-center justify-between gap-4">
|
|
||||||
<div className="pr-4">
|
|
||||||
<p className="font-bold text-main">Alarm-Webhooks ins lokale Netz erlauben</p>
|
|
||||||
<p className="text-[11px] text-muted mt-0.5">
|
|
||||||
Standard: Discord- und Webhook-Alarme gehen nur an öffentliche https-Adressen. Eingeschaltet sind auch Ziele
|
|
||||||
im Heimnetz erlaubt (z. B. Home Assistant unter 192.168.x.x oder über Tailscale), dort auch per http.
|
|
||||||
Der Server selbst (localhost) und Cloud-Metadaten bleiben immer gesperrt.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<ToggleSwitch
|
|
||||||
checked={Boolean(settings.allowPrivateWebhooks)}
|
|
||||||
onChange={(allowPrivateWebhooks) => handleSaveSystemSettings({ allowPrivateWebhooks })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Public Registration Toggle Switch */}
|
|
||||||
<div className="p-4 bg-canvas border border-border rounded-card flex items-center justify-between gap-4">
|
|
||||||
<div className="pr-4">
|
|
||||||
<p className="font-bold text-main">Öffentliche Selbstregistrierung erlauben</p>
|
|
||||||
<p className="text-[11px] text-muted mt-0.5">
|
|
||||||
Wenn deaktiviert, können sich fremde Personen auf dem Login-Bildschirm nicht selbst registrieren.
|
|
||||||
Neue Konten erfordern dann zwingend einen Einladungscode oder müssen von einem Admin angelegt werden.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-3 flex-shrink-0">
|
{/* Sharing Isolation Cards */}
|
||||||
<span className={`text-xs font-medium hidden sm:inline ${settings.allowRegistration ? 'text-accent' : 'text-muted'}`}>
|
<div className="space-y-2">
|
||||||
{settings.allowRegistration ? 'Aktiviert (Offen)' : 'Gesperrt (Nur Einladung)'}
|
<label className="block text-muted font-semibold flex items-center gap-1.5">
|
||||||
</span>
|
<Users className="w-4 h-4 text-accent" />
|
||||||
|
<span>Globale Sharing- & Sichtbarkeits-Isolation</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||||
|
{SHARING_MODES.map((m) => (
|
||||||
|
<button
|
||||||
|
key={m.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleSelectSharingMode(m.id)}
|
||||||
|
className={`p-4 text-left border rounded-card transition-colors ${
|
||||||
|
settings.sharingMode === m.id
|
||||||
|
? 'bg-canvas border-accent text-main shadow-panel'
|
||||||
|
: 'bg-canvas/50 border-border text-muted hover:text-main'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<p className="font-bold text-xs text-main">{m.title}</p>
|
||||||
|
<p className="text-[11px] text-muted mt-1 leading-relaxed">{m.text}</p>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{pendingMode && (
|
||||||
|
<div className="p-3 border border-status-warn/40 bg-status-warn/5 rounded-card flex flex-wrap items-center gap-2">
|
||||||
|
<AlertCircle className="w-4 h-4 text-status-warn shrink-0" />
|
||||||
|
<span className="flex-1 min-w-0 text-main">
|
||||||
|
Beim Wechsel zu „{SHARING_MODES.find((m) => m.id === pendingMode.mode)?.title.replace(/^\d\.\s*/, '')}“
|
||||||
|
fallen {pendingMode.affected} Freigaben und Gruppen-Mitgliedschaften weg, die danach nicht mehr erlaubt sind.
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const mode = pendingMode.mode;
|
||||||
|
setPendingMode(null);
|
||||||
|
handleSaveSystemSettings({ sharingMode: mode });
|
||||||
|
}}
|
||||||
|
className="btn-primary h-7 px-3 text-[11px]"
|
||||||
|
>
|
||||||
|
Trotzdem wechseln
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setPendingMode(null)} className="btn-action h-7 px-2 text-[11px]">
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<BackupAdminCard settings={settings} onSave={(patch) => handleSaveSystemSettings(patch)} />
|
||||||
|
|
||||||
|
{/* Alarm webhooks into the local network (SSRF protection) */}
|
||||||
|
<div className="p-4 bg-canvas border border-border rounded-card flex items-center justify-between gap-4">
|
||||||
|
<div className="pr-4">
|
||||||
|
<p className="font-bold text-main">Alarm-Webhooks ins lokale Netz erlauben</p>
|
||||||
|
<p className="text-[11px] text-muted mt-0.5">
|
||||||
|
Standard: Discord- und Webhook-Alarme gehen nur an öffentliche https-Adressen. Eingeschaltet sind auch Ziele
|
||||||
|
im Heimnetz erlaubt (z. B. Home Assistant unter 192.168.x.x oder über Tailscale), dort auch per http.
|
||||||
|
Der Server selbst (localhost) und Cloud-Metadaten bleiben immer gesperrt.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<ToggleSwitch
|
<ToggleSwitch
|
||||||
checked={settings.allowRegistration}
|
checked={Boolean(settings.allowPrivateWebhooks)}
|
||||||
onChange={(allowRegistration) => handleSaveSystemSettings({ allowRegistration })}
|
onChange={(allowPrivateWebhooks) => handleSaveSystemSettings({ allowPrivateWebhooks })}
|
||||||
title={settings.allowRegistration ? 'Selbstregistrierung deaktivieren' : 'Selbstregistrierung aktivieren'}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Public Registration Toggle Switch */}
|
||||||
|
<div className="p-4 bg-canvas border border-border rounded-card flex items-center justify-between gap-4">
|
||||||
|
<div className="pr-4">
|
||||||
|
<p className="font-bold text-main">Öffentliche Selbstregistrierung erlauben</p>
|
||||||
|
<p className="text-[11px] text-muted mt-0.5">
|
||||||
|
Wenn deaktiviert, können sich fremde Personen auf dem Login-Bildschirm nicht selbst registrieren.
|
||||||
|
Neue Konten erfordern dann zwingend einen Einladungscode oder müssen von einem Admin angelegt werden.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 flex-shrink-0">
|
||||||
|
<span className={`text-xs font-medium hidden sm:inline ${settings.allowRegistration ? 'text-accent' : 'text-muted'}`}>
|
||||||
|
{settings.allowRegistration ? 'Aktiviert (Offen)' : 'Gesperrt (Nur Einladung)'}
|
||||||
|
</span>
|
||||||
|
<ToggleSwitch
|
||||||
|
checked={settings.allowRegistration}
|
||||||
|
onChange={(allowRegistration) => handleSaveSystemSettings({ allowRegistration })}
|
||||||
|
title={settings.allowRegistration ? 'Selbstregistrierung deaktivieren' : 'Selbstregistrierung aktivieren'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</fieldset>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ================================================================= */}
|
{/* ================================================================= */}
|
||||||
{/* SECTION 4: E-MAIL / SMTP-SERVER (SERVER-ADMIN ONLY) */}
|
{/* SECTION 4: E-MAIL / SMTP-SERVER (SERVER-ADMIN ONLY) */}
|
||||||
{/* ================================================================= */}
|
{/* ================================================================= */}
|
||||||
{section === 'smtp' && isServerAdmin && (
|
{section === 'smtp' && isServerAdmin && (
|
||||||
<div className="space-y-6">
|
<fieldset disabled={demoMode} className="min-w-0 border-0 p-0 m-0">
|
||||||
<div className="border-b border-border pb-3 flex flex-col sm:flex-row sm:items-center justify-between gap-2">
|
<div className="space-y-6">
|
||||||
<div>
|
<div className="border-b border-border pb-3 flex flex-col sm:flex-row sm:items-center justify-between gap-2">
|
||||||
<h2 className="text-sm font-bold text-main">
|
<div>
|
||||||
E-Mail / SMTP-Server für Aufgaben-Alarme
|
<h2 className="text-sm font-bold text-main">
|
||||||
</h2>
|
E-Mail / SMTP-Server für Aufgaben-Alarme
|
||||||
<p className="text-muted mt-0.5">
|
</h2>
|
||||||
Konfiguriere den Postausgangsserver per Docker-Compose Environment-Variablen oder
|
<p className="text-muted mt-0.5">
|
||||||
direkt hier über die Oberfläche (wird persistent in der SQLite-Datenbank gespeichert).
|
Konfiguriere den Postausgangsserver per Docker-Compose Environment-Variablen oder
|
||||||
</p>
|
direkt hier über die Oberfläche (wird persistent in der SQLite-Datenbank gespeichert).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{settings.smtpConfiguredViaEnv && (
|
||||||
|
<span className="badge-ok text-[11px] flex-shrink-0">
|
||||||
|
Docker Env erkannt
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{settings.smtpConfiguredViaEnv && (
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
<span className="badge-ok text-[11px] flex-shrink-0">
|
<div>
|
||||||
Docker Env erkannt
|
<label className="block text-muted font-medium mb-1">
|
||||||
</span>
|
SMTP Host (`SMTP_HOST`)
|
||||||
)}
|
</label>
|
||||||
</div>
|
<input
|
||||||
|
type="text"
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
value={settings.smtpHost || ''}
|
||||||
<div>
|
onChange={(e) =>
|
||||||
<label className="block text-muted font-medium mb-1">
|
setSettings((prev) => ({ ...prev, smtpHost: e.target.value }))
|
||||||
SMTP Host (`SMTP_HOST`)
|
}
|
||||||
</label>
|
placeholder="z.B. smtp.example.com oder mail.deinedomain.de"
|
||||||
<input
|
className="w-full h-9 px-3 input-field font-mono"
|
||||||
type="text"
|
|
||||||
value={settings.smtpHost || ''}
|
|
||||||
onChange={(e) =>
|
|
||||||
setSettings((prev) => ({ ...prev, smtpHost: e.target.value }))
|
|
||||||
}
|
|
||||||
placeholder="z.B. smtp.example.com oder mail.deinedomain.de"
|
|
||||||
className="w-full h-9 px-3 input-field font-mono"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-muted font-medium mb-1">
|
|
||||||
SMTP Port (`SMTP_PORT`)
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
inputMode="numeric"
|
|
||||||
value={settings.smtpPort ?? ''}
|
|
||||||
onChange={(e) =>
|
|
||||||
// Digits only; an empty field stays empty while typing (saved as 587)
|
|
||||||
setSettings((prev) => ({ ...prev, smtpPort: e.target.value.replace(/\D/g, '').slice(0, 5) }))
|
|
||||||
}
|
|
||||||
placeholder="587"
|
|
||||||
className="w-full h-9 px-3 input-field font-mono"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-muted font-medium mb-1">
|
|
||||||
SMTP Benutzername (`SMTP_USER`)
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={settings.smtpUser || ''}
|
|
||||||
onChange={(e) =>
|
|
||||||
setSettings((prev) => ({ ...prev, smtpUser: e.target.value }))
|
|
||||||
}
|
|
||||||
placeholder="alerts@deinedomain.de"
|
|
||||||
className="w-full h-9 px-3 input-field font-mono"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-muted font-medium mb-1">
|
|
||||||
SMTP Passwort (`SMTP_PASS`)
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
value={settings.smtpPass || ''}
|
|
||||||
onChange={(e) =>
|
|
||||||
setSettings((prev) => ({ ...prev, smtpPass: e.target.value }))
|
|
||||||
}
|
|
||||||
placeholder="••••••••••••"
|
|
||||||
className="w-full h-9 px-3 input-field font-mono"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-muted font-medium mb-1">
|
|
||||||
Absender-Adresse (`SMTP_FROM`)
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={settings.smtpFrom || ''}
|
|
||||||
onChange={(e) =>
|
|
||||||
setSettings((prev) => ({ ...prev, smtpFrom: e.target.value }))
|
|
||||||
}
|
|
||||||
placeholder="MindSift <alerts@deinedomain.de>"
|
|
||||||
className="w-full h-9 px-3 input-field"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="sm:col-span-2 space-y-3 pt-1">
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<div>
|
|
||||||
<p className="text-main font-medium">Verschlüsselung (`SMTP_TLS`)</p>
|
|
||||||
<p className="text-[11px] text-muted">
|
|
||||||
Port 465: TLS ab Verbindungsbeginn, sonst STARTTLS. Ist es an, wird ohne Verschlüsselung nicht gesendet.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<ToggleSwitch
|
|
||||||
checked={settings.smtpTls ?? true}
|
|
||||||
onChange={(smtpTls) => setSettings((prev) => ({ ...prev, smtpTls }))}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<div>
|
<div>
|
||||||
<p className="text-main font-medium">Zertifikatsprüfung deaktivieren (`SMTP_SKIP_VERIFY`)</p>
|
<label className="block text-muted font-medium mb-1">
|
||||||
<p className="text-[11px] text-muted">
|
SMTP Port (`SMTP_PORT`)
|
||||||
Nur für interne Mailserver mit selbstsigniertem Zertifikat oder abweichendem Namen. Die Verbindung
|
</label>
|
||||||
bleibt verschlüsselt, der Server wird aber nicht mehr geprüft.
|
<input
|
||||||
</p>
|
type="text"
|
||||||
</div>
|
inputMode="numeric"
|
||||||
<ToggleSwitch
|
value={settings.smtpPort ?? ''}
|
||||||
checked={settings.smtpSkipVerify ?? false}
|
onChange={(e) =>
|
||||||
disabled={!(settings.smtpTls ?? true)}
|
// Digits only; an empty field stays empty while typing (saved as 587)
|
||||||
onChange={(smtpSkipVerify) => setSettings((prev) => ({ ...prev, smtpSkipVerify }))}
|
setSettings((prev) => ({ ...prev, smtpPort: e.target.value.replace(/\D/g, '').slice(0, 5) }))
|
||||||
|
}
|
||||||
|
placeholder="587"
|
||||||
|
className="w-full h-9 px-3 input-field font-mono"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-muted font-medium mb-1">
|
||||||
|
SMTP Benutzername (`SMTP_USER`)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={settings.smtpUser || ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setSettings((prev) => ({ ...prev, smtpUser: e.target.value }))
|
||||||
|
}
|
||||||
|
placeholder="alerts@deinedomain.de"
|
||||||
|
className="w-full h-9 px-3 input-field font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-muted font-medium mb-1">
|
||||||
|
SMTP Passwort (`SMTP_PASS`)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={settings.smtpPass || ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setSettings((prev) => ({ ...prev, smtpPass: e.target.value }))
|
||||||
|
}
|
||||||
|
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>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-muted font-medium mb-1">
|
||||||
|
Absender-Adresse (`SMTP_FROM`)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={settings.smtpFrom || ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setSettings((prev) => ({ ...prev, smtpFrom: e.target.value }))
|
||||||
|
}
|
||||||
|
placeholder="MindSift <alerts@deinedomain.de>"
|
||||||
|
className="w-full h-9 px-3 input-field"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sm:col-span-2 space-y-3 pt-1">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-main font-medium">Verschlüsselung (`SMTP_TLS`)</p>
|
||||||
|
<p className="text-[11px] text-muted">
|
||||||
|
Port 465: TLS ab Verbindungsbeginn, sonst STARTTLS. Ist es an, wird ohne Verschlüsselung nicht gesendet.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<ToggleSwitch
|
||||||
|
checked={settings.smtpTls ?? true}
|
||||||
|
onChange={(smtpTls) => setSettings((prev) => ({ ...prev, smtpTls }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-main font-medium">Zertifikatsprüfung deaktivieren (`SMTP_SKIP_VERIFY`)</p>
|
||||||
|
<p className="text-[11px] text-muted">
|
||||||
|
Nur für interne Mailserver mit selbstsigniertem Zertifikat oder abweichendem Namen. Die Verbindung
|
||||||
|
bleibt verschlüsselt, der Server wird aber nicht mehr geprüft.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<ToggleSwitch
|
||||||
|
checked={settings.smtpSkipVerify ?? false}
|
||||||
|
disabled={!(settings.smtpTls ?? true)}
|
||||||
|
onChange={(smtpSkipVerify) => setSettings((prev) => ({ ...prev, smtpSkipVerify }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-end pt-2 border-t border-border">
|
<div className="flex justify-end pt-2 border-t border-border">
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleSaveSystemSettings()}
|
|
||||||
className="btn-primary h-9 px-5 text-xs font-semibold"
|
|
||||||
>
|
|
||||||
SMTP-Konfiguration in Datenbank speichern
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Test SMTP Delivery */}
|
|
||||||
<div className="p-4 bg-canvas border border-border rounded-card space-y-2">
|
|
||||||
<h3 className="font-bold text-main">SMTP-Verbindung testen</h3>
|
|
||||||
<p className="text-[11px] text-muted">
|
|
||||||
Sendet sofort eine Test-E-Mail mit den oben eingetragenen Daten.
|
|
||||||
</p>
|
|
||||||
<div className="flex flex-col sm:flex-row gap-2">
|
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
value={testSmtpEmail}
|
|
||||||
onChange={(e) => setTestSmtpEmail(e.target.value)}
|
|
||||||
placeholder="empfaenger@beispiel.de"
|
|
||||||
className="flex-1 h-8 px-3 input-field"
|
|
||||||
/>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleTestSmtp}
|
onClick={() => handleSaveSystemSettings()}
|
||||||
disabled={testingSmtp || !testSmtpEmail.trim()}
|
className="btn-primary h-9 px-5 text-xs font-semibold"
|
||||||
className="btn-action h-8 px-4 text-xs flex items-center justify-center gap-1.5 disabled:opacity-40"
|
|
||||||
>
|
>
|
||||||
<Send className="w-3.5 h-3.5 text-accent" />
|
SMTP-Konfiguration in Datenbank speichern
|
||||||
<span>{testingSmtp ? 'Sende Test-Mail…' : 'Test-E-Mail senden'}</span>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Test SMTP Delivery */}
|
||||||
|
<div className="p-4 bg-canvas border border-border rounded-card space-y-2">
|
||||||
|
<h3 className="font-bold text-main">SMTP-Verbindung testen</h3>
|
||||||
|
<p className="text-[11px] text-muted">
|
||||||
|
Sendet sofort eine Test-E-Mail mit den oben eingetragenen Daten.
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-col sm:flex-row gap-2">
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={testSmtpEmail}
|
||||||
|
onChange={(e) => setTestSmtpEmail(e.target.value)}
|
||||||
|
placeholder="empfaenger@beispiel.de"
|
||||||
|
className="flex-1 h-8 px-3 input-field"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleTestSmtp}
|
||||||
|
disabled={testingSmtp || !testSmtpEmail.trim()}
|
||||||
|
className="btn-action h-8 px-4 text-xs flex items-center justify-center gap-1.5 disabled:opacity-40"
|
||||||
|
>
|
||||||
|
<Send className="w-3.5 h-3.5 text-accent" />
|
||||||
|
<span>{testingSmtp ? 'Sende Test-Mail…' : 'Test-E-Mail senden'}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</fieldset>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -97,8 +97,16 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
const [showAbout, setShowAbout] = useState(false);
|
const [showAbout, setShowAbout] = useState(false);
|
||||||
|
// The public demo sends nothing and keeps the shared accounts intact (FLAW-33)
|
||||||
|
const [demoMode, setDemoMode] = useState(false);
|
||||||
useEffect(() => {
|
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 ---
|
// --- Custom Theme Editor State ---
|
||||||
@@ -628,7 +636,9 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
|||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
required
|
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>
|
||||||
</div>
|
</div>
|
||||||
@@ -641,9 +651,10 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
|||||||
type="password"
|
type="password"
|
||||||
value={newPassword}
|
value={newPassword}
|
||||||
onChange={(e) => setNewPassword(e.target.value)}
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
placeholder="Mindestens 8 Zeichen"
|
placeholder={demoMode ? 'In der Demo deaktiviert' : 'Mindestens 8 Zeichen'}
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
className="w-full h-9 px-3 input-field"
|
disabled={demoMode}
|
||||||
|
className="w-full h-9 px-3 input-field disabled:opacity-60"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -693,8 +704,9 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleRevokeAllSessions}
|
onClick={handleRevokeAllSessions}
|
||||||
disabled={revokingSessions}
|
disabled={demoMode || revokingSessions}
|
||||||
className="btn-danger h-8 px-3 text-xs shrink-0 cursor-pointer"
|
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'}
|
{revokingSessions ? 'Beende…' : 'Alle Sitzungen beenden'}
|
||||||
</button>
|
</button>
|
||||||
@@ -735,7 +747,8 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => openDanger('account')}
|
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"
|
className="btn-danger h-11 sm:h-8 px-3 text-xs shrink-0 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
Konto löschen
|
Konto löschen
|
||||||
@@ -968,6 +981,13 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</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 && (
|
{alarmMsg && (
|
||||||
<div
|
<div
|
||||||
className={`flex items-center gap-2 ${
|
className={`flex items-center gap-2 ${
|
||||||
@@ -1034,8 +1054,8 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleTestChannel('email')}
|
onClick={() => handleTestChannel('email')}
|
||||||
disabled={testingChannel === 'email'}
|
disabled={demoMode || testingChannel === 'email'}
|
||||||
className="btn-action h-8 px-3 text-xs flex items-center gap-1.5 flex-shrink-0"
|
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" />
|
<Send className="w-3 h-3 text-accent" />
|
||||||
<span>{testingChannel === 'email' ? 'Sende…' : 'E-Mail testen'}</span>
|
<span>{testingChannel === 'email' ? 'Sende…' : 'E-Mail testen'}</span>
|
||||||
@@ -1083,8 +1103,8 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleTestChannel('discord')}
|
onClick={() => handleTestChannel('discord')}
|
||||||
disabled={testingChannel === 'discord'}
|
disabled={demoMode || testingChannel === 'discord'}
|
||||||
className="btn-action h-8 px-3 text-xs flex items-center gap-1.5 flex-shrink-0"
|
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" />
|
<Send className="w-3 h-3 text-accent" />
|
||||||
<span>{testingChannel === 'discord' ? 'Sende…' : 'Discord testen'}</span>
|
<span>{testingChannel === 'discord' ? 'Sende…' : 'Discord testen'}</span>
|
||||||
@@ -1132,8 +1152,8 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleTestChannel('webhook')}
|
onClick={() => handleTestChannel('webhook')}
|
||||||
disabled={testingChannel === 'webhook'}
|
disabled={demoMode || testingChannel === 'webhook'}
|
||||||
className="btn-action h-8 px-3 text-xs flex items-center gap-1.5 flex-shrink-0"
|
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" />
|
<Send className="w-3 h-3 text-accent" />
|
||||||
<span>{testingChannel === 'webhook' ? 'Sende…' : 'Webhook testen'}</span>
|
<span>{testingChannel === 'webhook' ? 'Sende…' : 'Webhook testen'}</span>
|
||||||
|
|||||||
@@ -499,3 +499,9 @@ body {
|
|||||||
padding-bottom: 6px !important;
|
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;
|
||||||
|
}
|
||||||
@@ -180,7 +180,8 @@ export interface SystemSettings {
|
|||||||
smtpHost?: string;
|
smtpHost?: string;
|
||||||
smtpPort?: string; // sent as text, like the server stores it
|
smtpPort?: string; // sent as text, like the server stores it
|
||||||
smtpUser?: string;
|
smtpUser?: string;
|
||||||
smtpPass?: string;
|
smtpPass?: string; // write only: the server never sends it back, empty keeps the stored one
|
||||||
|
smtpPassSet?: boolean;
|
||||||
smtpFrom?: string;
|
smtpFrom?: string;
|
||||||
smtpTls?: boolean;
|
smtpTls?: boolean;
|
||||||
smtpSkipVerify?: boolean;
|
smtpSkipVerify?: boolean;
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ import (
|
|||||||
// --- MULTI-CHANNEL ALARM DISPATCHER (EMAIL, DISCORD, CUSTOM WEBHOOK) ---
|
// --- MULTI-CHANNEL ALARM DISPATCHER (EMAIL, DISCORD, CUSTOM WEBHOOK) ---
|
||||||
|
|
||||||
func (s *Server) sendEmailNotification(toEmail, subject, bodyText string) error {
|
func (s *Server) sendEmailNotification(toEmail, subject, bodyText string) error {
|
||||||
|
if s.cfg.DemoMode {
|
||||||
|
return errDemoOutgoing
|
||||||
|
}
|
||||||
cfg, err := s.smtpSettings()
|
cfg, err := s.smtpSettings()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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 {
|
func (s *Server) sendDiscordNotification(webhookURL, title, description, dueDate, priority string) error {
|
||||||
|
if s.cfg.DemoMode {
|
||||||
|
return errDemoOutgoing
|
||||||
|
}
|
||||||
allowPrivate := s.allowPrivateWebhooks()
|
allowPrivate := s.allowPrivateWebhooks()
|
||||||
if err := validateWebhookURL(webhookURL, true, allowPrivate); err != nil {
|
if err := validateWebhookURL(webhookURL, true, allowPrivate); err != nil {
|
||||||
return err
|
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 {
|
func (s *Server) sendCustomWebhookNotification(webhookURL, event, title, notes, dueDate, priority string) error {
|
||||||
|
if s.cfg.DemoMode {
|
||||||
|
return errDemoOutgoing
|
||||||
|
}
|
||||||
allowPrivate := s.allowPrivateWebhooks()
|
allowPrivate := s.allowPrivateWebhooks()
|
||||||
if err := validateWebhookURL(webhookURL, false, allowPrivate); err != nil {
|
if err := validateWebhookURL(webhookURL, false, allowPrivate); err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -162,7 +162,21 @@ func serveBackupFile(w http.ResponseWriter, r *http.Request, dir, name, contentT
|
|||||||
writeError(w, http.StatusNotFound, err.Error())
|
writeError(w, http.StatusNotFound, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
allowLongTransfer(w)
|
||||||
w.Header().Set("Content-Type", contentType)
|
w.Header().Set("Content-Type", contentType)
|
||||||
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, name))
|
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, name))
|
||||||
http.ServeFile(w, r, path)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -75,3 +76,18 @@ func (s *Server) startDemoResetTicker() {
|
|||||||
s.resetDemo()
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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).
|
// handleExportMarkdown streams an Obsidian-compatible ZIP of the user's workspace (no temp files).
|
||||||
func (s *Server) handleExportMarkdown(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleExportMarkdown(w http.ResponseWriter, r *http.Request) {
|
||||||
userID := auth.GetUserID(r.Context())
|
userID := auth.GetUserID(r.Context())
|
||||||
|
allowLongTransfer(w)
|
||||||
w.Header().Set("Content-Type", "application/zip")
|
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")))
|
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 {
|
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) {
|
func (s *Server) handleExport(w http.ResponseWriter, r *http.Request) {
|
||||||
userID := auth.GetUserID(r.Context())
|
userID := auth.GetUserID(r.Context())
|
||||||
|
allowLongTransfer(w)
|
||||||
exportData, err := s.store.ExportUserData(userID)
|
exportData, err := s.store.ExportUserData(userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
|||||||
@@ -246,6 +246,7 @@ func (s *Server) handleAdminDeleteUser(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
s.removeUserBackups(targetUserID)
|
||||||
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
|
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())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
settings.SMTPConfiguredViaEnv = strings.TrimSpace(s.cfg.SMTPHost) != ""
|
writeJSON(w, http.StatusOK, s.settingsForBrowser(settings))
|
||||||
writeJSON(w, http.StatusOK, 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) {
|
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")
|
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||||||
return
|
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()
|
previousMode := s.store.SharingMode()
|
||||||
if err := s.store.UpdateSystemSettings(&settings); err != nil {
|
if err := s.store.UpdateSystemSettings(&settings); err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
@@ -324,8 +339,7 @@ func (s *Server) handleAdminUpdateSettings(w http.ResponseWriter, r *http.Reques
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
settings.SMTPConfiguredViaEnv = strings.TrimSpace(s.cfg.SMTPHost) != ""
|
writeJSON(w, http.StatusOK, s.settingsForBrowser(&settings))
|
||||||
writeJSON(w, http.StatusOK, settings)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleAdminTestSMTP(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleAdminTestSMTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@@ -385,12 +385,14 @@ func (s *Server) handleDeleteOwnAccount(w http.ResponseWriter, r *http.Request)
|
|||||||
writeError(w, status, err.Error())
|
writeError(w, status, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
s.removeUserBackups(user.ID)
|
||||||
slog.Info("Benutzer hat sein Konto gelöscht", "user_id", user.ID)
|
slog.Info("Benutzer hat sein Konto gelöscht", "user_id", user.ID)
|
||||||
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
|
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
|
||||||
}
|
}
|
||||||
|
|
||||||
// importUpload reads the multipart field "file" (max. storage.MaxImportUploadBytes).
|
// importUpload reads the multipart field "file" (max. storage.MaxImportUploadBytes).
|
||||||
func importUpload(w http.ResponseWriter, r *http.Request) ([]byte, string, error) {
|
func importUpload(w http.ResponseWriter, r *http.Request) ([]byte, string, error) {
|
||||||
|
allowLongTransfer(w)
|
||||||
r.Body = http.MaxBytesReader(w, r.Body, storage.MaxImportUploadBytes+1<<20)
|
r.Body = http.MaxBytesReader(w, r.Body, storage.MaxImportUploadBytes+1<<20)
|
||||||
file, header, err := r.FormFile("file")
|
file, header, err := r.FormFile("file")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -456,6 +458,10 @@ func (s *Server) handleUpdateUserSettings(w http.ResponseWriter, r *http.Request
|
|||||||
}
|
}
|
||||||
changesPassword := req.Password != nil && strings.TrimSpace(*req.Password) != ""
|
changesPassword := req.Password != nil && strings.TrimSpace(*req.Password) != ""
|
||||||
changesEmail := req.Email != nil && !strings.EqualFold(strings.TrimSpace(*req.Email), before.Email)
|
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) {
|
if (changesPassword || changesEmail) && !s.checkCurrentPassword(w, r, before, req.CurrentPassword) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
// 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) {
|
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")
|
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 == "" {
|
if token == "" {
|
||||||
token = r.Header.Get("X-API-Key")
|
token = r.Header.Get("X-API-Key")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+13
-12
@@ -51,12 +51,12 @@ func NewServer(cfg *config.Config, store *storage.Store, wsHub *ws.Hub, staticFS
|
|||||||
store.OnTaskCreated = func(th *domain.Thought) {
|
store.OnTaskCreated = func(th *domain.Thought) {
|
||||||
srv.wsHub.BroadcastToUsers(srv.taskAudience(th), "TASK_CREATED", th)
|
srv.wsHub.BroadcastToUsers(srv.taskAudience(th), "TASK_CREATED", th)
|
||||||
}
|
}
|
||||||
go srv.startAlarmTicker()
|
|
||||||
go srv.startCompaction()
|
go srv.startCompaction()
|
||||||
if cfg.DemoMode && cfg.DemoResetMinutes > 0 {
|
if cfg.DemoMode && cfg.DemoResetMinutes > 0 {
|
||||||
go srv.startDemoResetTicker()
|
go srv.startDemoResetTicker()
|
||||||
}
|
}
|
||||||
if !cfg.DemoMode {
|
if !cfg.DemoMode {
|
||||||
|
go srv.startAlarmTicker() // the demo sends nothing (FLAW-33)
|
||||||
go srv.startBackupScheduler()
|
go srv.startBackupScheduler()
|
||||||
}
|
}
|
||||||
return srv
|
return srv
|
||||||
@@ -84,6 +84,7 @@ func (s *Server) Router() http.Handler {
|
|||||||
|
|
||||||
r.Use(s.clientIPMiddleware)
|
r.Use(s.clientIPMiddleware)
|
||||||
r.Use(s.securityHeaders)
|
r.Use(s.securityHeaders)
|
||||||
|
r.Use(bodyLimit)
|
||||||
r.Use(s.demoResetGate)
|
r.Use(s.demoResetGate)
|
||||||
r.Use(middleware.Recoverer)
|
r.Use(middleware.Recoverer)
|
||||||
r.Use(s.apiLoggingMiddleware)
|
r.Use(s.apiLoggingMiddleware)
|
||||||
@@ -126,11 +127,11 @@ func (s *Server) Router() http.Handler {
|
|||||||
r.Post("/api/v1/events/rooms", s.handleSSERooms)
|
r.Post("/api/v1/events/rooms", s.handleSSERooms)
|
||||||
r.Post("/api/v1/auth/stream-ticket", s.handleStreamTicket)
|
r.Post("/api/v1/auth/stream-ticket", s.handleStreamTicket)
|
||||||
r.Get("/api/v1/auth/sessions", s.handleListSessions)
|
r.Get("/api/v1/auth/sessions", s.handleListSessions)
|
||||||
r.Delete("/api/v1/auth/sessions/{id}", s.handleEndSession)
|
r.Delete("/api/v1/auth/sessions/{id}", s.notInDemo(s.handleEndSession))
|
||||||
r.Post("/api/v1/auth/logout", s.handleLogout)
|
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.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/users/directory", s.handleGetUsersDirectory)
|
||||||
r.Get("/api/v1/collaborators", s.handleGetCollaborators)
|
r.Get("/api/v1/collaborators", s.handleGetCollaborators)
|
||||||
r.Get("/api/v1/stats", s.handleStats)
|
r.Get("/api/v1/stats", s.handleStats)
|
||||||
@@ -142,7 +143,7 @@ func (s *Server) Router() http.Handler {
|
|||||||
r.Get("/api/v1/export/markdown", s.handleExportMarkdown)
|
r.Get("/api/v1/export/markdown", s.handleExportMarkdown)
|
||||||
r.Post("/api/v1/import/mindsift", s.handleImportMindSift)
|
r.Post("/api/v1/import/mindsift", s.handleImportMindSift)
|
||||||
r.Delete("/api/v1/user/data", s.handlePurgeOwnData)
|
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.Post("/api/v1/import/obsidian", s.handleImportObsidian)
|
||||||
r.Get("/api/v1/themes", s.handleGetThemes)
|
r.Get("/api/v1/themes", s.handleGetThemes)
|
||||||
r.Post("/api/v1/themes", s.handleSaveTheme)
|
r.Post("/api/v1/themes", s.handleSaveTheme)
|
||||||
@@ -251,10 +252,10 @@ func (s *Server) Router() http.Handler {
|
|||||||
r.Group(func(r chi.Router) {
|
r.Group(func(r chi.Router) {
|
||||||
r.Use(s.staffMiddleware)
|
r.Use(s.staffMiddleware)
|
||||||
r.Get("/api/v1/admin/users", s.handleAdminGetUsers)
|
r.Get("/api/v1/admin/users", s.handleAdminGetUsers)
|
||||||
r.Post("/api/v1/admin/users", s.handleAdminCreateUser)
|
r.Post("/api/v1/admin/users", s.notInDemo(s.handleAdminCreateUser))
|
||||||
r.Patch("/api/v1/admin/users/{id}", s.handleAdminUpdateUser)
|
r.Patch("/api/v1/admin/users/{id}", s.notInDemo(s.handleAdminUpdateUser))
|
||||||
r.Put("/api/v1/admin/users/{id}/password", s.handleAdminResetPassword)
|
r.Put("/api/v1/admin/users/{id}/password", s.notInDemo(s.handleAdminResetPassword))
|
||||||
r.Delete("/api/v1/admin/users/{id}", s.handleAdminDeleteUser)
|
r.Delete("/api/v1/admin/users/{id}", s.notInDemo(s.handleAdminDeleteUser))
|
||||||
|
|
||||||
r.Get("/api/v1/admin/invites", s.handleAdminGetInvites)
|
r.Get("/api/v1/admin/invites", s.handleAdminGetInvites)
|
||||||
r.Post("/api/v1/admin/invites", s.handleAdminCreateInvite)
|
r.Post("/api/v1/admin/invites", s.handleAdminCreateInvite)
|
||||||
@@ -266,9 +267,9 @@ func (s *Server) Router() http.Handler {
|
|||||||
r.Use(s.adminMiddleware)
|
r.Use(s.adminMiddleware)
|
||||||
r.Get("/api/v1/admin/settings", s.handleAdminGetSettings)
|
r.Get("/api/v1/admin/settings", s.handleAdminGetSettings)
|
||||||
r.Get("/api/v1/admin/backups", s.handleAdminListBackups)
|
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.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)
|
r.Get("/api/v1/admin/settings/sharing-impact", s.handleAdminSharingImpact)
|
||||||
|
|
||||||
// Organizations decide who can see whom; only server admins manage them
|
// Organizations decide who can see whom; only server admins manage them
|
||||||
@@ -277,7 +278,7 @@ func (s *Server) Router() http.Handler {
|
|||||||
r.Put("/api/v1/admin/organizations/{id}", s.handleAdminRenameOrganization)
|
r.Put("/api/v1/admin/organizations/{id}", s.handleAdminRenameOrganization)
|
||||||
r.Delete("/api/v1/admin/organizations/{id}", s.handleAdminDeleteOrganization)
|
r.Delete("/api/v1/admin/organizations/{id}", s.handleAdminDeleteOrganization)
|
||||||
r.Put("/api/v1/admin/users/{id}/organization", s.handleAdminSetUserOrganization)
|
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))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -58,6 +58,16 @@ func (s *Server) handleListSessions(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
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)
|
writeJSON(w, http.StatusOK, list)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -285,10 +285,13 @@ type SystemSettings struct {
|
|||||||
SMTPHost string `json:"smtpHost"`
|
SMTPHost string `json:"smtpHost"`
|
||||||
SMTPPort string `json:"smtpPort"`
|
SMTPPort string `json:"smtpPort"`
|
||||||
SMTPUser string `json:"smtpUser"`
|
SMTPUser string `json:"smtpUser"`
|
||||||
SMTPPass string `json:"smtpPass"`
|
// SMTPPass is never sent to the browser (FLAW-34): reads leave it empty and set SMTPPassSet;
|
||||||
SMTPFrom string `json:"smtpFrom"`
|
// writing it empty keeps the stored password
|
||||||
SMTPTLS bool `json:"smtpTls"`
|
SMTPPass string `json:"smtpPass"`
|
||||||
SMTPSkipVerify bool `json:"smtpSkipVerify"` // accept self-signed certificates
|
SMTPPassSet bool `json:"smtpPassSet,omitempty"`
|
||||||
|
SMTPFrom string `json:"smtpFrom"`
|
||||||
|
SMTPTLS bool `json:"smtpTls"`
|
||||||
|
SMTPSkipVerify bool `json:"smtpSkipVerify"` // accept self-signed certificates
|
||||||
// AllowPrivateWebhooks lets alarm webhooks reach LAN addresses (and plain http); localhost and cloud
|
// AllowPrivateWebhooks lets alarm webhooks reach LAN addresses (and plain http); localhost and cloud
|
||||||
// metadata stay blocked
|
// metadata stay blocked
|
||||||
AllowPrivateWebhooks bool `json:"allowPrivateWebhooks"`
|
AllowPrivateWebhooks bool `json:"allowPrivateWebhooks"`
|
||||||
|
|||||||
Reference in new issue
Block a user