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

- 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:
sandrodachandClaude Opus 5.5 committed 2026-10-29 19:43:23 +01:00
1 parent 3709282a4f
commit 9d9698186c
21 files changed
+631 -304

No files matched your search

+8
View File
@@ -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).
- 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).
### 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.
### Added
@@ -24,6 +30,8 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V
### 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.
+3 -3
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
@@ -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/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 |
@@ -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 |
| 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) |
+1 -1
View File
@@ -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`) |
| `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) |
+12 -6
View File
@@ -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-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) | 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-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 |
@@ -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.
- **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.
@@ -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,300 +1028,305 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
{/* SECTION 3: SERVER & SHARING-ISOLATION (SERVER-ADMIN ONLY) */}
{/* ================================================================= */}
{section === 'server' && isServerAdmin && (
<div className="space-y-6">
<div className="border-b border-border pb-3">
<h2 className="text-sm font-bold text-main">
Server-Grundeinstellungen & Benutzer-Sichtbarkeit
</h2>
<p className="text-muted mt-0.5">
Als Server-Admin bestimmst du zentral, ob Nutzer komplett isoliert arbeiten, sich nur
innerhalb ihrer Organisation sehen oder ob alle Nutzer für alle sichtbar sind.
</p>
</div>
<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>
<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">
Server-Grundeinstellungen & Benutzer-Sichtbarkeit
</h2>
<p className="text-muted mt-0.5">
Als Server-Admin bestimmst du zentral, ob Nutzer komplett isoliert arbeiten, sich nur
innerhalb ihrer Organisation sehen oder ob alle Nutzer für alle sichtbar sind.
</p>
</div>
{/* Sharing Isolation Cards */}
<div className="space-y-2">
<label className="block text-muted font-semibold flex items-center gap-1.5">
<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) => (
<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
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'
}`}
onClick={() => handleSaveSystemSettings()}
className="btn-primary h-9 px-4 text-xs"
>
<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
Speichern
</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
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 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>
{/* Sharing Isolation Cards */}
<div className="space-y-2">
<label className="block text-muted font-semibold flex items-center gap-1.5">
<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
checked={settings.allowRegistration}
onChange={(allowRegistration) => handleSaveSystemSettings({ allowRegistration })}
title={settings.allowRegistration ? 'Selbstregistrierung deaktivieren' : 'Selbstregistrierung aktivieren'}
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 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>
</fieldset>
)}
{/* ================================================================= */}
{/* SECTION 4: E-MAIL / SMTP-SERVER (SERVER-ADMIN ONLY) */}
{/* ================================================================= */}
{section === 'smtp' && isServerAdmin && (
<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>
<h2 className="text-sm font-bold text-main">
E-Mail / SMTP-Server für Aufgaben-Alarme
</h2>
<p className="text-muted mt-0.5">
Konfiguriere den Postausgangsserver per Docker-Compose Environment-Variablen oder
direkt hier über die Oberfläche (wird persistent in der SQLite-Datenbank gespeichert).
</p>
<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>
<h2 className="text-sm font-bold text-main">
E-Mail / SMTP-Server für Aufgaben-Alarme
</h2>
<p className="text-muted mt-0.5">
Konfiguriere den Postausgangsserver per Docker-Compose Environment-Variablen oder
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>
{settings.smtpConfiguredViaEnv && (
<span className="badge-ok text-[11px] flex-shrink-0">
Docker Env erkannt
</span>
)}
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-muted font-medium mb-1">
SMTP Host (`SMTP_HOST`)
</label>
<input
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 className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-muted font-medium mb-1">
SMTP Host (`SMTP_HOST`)
</label>
<input
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 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>
<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={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 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"
/>
<div className="flex justify-end pt-2 border-t border-border">
<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"
onClick={() => handleSaveSystemSettings()}
className="btn-primary h-9 px-5 text-xs font-semibold"
>
<Send className="w-3.5 h-3.5 text-accent" />
<span>{testingSmtp ? 'Sende Test-Mail…' : 'Test-E-Mail senden'}</span>
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
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>
</fieldset>
)}
</div>
</div>
@@ -97,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 ---
@@ -628,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>
@@ -641,9 +651,10 @@ 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"
className="w-full h-9 px-3 input-field"
disabled={demoMode}
className="w-full h-9 px-3 input-field disabled:opacity-60"
/>
</div>
@@ -693,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>
@@ -735,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
@@ -968,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 ${
@@ -1034,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>
@@ -1083,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>
@@ -1132,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>
+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;
}
+2 -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;
+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) {
+6
View File
@@ -385,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 {
@@ -456,6 +458,10 @@ func (s *Server) handleUpdateUserSettings(w http.ResponseWriter, r *http.Request
}
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
}
+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")
}
+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)
}
}
+13 -12
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
@@ -84,6 +84,7 @@ func (s *Server) Router() http.Handler {
r.Use(s.clientIPMiddleware)
r.Use(s.securityHeaders)
r.Use(bodyLimit)
r.Use(s.demoResetGate)
r.Use(middleware.Recoverer)
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/auth/stream-ticket", s.handleStreamTicket)
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/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)
@@ -142,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)
@@ -251,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)
@@ -266,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
@@ -277,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))
})
})
+10
View File
@@ -58,6 +58,16 @@ func (s *Server) handleListSessions(w http.ResponseWriter, r *http.Request) {
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)
}
+7 -4
View File
@@ -285,10 +285,13 @@ type SystemSettings struct {
SMTPHost string `json:"smtpHost"`
SMTPPort string `json:"smtpPort"`
SMTPUser string `json:"smtpUser"`
SMTPPass string `json:"smtpPass"`
SMTPFrom string `json:"smtpFrom"`
SMTPTLS bool `json:"smtpTls"`
SMTPSkipVerify bool `json:"smtpSkipVerify"` // accept self-signed certificates
// 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
// AllowPrivateWebhooks lets alarm webhooks reach LAN addresses (and plain http); localhost and cloud
// metadata stay blocked
AllowPrivateWebhooks bool `json:"allowPrivateWebhooks"`