From 51a41df14b9d029b4e08d1645879bc7b53cc9786 Mon Sep 17 00:00:00 2001 From: Sandro Date: Thu, 29 Oct 2026 09:15:20 +0100 Subject: [PATCH 01/26] feat(theme): revise Polarized to classic green/teal dark palette --- Doku/theming.md | 2 +- frontend/src/lib/__tests__/theme.test.ts | 2 +- frontend/src/lib/theme.ts | 12 +++---- frontend/src/themes/theme-polarized.css | 46 ++++++++++++------------ 4 files changed, 31 insertions(+), 31 deletions(-) diff --git a/Doku/theming.md b/Doku/theming.md index e48e7d5..c4161c4 100644 --- a/Doku/theming.md +++ b/Doku/theming.md @@ -82,7 +82,7 @@ Jede Theme-Datei gliedert sich in sechs exakt definierte Bereiche: | `light` | [`theme-light.css`](../frontend/src/themes/theme-light.css) | Daylight Clean White / Minimalistisch | Modern dezent (`4px` Cards, `4px` Buttons) | | `high-contrast` | [`theme-high-contrast.css`](../frontend/src/themes/theme-high-contrast.css) | Reines Schwarz/Weiß mit AAA-Kontrast | Streng eckig (`0px`, kräftige Rahmen) | | `soft` | [`theme-soft.css`](../frontend/src/themes/theme-soft.css) | Warmes Soft Slate mit Indigo-Akzent | Weich gerundet (`12px` Cards, `8px` Buttons) | -| `polarized` | [`theme-polarized.css`](../frontend/src/themes/theme-polarized.css) | Tiefdunkles Obsidian mit elektrischem Ultraviolett | Streng eckig (`0px` Cards, `2px` Buttons) | +| `polarized` | [`theme-polarized.css`](../frontend/src/themes/theme-polarized.css) | Tiefdunkles Obsidian mit elektrischem Grün/Türkis | Streng eckig (`0px` Cards, `2px` Buttons) | | `suri` | [`theme-suri.css`](../frontend/src/themes/theme-suri.css) | Warmes Mitternachts-Pflaume mit samtiger Orchidee | Weich gerundet (`10px` Cards, `6px` Buttons) | | *Template* | [`theme-template.css`](../frontend/src/themes/theme-template.css) | Vollständig dokumentierte Kopiervorlage | Frei anpassbar | diff --git a/frontend/src/lib/__tests__/theme.test.ts b/frontend/src/lib/__tests__/theme.test.ts index 6eb2ac4..89d1b35 100644 --- a/frontend/src/lib/__tests__/theme.test.ts +++ b/frontend/src/lib/__tests__/theme.test.ts @@ -53,7 +53,7 @@ describe('custom themes', () => { expect(opt.isRounded).toBe(true); }); - it('supports polarized and suri built-in purple dark themes', () => { + it('supports polarized and suri built-in dark themes', () => { setActiveTheme('polarized'); expect(getActiveTheme()).toBe('polarized'); expect(document.documentElement.getAttribute('data-theme')).toBe('polarized'); diff --git a/frontend/src/lib/theme.ts b/frontend/src/lib/theme.ts index 22bcc3b..2a36c78 100644 --- a/frontend/src/lib/theme.ts +++ b/frontend/src/lib/theme.ts @@ -70,13 +70,13 @@ export const AVAILABLE_THEMES: ThemeOption[] = [ }, { id: 'polarized', - name: 'Polarized (Ultraviolet)', - subtitle: 'Tiefdunkles Obsidian mit elektrischem Violett (0px)', + name: 'Polarized (Emerald / Teal)', + subtitle: 'Tiefdunkles Obsidian mit elektrischem Türkis/Grün (0px)', file: 'src/themes/theme-polarized.css', - previewBg: '#0d0b14', - previewPanel: '#151221', - previewAccent: '#a855f7', - previewText: '#f5f3ff', + previewBg: '#090e11', + previewPanel: '#10181c', + previewAccent: '#14b8a6', + previewText: '#f0fdfa', isRounded: false, }, { diff --git a/frontend/src/themes/theme-polarized.css b/frontend/src/themes/theme-polarized.css index 80ef704..951b3c8 100644 --- a/frontend/src/themes/theme-polarized.css +++ b/frontend/src/themes/theme-polarized.css @@ -1,37 +1,37 @@ /* ========================================================================== - MINDSIFT THEME: POLARIZED (Ultraviolet / Obsidian) - Tiefdunkles Obsidian mit elektrischem Violett-Akzent & scharfen Kanten. + MINDSIFT THEME: POLARIZED (Emerald / Teal) + Tiefdunkles Obsidian mit elektrischem Grün/Türkis-Akzent & scharfen Kanten. ========================================================================== */ :root[data-theme="polarized"] { - /* --- 1. AKZENTFARBE (Elektrisches Ultraviolett) --- */ - --accent-primary: #a855f7; - --accent-hover: #c084fc; - --accent-dim: rgba(168, 85, 247, 0.15); - --accent-border: rgba(192, 132, 252, 0.45); + /* --- 1. AKZENTFARBE (Elektrisches Grün / Türkis) --- */ + --accent-primary: #14b8a6; + --accent-hover: #2dd4bf; + --accent-dim: rgba(20, 184, 166, 0.16); + --accent-border: rgba(45, 212, 191, 0.45); --text-on-accent: #ffffff; /* --- 2. OBERFLÄCHEN & HINTERGRÜNDE --- */ - --bg-app: #0d0b14; - --bg-panel: #151221; - --bg-panel-elevated: #1d182e; - --bg-panel-hover: #26203c; - --bg-input: #0f0d18; - --bg-overlay: rgba(6, 4, 10, 0.78); - --bg-code: #08060d; + --bg-app: #090e11; + --bg-panel: #10181c; + --bg-panel-elevated: #162127; + --bg-panel-hover: #1e2c34; + --bg-input: #0c1317; + --bg-overlay: rgba(4, 7, 9, 0.78); + --bg-code: #070b0e; /* --- 3. TEXT & KONTRASTE --- */ - --text-main: #f5f3ff; - --text-muted: #a79fc2; - --text-faint: #6d6585; + --text-main: #f0fdfa; + --text-muted: #8da6a9; + --text-faint: #5a7074; /* --- 4. RAHMEN, LINIEN & SCHATTEN --- */ - --border-color: rgba(168, 85, 247, 0.14); - --border-subtle: rgba(168, 85, 247, 0.07); - --border-strong: rgba(192, 132, 252, 0.30); - --shadow-panel: 0 10px 30px rgba(5, 3, 10, 0.55); - --scrollbar-thumb: rgba(168, 85, 247, 0.22); - --scrollbar-thumb-hover: rgba(192, 132, 252, 0.40); + --border-color: rgba(20, 184, 166, 0.16); + --border-subtle: rgba(20, 184, 166, 0.08); + --border-strong: rgba(45, 212, 191, 0.32); + --shadow-panel: 0 10px 30px rgba(3, 8, 10, 0.55); + --scrollbar-thumb: rgba(20, 184, 166, 0.22); + --scrollbar-thumb-hover: rgba(45, 212, 191, 0.42); /* --- 5. STATUS- & SIGNALFARBEN --- */ --color-success: #10b981; From f8989d0c4418f28c1bf2b58606efe9a61692c3b4 Mon Sep 17 00:00:00 2001 From: Sandro Date: Thu, 29 Oct 2026 11:08:39 +0100 Subject: [PATCH 02/26] fix(theme): readable text on the Polarized teal accent White on #14b8a6 reaches only 2.5:1; dark teal #042f2e gives 5.8:1 for primary buttons, counters and active filters. Co-Authored-By: Claude Opus 5.5 --- frontend/src/themes/theme-polarized.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/themes/theme-polarized.css b/frontend/src/themes/theme-polarized.css index 951b3c8..3f6d5dc 100644 --- a/frontend/src/themes/theme-polarized.css +++ b/frontend/src/themes/theme-polarized.css @@ -9,7 +9,7 @@ --accent-hover: #2dd4bf; --accent-dim: rgba(20, 184, 166, 0.16); --accent-border: rgba(45, 212, 191, 0.45); - --text-on-accent: #ffffff; + --text-on-accent: #042f2e; /* dunkel: Weiß auf Türkis erreicht nur 2,5:1 */ /* --- 2. OBERFLÄCHEN & HINTERGRÜNDE --- */ --bg-app: #090e11; From b5f926bcf71bd4db299e6c25881e8adfa1c52537 Mon Sep 17 00:00:00 2001 From: Sandro Date: Thu, 29 Oct 2026 12:20:46 +0100 Subject: [PATCH 03/26] fix(auth): new passwords need at least 8 characters (FLAW-09) auth.ValidatePassword checks every way a password is set: setup, registration, profile, admin create and reset, reset by mail. Same rule in the browser before sending (lib/password.ts), with our own German message instead of the browser popup. Existing shorter passwords still log in. Profile and admin reset no longer trim the password, which made passwords with surrounding spaces unusable for login. Co-Authored-By: Claude Opus 5.5 --- Doku/api/rest.md | 2 +- Doku/architecture.md | 2 +- Doku/flaws.md | 3 +- .../src/features/auth/SetupAndAuthView.tsx | 29 ++++++++++++++----- .../features/settings/AdminSettingsView.tsx | 19 +++++++++--- .../features/settings/UserSettingsView.tsx | 11 +++++-- frontend/src/lib/password.ts | 8 +++++ internal/api/handlers_admin.go | 14 +++++++-- internal/api/handlers_auth.go | 20 ++++++++++--- internal/api/password_reset.go | 4 +-- internal/api/server_test.go | 11 ++++++- internal/auth/auth.go | 23 +++++++++++++++ internal/auth/auth_test.go | 22 ++++++++++++++ 13 files changed, 142 insertions(+), 26 deletions(-) create mode 100644 frontend/src/lib/password.ts create mode 100644 internal/auth/auth_test.go diff --git a/Doku/api/rest.md b/Doku/api/rest.md index 6c3ac43..374827a 100644 --- a/Doku/api/rest.md +++ b/Doku/api/rest.md @@ -13,7 +13,7 @@ Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}` | POST | `/auth/register` | Registrierung (`email`, `password`, `displayName`, `inviteCode`); nur mit Code oder wenn freigegeben | | POST | `/auth/login` | Anmeldung (`login`/`email`/`username`, `password`) → `{token, user}` | | POST | `/auth/forgot-password` | `{email}` → immer `{ok: true}`; Mail mit Link nur, wenn das Konto existiert. `503`, wenn SMTP oder `BASE_URL` fehlen | -| POST | `/auth/reset-password` | `{token, password}` (mind. 4 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 | `/webhooks/ingest` | Aufgabe per Webhook anlegen, Token als `?token=`, `X-API-Key` oder Bearer. Body: `title`/`content`/`text`/`message`, `priority`, `source`, `tags`, `dueDate`; Klartext-Body wird als Inhalt genommen | | GET | `/ws`, `/ws/spaces/{id}`, `/ws/lists/{id}` | WebSocket, siehe [websocket.md](websocket.md); Token als `?token=` nötig | diff --git a/Doku/architecture.md b/Doku/architecture.md index fcb0d1d..7c66ea5 100644 --- a/Doku/architecture.md +++ b/Doku/architecture.md @@ -74,7 +74,7 @@ Hinweis: `DATABASE_URL` mit `postgres…` führt dazu, dass der Standardpfad in ## Authentifizierung - **Login** mit E-Mail, Benutzername (aus dem E-Mail-Präfix) oder `login`. Passwörter: bcrypt (`DefaultCost`). -- **JWT:** HS256, Laufzeit 30 Tage, Claims `userId`, `email`, `displayName`, `tokenVersion`, `jti`. Gültig nur, wenn Token nicht in `revoked_tokens` steht, das Konto aktiv ist und `tokenVersion` mit `users.token_version` übereinstimmt. `token_version` wird bei Passwortänderung, Rollenwechsel, Deaktivierung und „Alle Sitzungen beenden“ erhöht. +- **JWT:** HS256, Laufzeit 30 Tage, Claims `userId`, `email`, `displayName`, `tokenVersion`, `jti`. Gültig nur, wenn Token nicht in `revoked_tokens` steht, das Konto aktiv ist und `tokenVersion` mit `users.token_version` übereinstimmt. Neue Passwörter brauchen mindestens 8 Zeichen (`auth.ValidatePassword`). `token_version` wird bei Passwortänderung, Rollenwechsel, Deaktivierung und „Alle Sitzungen beenden“ erhöht. - **Logout** schreibt den Token-Hash in `revoked_tokens`. - **API-Keys:** `mindsift_sec_`, gespeichert als SHA-256, nur das Präfix bleibt sichtbar. Der Key gilt als Bearer-Token oder in `X-API-Key` und darf nur Daten anliefern (`POST /inbox`, `POST /webhooks/ingest`); alle anderen Endpunkte antworten mit `403` ([FLAW-07](flaws.md)). - **Passwort vergessen:** nur wenn SMTP und `BASE_URL` gesetzt sind (nicht im Demo-Modus; `passwordReset` in `/auth/status`). `POST /auth/forgot-password` antwortet immer gleich und verschickt die Mail im Hintergrund, höchstens alle 2 Minuten pro Konto. Der Link `BASE_URL/?resetToken=…` gilt eine Stunde und einmal; gespeichert wird nur der SHA-256-Hash (`password_reset_tokens`). `POST /auth/reset-password` setzt das Passwort, entwertet alle offenen Links des Kontos und erhöht `token_version` (alle Sitzungen enden). Die App entfernt den Token sofort aus der Adresszeile. diff --git a/Doku/flaws.md b/Doku/flaws.md index effe319..a29c2b9 100644 --- a/Doku/flaws.md +++ b/Doku/flaws.md @@ -18,7 +18,7 @@ Schweregrade: **Hoch** (Datenabfluss oder Rechteumgehung möglich), **Mittel** ( | FLAW-06 | Aufgaben | ~~Jeder Editor einer Aufgabenliste kann sie per `teamId` an weitere Gruppen freigeben~~ | **Behoben** | | FLAW-07 | Auth | ~~Ein API-Key ist ein Vollzugriff~~ – Keys dürfen nur noch einliefern; Scopes fehlen noch | Niedrig | | FLAW-08 | Auth | ~~Webhook-Ingest mit JWT prüft weder Sperrliste noch `token_version` noch `is_active`~~ | **Behoben** | -| FLAW-09 | Auth | Kein Rate-Limit, keine Sperre bei Fehlversuchen, Mindestpasswort 4 Zeichen | Mittel | +| FLAW-09 | Auth | Kein Rate-Limit, keine Sperre bei Fehlversuchen; ~~Mindestpasswort 4 Zeichen~~ | Mittel | | FLAW-10 | Freigaben | ~~`sharing_mode` wird bei Freigaben nicht durchgesetzt; Benutzerverzeichnis leakt alle Konten~~ | **Behoben** | | FLAW-11 | Workspace | ~~Block-Upsert (`ON CONFLICT(id)`) prüft das Dokument nicht~~ | **Behoben** | | FLAW-12 | Benachrichtigungen | ~~`notify_before_minutes` wirkungslos; Alarm feuert nach Umplanung nie wieder~~; kein Backoff bei Fehlern | **Teilweise behoben** | @@ -90,6 +90,7 @@ Schweregrade: **Hoch** (Datenabfluss oder Rechteumgehung möglich), **Mittel** ( - **Maßnahme:** Dieselbe Prüfkette wie in `authMiddleware` verwenden (gemeinsame Funktion statt Kopien; siehe auch `authenticateUserFromReq`). ### FLAW-09: Kein Schutz gegen Brute-Force +- **Teilweise behoben:** Neue Passwörter brauchen überall mindestens 8 Zeichen (Setup, Registrierung, Profil, Admin-Anlage und -Reset, Reset per Mail), geprüft zentral in `auth.ValidatePassword` und vorab im Browser (`lib/password.ts`). Bestehende kürzere Passwörter funktionieren beim Anmelden weiter. Offen: Rate-Limit und Sperre. - **Fundstellen:** `handleLogin`, `handleRegister`, `handleFirstBootSetup`. - **Problem:** Weder Rate-Limit noch Sperre; `middleware.RealIP` vertraut `X-Forwarded-For` ungeprüft. Mindestpasswortlänge ist 4 Zeichen. Der Login-Pfad ist so über das Internet raten- und durchprobierbar. - **Maßnahme:** Rate-Limit je IP und Konto, längeres Mindestpasswort. diff --git a/frontend/src/features/auth/SetupAndAuthView.tsx b/frontend/src/features/auth/SetupAndAuthView.tsx index fb36735..12eecfd 100644 --- a/frontend/src/features/auth/SetupAndAuthView.tsx +++ b/frontend/src/features/auth/SetupAndAuthView.tsx @@ -15,6 +15,7 @@ import { AuthStatus, SharingMode, User } from '../../lib/types'; import { api, triggerHaptic } from '../../lib/api'; import { ToggleSwitch } from '../../components/ToggleSwitch'; import { LegalLinks } from '../../components/LegalLinks'; +import { passwordProblem } from '../../lib/password'; interface SetupAndAuthViewProps { authStatus: AuthStatus; @@ -74,6 +75,12 @@ export const SetupAndAuthView: React.FC = ({ const handleResetPassword = async (e: React.FormEvent) => { e.preventDefault(); setError(null); + const resetProblem = passwordProblem(newPassword); + if (resetProblem) { + setError(resetProblem); + triggerHaptic(25); + return; + } if (newPassword !== newPasswordConfirm) { setError('Die eingegebenen Passwörter stimmen nicht überein.'); triggerHaptic(25); @@ -99,6 +106,12 @@ export const SetupAndAuthView: React.FC = ({ e.preventDefault(); setError(null); + const setupProblem = passwordProblem(setupPassword); + if (setupProblem) { + setError(setupProblem); + triggerHaptic(25); + return; + } if (setupPassword !== setupPasswordConfirm) { setError('Die eingegebenen Passwörter stimmen nicht überein.'); triggerHaptic(25); @@ -143,6 +156,12 @@ export const SetupAndAuthView: React.FC = ({ const handleRegisterOrInvite = async (e: React.FormEvent) => { e.preventDefault(); setError(null); + const registerProblem = passwordProblem(password); + if (registerProblem) { + setError(registerProblem); + triggerHaptic(25); + return; + } setSubmitting(true); triggerHaptic(15); try { @@ -235,8 +254,7 @@ export const SetupAndAuthView: React.FC = ({ type="password" value={setupPassword} onChange={(e) => setSetupPassword(e.target.value)} - placeholder="Mindestens 4 Zeichen" - minLength={4} + placeholder="Mindestens 8 Zeichen" required className="w-full h-8 px-2.5 input-field" /> @@ -248,7 +266,6 @@ export const SetupAndAuthView: React.FC = ({ value={setupPasswordConfirm} onChange={(e) => setSetupPasswordConfirm(e.target.value)} placeholder="Passwort erneut eingeben" - minLength={4} required className={`w-full h-8 px-2.5 input-field ${ setupPasswordConfirm && setupPassword !== setupPasswordConfirm @@ -529,7 +546,7 @@ export const SetupAndAuthView: React.FC = ({ type="password" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} - minLength={4} + placeholder="Mindestens 8 Zeichen" required autoFocus autoComplete="new-password" @@ -542,7 +559,6 @@ export const SetupAndAuthView: React.FC = ({ type="password" value={newPasswordConfirm} onChange={(e) => setNewPasswordConfirm(e.target.value)} - minLength={4} required autoComplete="new-password" className="w-full h-11 sm:h-8 px-2.5 input-field text-base sm:text-xs" @@ -716,8 +732,7 @@ export const SetupAndAuthView: React.FC = ({ type="password" value={password} onChange={(e) => setPassword(e.target.value)} - placeholder="Mindestens 4 Zeichen" - minLength={4} + placeholder="Mindestens 8 Zeichen" required className="w-full h-8 px-2.5 input-field" /> diff --git a/frontend/src/features/settings/AdminSettingsView.tsx b/frontend/src/features/settings/AdminSettingsView.tsx index bdc30af..85cedc0 100644 --- a/frontend/src/features/settings/AdminSettingsView.tsx +++ b/frontend/src/features/settings/AdminSettingsView.tsx @@ -25,6 +25,7 @@ import { UserRole, } from '../../lib/types'; import { api, setCurrentSharingMode, triggerHaptic } from '../../lib/api'; +import { passwordProblem } from '../../lib/password'; import { UserAvatar } from '../../components/UserAvatar'; import { ToggleSwitch } from '../../components/ToggleSwitch'; import { CountBadge } from '../../components/CountBadge'; @@ -151,6 +152,11 @@ export const AdminSettingsView: React.FC = ({ const handleCreateUser = async (e: React.FormEvent) => { e.preventDefault(); setStatusMsg(null); + const problem = passwordProblem(newUserPassword); + if (problem) { + setStatusMsg({ type: 'err', text: problem }); + return; + } triggerHaptic(15); try { const created = await api.adminCreateUser({ @@ -235,8 +241,14 @@ export const AdminSettingsView: React.FC = ({ const handleConfirmResetPassword = async (userId: string) => { if (!resetPwdVal.trim()) return; setStatusMsg(null); + const problem = passwordProblem(resetPwdVal); + if (problem) { + setStatusMsg({ type: 'err', text: problem }); + return; + } try { - await api.adminResetPassword(userId, resetPwdVal.trim()); + // Not trimmed: login compares the password exactly as typed + await api.adminResetPassword(userId, resetPwdVal); setResetPwdUserId(null); setResetPwdVal(''); setStatusMsg({ type: 'ok', text: 'Passwort erfolgreich zurückgesetzt.' }); @@ -555,8 +567,7 @@ export const AdminSettingsView: React.FC = ({ type="password" value={newUserPassword} onChange={(e) => setNewUserPassword(e.target.value)} - placeholder="Mind. 4 Zeichen" - minLength={4} + placeholder="Mind. 8 Zeichen" required className="w-full min-w-0 h-8 px-2.5 input-field" /> @@ -843,7 +854,7 @@ export const AdminSettingsView: React.FC = ({ type="password" value={resetPwdVal} onChange={(e) => setResetPwdVal(e.target.value)} - placeholder="Neues Passwort eingeben…" + placeholder="Neues Passwort (mind. 8 Zeichen)…" className="flex-1 h-7 px-2 input-field" /> From c4fc455850d21f275066603c7878585342ad2117 Mon Sep 17 00:00:00 2001 From: Sandro Date: Thu, 29 Oct 2026 12:43:56 +0100 Subject: [PATCH 06/26] feat(ui): open list items counter on Listen, none on Workspace New stat listItemsOpenCount: unchecked items in all lists the user sees (own, shared directly or through a group), shown as accent counter on Listen in the header and the mobile bottom bar. Workspace loses its counter. Stats refresh on list events and after deleting an item. Co-Authored-By: Claude Opus 5.5 --- Doku/api/rest.md | 2 +- Doku/ui-patterns.md | 2 +- frontend/src/App.tsx | 25 +++++----- .../src/features/lists/SharedListsView.tsx | 1 + frontend/src/lib/types.ts | 1 + internal/domain/models.go | 1 + internal/storage/system.go | 13 +++-- internal/storage/system_test.go | 49 +++++++++++++++++++ 8 files changed, 75 insertions(+), 19 deletions(-) create mode 100644 internal/storage/system_test.go diff --git a/Doku/api/rest.md b/Doku/api/rest.md index 374827a..0fd0306 100644 --- a/Doku/api/rest.md +++ b/Doku/api/rest.md @@ -30,7 +30,7 @@ Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}` | PATCH | `/auth/settings` | `displayName`, `email`, `password`, `avatarUrl`, `alarmChannels`, `alarmEmail`, `discordWebhookUrl`, `customWebhookUrl`, `notifyBeforeMinutes` | | POST | `/auth/alarms/test` | Test-Alarm (`channel`: `email`/`discord`/`webhook`, optional `target`) | | GET | `/users/directory`, `/collaborators` | Personen, die man sehen und mit denen man teilen kann (je nach `sharingMode`, [sharing-and-permissions.md](../sharing-and-permissions.md)) | -| GET | `/stats` | Zähler fürs Dashboard | +| GET | `/stats` | Zähler fürs Dashboard (u. a. `inboxCount`, `tasksOpenCount`, `listItemsOpenCount` = offene Einträge aller sichtbaren Listen) | | GET | `/search?q=` | Globale FTS5-Suche in zugänglichen Notizen (Titel & Blöcke), Aufgaben und Listen → `{thoughts, documents, listItems, hits}` | | GET | `/export` | Datenexport als JSON; mit `?format=zip` dieselben Daten als `mindsift-backup.json` in einer ZIP (so lädt die Oberfläche das Backup) | | GET | `/export/markdown` | Obsidian-kompatibles ZIP (`mindsift-vault-DATUM.zip`), direkt gestreamt: Notizen als `.md` mit YAML-Frontmatter in der Ordnerstruktur des Nutzers (inkl. geteilter), `Aufgaben/.md` im Format des Tasks-Plugins (`📅`, `⏫`/`🔺`), `Listen/.md` | diff --git a/Doku/ui-patterns.md b/Doku/ui-patterns.md index f89a889..892457e 100644 --- a/Doku/ui-patterns.md +++ b/Doku/ui-patterns.md @@ -53,7 +53,7 @@ Komponente: [CountBadge.tsx](../frontend/src/components/CountBadge.tsx), CSS-Kla - **Form:** runder Kreis, mindestens 16 px, ab 100 als „99+“ (der Screenreader liest die echte Zahl). Bei 0 ausgeblendet, außer mit `showZero` (z. B. Mitgliederzahl, mobiles Menü). - **Farbe (`variant`):** - - `accent`: Hier wartet etwas auf dich. Navigationsleiste (Triage, Workspace, Aufgaben). + - `accent`: Hier wartet etwas auf dich. Navigationsleiste: Triage (Inbox), Aufgaben (offene Aufgaben), Listen (offene Einträge aller eigenen und geteilten Listen). Workspace hat keinen Zähler. - `muted` (Standard): reine Anzahl. Tabs der Aufgabenlisten und Listen, Organisationen, mobiles Menü. - Beide kommen aus den Theme-Variablen (`--accent-primary`/`--text-on-accent` bzw. `--text-muted`/`--bg-panel`) und passen sich damit jedem Theme an. - **Position (`position`):** diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b2fc5a8..4567a39 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -141,6 +141,7 @@ export const App: React.FC = () => { tasksDoneCount: 0, docsCount: 0, docsTodosOpenCount: 0, + listItemsOpenCount: 0, listsCount: 0, discardedCount: 0, }); @@ -550,14 +551,14 @@ export const App: React.FC = () => { } }, [user?.id]); - // Refresh stats on task/doc real-time events + // Refresh stats on task/doc/list real-time events (counters in the navigation) useEffect(() => { const handleWs = (e: any) => { const msg = e.detail; if (!msg) return; if ( typeof msg.event === 'string' && - (msg.event.startsWith('TASK_') || msg.event.startsWith('DOC_') || msg.event.startsWith('SPACE_')) + ['TASK_', 'DOC_', 'SPACE_', 'ITEM_', 'LIST_'].some((prefix) => msg.event.startsWith(prefix)) ) { api.getMe().then((res) => { setStats(res.stats); @@ -1000,10 +1001,7 @@ export const App: React.FC = () => { className={`tab-btn ${activeTab === 'workspace' ? 'tab-btn-active' : ''}`} > - 0 ? 'pr-2' : ''}`}> - Workspace - - + Workspace @@ -1166,10 +1167,7 @@ export const App: React.FC = () => { }`} title="Workspace" > -
- - -
+ Workspace @@ -1223,7 +1221,10 @@ export const App: React.FC = () => { }`} title="Listen" > - +
+ + +
Listen diff --git a/frontend/src/features/lists/SharedListsView.tsx b/frontend/src/features/lists/SharedListsView.tsx index 59e24b3..fca492a 100644 --- a/frontend/src/features/lists/SharedListsView.tsx +++ b/frontend/src/features/lists/SharedListsView.tsx @@ -357,6 +357,7 @@ export const SharedListsView: React.FC = ({ onRefreshGloba ) ); await api.deleteListItem(activeList.id, itemId); + onRefreshGlobal(); }; const handleClearCompleted = async () => { diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index da9c332..a372c14 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -490,6 +490,7 @@ export interface DashboardStats { tasksDoneCount: number; docsCount: number; docsTodosOpenCount?: number; + listItemsOpenCount?: number; listsCount: number; discardedCount: number; } diff --git a/internal/domain/models.go b/internal/domain/models.go index ba70a8e..aeb3653 100644 --- a/internal/domain/models.go +++ b/internal/domain/models.go @@ -520,6 +520,7 @@ type DashboardStats struct { DocsCount int `json:"docsCount"` DocsTodosOpenCount int `json:"docsTodosOpenCount"` ListsCount int `json:"listsCount"` + ListItemsOpenCount int `json:"listItemsOpenCount"` // unchecked items in all lists the user sees DiscardedCount int `json:"discardedCount"` } diff --git a/internal/storage/system.go b/internal/storage/system.go index 61f1945..9554149 100644 --- a/internal/storage/system.go +++ b/internal/storage/system.go @@ -18,15 +18,18 @@ func (s *Store) GetDashboardStats(userID string) (*domain.DashboardStats, error) _ = s.db.QueryRow(`SELECT COUNT(*) FROM thoughts WHERE `+taskScope+` AND is_completed = 1`, userID, userID, userID, userID).Scan(&st.TasksDoneCount) _ = s.db.QueryRow(`SELECT COUNT(*) FROM documents WHERE user_id = ? AND is_archived = 0`, userID).Scan(&st.DocsCount) _ = s.db.QueryRow(`SELECT COUNT(*) FROM thoughts WHERE user_id = ? AND status = 'discarded'`, userID).Scan(&st.DiscardedCount) - _ = s.db.QueryRow(` - SELECT COUNT(DISTINCT l.id) - FROM lists l + // Own lists and lists shared with the user directly or through a group + visibleLists := ` + SELECT l.id + FROM lists l LEFT JOIN list_shares ls ON ls.list_id = l.id AND ( (ls.target_type = 'user' AND ls.target_id = ?) OR (ls.target_type = 'team' AND ls.target_id IN (SELECT team_id FROM team_members WHERE user_id = ?)) ) - WHERE l.user_id = ? OR ls.id IS NOT NULL`, - userID, userID, userID).Scan(&st.ListsCount) + WHERE l.user_id = ? OR ls.id IS NOT NULL` + _ = s.db.QueryRow(`SELECT COUNT(DISTINCT id) FROM (`+visibleLists+`)`, userID, userID, userID).Scan(&st.ListsCount) + _ = s.db.QueryRow(`SELECT COUNT(*) FROM list_items WHERE is_completed = 0 AND list_id IN (`+visibleLists+`)`, + userID, userID, userID).Scan(&st.ListItemsOpenCount) // Count open (uncompleted) To-Dos across accessible workspace documents (owned or shared) docRows, err := s.db.Query(` diff --git a/internal/storage/system_test.go b/internal/storage/system_test.go new file mode 100644 index 0000000..aa7d3ca --- /dev/null +++ b/internal/storage/system_test.go @@ -0,0 +1,49 @@ +package storage + +import ( + "path/filepath" + "testing" +) + +func TestOpenListItemsCountCoversSharedLists(t *testing.T) { + store, err := NewStore(filepath.Join(t.TempDir(), "stats.db")) + if err != nil { + t.Fatalf("NewStore: %v", err) + } + defer store.Close() + openSharing(t, store) + anna, _ := store.CreateUser("anna@example.com", "hash", "Anna", "member") + ben, _ := store.CreateUser("ben@example.com", "hash", "Ben", "member") + carla, _ := store.CreateUser("carla@example.com", "hash", "Carla", "member") + + addItems := func(owner, listID string, open, done int) { + for i := 0; i < open+done; i++ { + item, _ := store.CreateListItem(listID, "Eintrag", "", "", i, owner) + if i >= open { + _, _ = store.ToggleListItem(item.ID, true, owner, "") + } + } + } + own, _ := store.CreateList(anna.ID, "Einkauf", "🛒", "shopping") + addItems(anna.ID, own.ID, 2, 1) + direct, _ := store.CreateList(ben.ID, "Bens Liste", "", "shopping") + addItems(ben.ID, direct.ID, 1, 2) + if _, err := store.AddListShare(ben.ID, direct.ID, "user", anna.ID, "viewer"); err != nil { + t.Fatalf("share: %v", err) + } + viaGroup, _ := store.CreateList(carla.ID, "WG-Putzplan", "", "shopping") + addItems(carla.ID, viaGroup.ID, 3, 0) + team, _ := store.CreateTeam("", "WG", "") + _ = store.AddTeamMember(team.ID, anna.ID, "member") + _ = store.AddTeamMember(team.ID, carla.ID, "admin") + if _, err := store.AddListShare(carla.ID, viaGroup.ID, "team", team.ID, "editor"); err != nil { + t.Fatalf("team share: %v", err) + } + foreign, _ := store.CreateList(ben.ID, "Privat", "", "shopping") + addItems(ben.ID, foreign.ID, 5, 0) + + st, _ := store.GetDashboardStats(anna.ID) + if st.ListItemsOpenCount != 6 || st.ListsCount != 3 { + t.Errorf("want 6 open items in 3 lists (own 2 + direct 1 + group 3), got %d in %d", st.ListItemsOpenCount, st.ListsCount) + } +} From 4bf060b97e1b103f7e3228ff4394233e4d0c76d8 Mon Sep 17 00:00:00 2001 From: Sandro Date: Thu, 29 Oct 2026 12:44:07 +0100 Subject: [PATCH 07/26] feat(ui): workspace tabs use the shared counter To-Dos and Hashtags show a CountBadge on the label like all other tabs; Notizen has no counter any more. Co-Authored-By: Claude Opus 5.5 --- frontend/src/features/workspace/WorkspaceView.tsx | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/frontend/src/features/workspace/WorkspaceView.tsx b/frontend/src/features/workspace/WorkspaceView.tsx index b62758f..9fbbec5 100644 --- a/frontend/src/features/workspace/WorkspaceView.tsx +++ b/frontend/src/features/workspace/WorkspaceView.tsx @@ -1223,7 +1223,6 @@ export const WorkspaceView: React.FC = ({ > Notizen - {docs.length > 0 && {docs.length}} From e7851f12570a41e3e8c455b7ed2428cd5c6097e4 Mon Sep 17 00:00:00 2001 From: Sandro Date: Thu, 29 Oct 2026 12:44:40 +0100 Subject: [PATCH 08/26] feat(workspace): sidebar search opens the global search The sidebar filter only matched titles and tags, never the bullets. It is replaced by a search-field-like button that opens the command palette (full-text search over all notes and bullets). Co-Authored-By: Claude Opus 5.5 --- frontend/src/App.tsx | 1 + .../src/features/workspace/WorkspaceView.tsx | 62 +++++++------------ 2 files changed, 24 insertions(+), 39 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 4567a39..4281502 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1086,6 +1086,7 @@ export const App: React.FC = () => { initialBlockId={selectedBlockId} onStatsChange={refreshAll} onOpenTask={handleOpenTask} + onOpenSearch={() => setShowCommandPalette(true)} /> )} diff --git a/frontend/src/features/workspace/WorkspaceView.tsx b/frontend/src/features/workspace/WorkspaceView.tsx index 9fbbec5..d6ccaf5 100644 --- a/frontend/src/features/workspace/WorkspaceView.tsx +++ b/frontend/src/features/workspace/WorkspaceView.tsx @@ -64,6 +64,8 @@ interface WorkspaceViewProps { initialBlockId?: string | null; onStatsChange: () => void; onOpenTask?: (taskId: string) => void; + // Opens the global search (command palette); the notes sidebar has no search of its own + onOpenSearch?: () => void; } type WorkspaceSubView = 'editor' | 'tag_detail' | 'todos' | 'tags' | 'trash'; @@ -282,6 +284,7 @@ export const WorkspaceView: React.FC = ({ initialBlockId, onStatsChange, onOpenTask, + onOpenSearch, }) => { const [docs, setDocs] = useState([]); const [folders, setFolders] = useState([]); @@ -325,7 +328,6 @@ export const WorkspaceView: React.FC = ({ const [tagSearchQuery, setTagSearchQuery] = useState(''); const [tagSortBy, setTagSortBy] = useState<'count' | 'name'>('count'); - const [searchQuery, setSearchQuery] = useState(''); const [expandedFolders, setExpandedFolders] = useState>(() => { try { const saved = localStorage.getItem('mindsift:workspace_expanded_folders'); @@ -868,17 +870,6 @@ export const WorkspaceView: React.FC = ({ return Array.from(set).sort(); }, [docs, folders, sharedFolders]); - const filteredDocs = useMemo(() => { - const q = searchQuery.trim().toLowerCase(); - if (!q) return docs; - return docs.filter( - (d) => - d.title.toLowerCase().includes(q) || - (d.content && d.content.toLowerCase().includes(q)) || - (d.tags && d.tags.some((t) => t.toLowerCase().includes(q))) - ); - }, [docs, searchQuery]); - const folderTree = useMemo(() => { const rootNodes: FolderTreeNode[] = []; const nodeMap = new Map(); @@ -906,7 +897,7 @@ export const WorkspaceView: React.FC = ({ const sharedFolderObj = sharedFolders.find( (sf) => sf.mountPath === p || sf.name === p ); - const folderDocs = filteredDocs.filter((d) => d.folderPath === p); + const folderDocs = docs.filter((d) => d.folderPath === p); const node: FolderTreeNode = { name: leafName, @@ -931,16 +922,16 @@ export const WorkspaceView: React.FC = ({ } return rootNodes; - }, [allFolderPaths, filteredDocs, sharedFolders]); + }, [allFolderPaths, docs, sharedFolders]); const pinnedDocs = useMemo( - () => filteredDocs.filter((d) => d.isPinned), - [filteredDocs] + () => docs.filter((d) => d.isPinned), + [docs] ); const rootDocs = useMemo( - () => filteredDocs.filter((d) => !d.folderPath), - [filteredDocs] + () => docs.filter((d) => !d.folderPath), + [docs] ); // Global To-Dos rollup collections @@ -1357,32 +1348,25 @@ export const WorkspaceView: React.FC = ({ - {/* Search Input */} -
- - setSearchQuery(e.target.value)} - placeholder="Notizen oder #Tags suchen…" - className="input-field w-full pl-8 pr-6 py-1 text-xs" - /> - {searchQuery && ( - - )} -
+ {/* Opens the global full-text search, which also finds the content of every bullet */} + {onOpenSearch && ( + + )} {/* Scrollable Navigation Tree */}
{/* Pinned Notes */} - {pinnedDocs.length > 0 && !searchQuery && ( + {pinnedDocs.length > 0 && (
From 6d6a35d28d88831a1323b4b5564f9140ab4dd95c Mon Sep 17 00:00:00 2001 From: Sandro Date: Thu, 29 Oct 2026 12:46:26 +0100 Subject: [PATCH 09/26] fix(ui): tab counters 4px lower and further right Corner counters on tabs (header, task lists, lists, workspace) sat on the tab's top border and close to the text; now -top-0.5 -right-2 with pr-3 on the label. The mobile bottom bar keeps its icon placement. Co-Authored-By: Claude Opus 5.5 --- Doku/ui-patterns.md | 2 +- frontend/src/App.tsx | 12 ++++++------ frontend/src/features/lists/SharedListsView.tsx | 4 ++-- frontend/src/features/triage/TasksView.tsx | 8 ++++---- frontend/src/features/workspace/WorkspaceView.tsx | 8 ++++---- 5 files changed, 17 insertions(+), 17 deletions(-) diff --git a/Doku/ui-patterns.md b/Doku/ui-patterns.md index 892457e..4b5a34b 100644 --- a/Doku/ui-patterns.md +++ b/Doku/ui-patterns.md @@ -57,7 +57,7 @@ Komponente: [CountBadge.tsx](../frontend/src/components/CountBadge.tsx), CSS-Kla - `muted` (Standard): reine Anzahl. Tabs der Aufgabenlisten und Listen, Organisationen, mobiles Menü. - Beide kommen aus den Theme-Variablen (`--accent-primary`/`--text-on-accent` bzw. `--text-muted`/`--bg-panel`) und passen sich damit jedem Theme an. - **Position (`position`):** - - `corner`: Benachrichtigungs-Badge oben rechts am nächsten `relative` Elternelement, mit Ring in der Seitenfarbe. In der Navigation und an den Tabs sitzt er **auf dem Text**: der Text steht in einem `relative inline-flex`-Span (mit `pr-2`, solange ein Zähler da ist), der Kreis bekommt `-top-1.5 -right-1`. + - `corner`: Benachrichtigungs-Badge oben rechts am nächsten `relative` Elternelement, mit Ring in der Seitenfarbe. In der Navigation und an den Tabs sitzt er **auf dem Text**: der Text steht in einem `relative inline-flex`-Span (mit `pr-3`, solange ein Zähler da ist), der Kreis bekommt `-top-0.5 -right-2`, damit er die obere Tab-Linie nicht anschneidet und nicht am Text klebt. In der mobilen Leiste sitzt er am Icon (`-top-1.5 -right-2.5`). - `inline`: im Textfluss. - Zahlen im mobilen Menü (`MobileActionContext`, `badge: number`) werden automatisch als `CountBadge` gezeigt; Text-Badges wie „2 aktiv“ bleiben Textmarken. - **Wichtig für neue Klassen:** Tailwind entfernt Klassen aus `@layer components`, die nicht wörtlich im Code stehen. Klassennamen deshalb nie zusammensetzen (`count-badge-${variant}`), sondern ausschreiben. diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 4281502..e4012f6 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -987,9 +987,9 @@ export const App: React.FC = () => { className={`tab-btn ${activeTab === 'triage' ? 'tab-btn-active' : ''}`} > - 0 ? 'pr-2' : ''}`}> + 0 ? 'pr-3' : ''}`}> Triage - + @@ -1012,9 +1012,9 @@ export const App: React.FC = () => { className={`tab-btn ${activeTab === 'tasks' ? 'tab-btn-active' : ''}`} > - 0 ? 'pr-2' : ''}`}> + 0 ? 'pr-3' : ''}`}> Aufgaben - + @@ -1026,9 +1026,9 @@ export const App: React.FC = () => { className={`tab-btn ${activeTab === 'lists' ? 'tab-btn-active' : ''}`} > - 0 ? 'pr-2' : ''}`}> + 0 ? 'pr-3' : ''}`}> Listen - +
diff --git a/frontend/src/features/lists/SharedListsView.tsx b/frontend/src/features/lists/SharedListsView.tsx index fca492a..9bbf9c8 100644 --- a/frontend/src/features/lists/SharedListsView.tsx +++ b/frontend/src/features/lists/SharedListsView.tsx @@ -872,9 +872,9 @@ export const SharedListsView: React.FC = ({ onRefreshGloba > {lst.icon} {/* Counter on the label, like in the nav bar */} - 0 ? 'pr-2' : ''}`}> + 0 ? 'pr-3' : ''}`}> {lst.title} - + = ({ }`} > {/* Counter on the label, like in the nav bar */} - + Alle Aufgaben - + @@ -1423,9 +1423,9 @@ export const TasksView: React.FC = ({ className="h-full px-3 text-xs font-medium flex items-center gap-1.5 rounded-btn" > {tl.icon} - + {tl.title} - + {(() => { diff --git a/frontend/src/features/workspace/WorkspaceView.tsx b/frontend/src/features/workspace/WorkspaceView.tsx index d6ccaf5..c0a4904 100644 --- a/frontend/src/features/workspace/WorkspaceView.tsx +++ b/frontend/src/features/workspace/WorkspaceView.tsx @@ -1229,9 +1229,9 @@ export const WorkspaceView: React.FC = ({ }`} > - 0 ? 'pr-2' : ''}`}> + 0 ? 'pr-3' : ''}`}> To-Dos - + @@ -1248,9 +1248,9 @@ export const WorkspaceView: React.FC = ({ }`} > - 0 ? 'pr-2' : ''}`}> + 0 ? 'pr-3' : ''}`}> Hashtags - +
From afd3b0347c4479baeadc71ad4d89d06066e6b177 Mon Sep 17 00:00:00 2001 From: Sandro Date: Thu, 29 Oct 2026 14:16:31 +0100 Subject: [PATCH 10/26] fix(ui): rounded-full wins over the theme radius on framed panels The global rule giving .bg-panel.border the theme's card radius made the round search button square in themes with 0px corners. Elements that ask for rounded-full now stay round in every theme. Also marks FLAW-21 as fixed (Unlicense is in the repository). Co-Authored-By: Claude Opus 5.5 --- Doku/flaws.md | 3 ++- frontend/src/index.css | 6 ++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/Doku/flaws.md b/Doku/flaws.md index 6c2935b..51237a1 100644 --- a/Doku/flaws.md +++ b/Doku/flaws.md @@ -30,7 +30,7 @@ Schweregrade: **Hoch** (Datenabfluss oder Rechteumgehung möglich), **Mittel** ( | FLAW-18 | Suche | ~~Nur eigene Gedanken und Dokumente, `LIKE` ohne Escaping, keine FTS~~ | **Behoben** | | FLAW-19 | Qualität | Frontend-Tests nur für den Outliner, `lint` ist ein Platzhalter | Niedrig | | FLAW-20 | Qualität | ~~Sehr große Dateien~~; duplizierte Zugriffslogik | Niedrig | -| FLAW-21 | Repo | `LICENSE` fehlt, obwohl README MIT angibt | Niedrig | +| FLAW-21 | Repo | ~~`LICENSE` fehlt, obwohl README MIT angibt~~ | **Behoben** | | FLAW-22 | Netzwerk | Token in der URL (WS/SSE), CORS `*`, `CheckOrigin` immer `true` | Niedrig | | FLAW-23 | SQLite | Single-Writer-Engpass bei vielen gleichzeitigen Schreibern | Niedrig | | FLAW-24 | Gruppen | ~~Organisations-Endpunkte ohne Berechtigungsprüfung~~ | **Behoben** | @@ -168,6 +168,7 @@ Schweregrade: **Hoch** (Datenabfluss oder Rechteumgehung möglich), **Mittel** ( - **Maßnahme:** In Teilmodule zerlegen (Auth, Dokumente, Aufgaben, Listen, Teams), Zugriffsprüfung zentralisieren. ### FLAW-21: Lizenzdatei fehlt +- **Status: Behoben.** MindSift steht unter der Unlicense (gemeinfrei); `LICENSE` liegt im Repository, README verweist darauf. - README und Badge nennen die MIT-Lizenz und verlinken auf `LICENSE`; die Datei existiert nicht im Repository. ### FLAW-22: Netzwerk-Details diff --git a/frontend/src/index.css b/frontend/src/index.css index 8624fff..3b3ddab 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -80,6 +80,12 @@ body { border-radius: var(--radius-card); } + /* An explicit rounded-full (round icon buttons, avatars) wins over the theme radius above */ + .bg-panel.border.rounded-full, + .bg-canvas.border.rounded-full { + border-radius: 9999px; + } + /* Quick-Capture-Blase im Header (immer vollständig abgerundet) */ .capture-pill, .capture-pill.bg-panel.border { From 6b9eb62caf9ae156654f17e851836816c2bc7c77 Mon Sep 17 00:00:00 2001 From: Sandro Date: Thu, 29 Oct 2026 14:22:06 +0100 Subject: [PATCH 11/26] feat(demo): reset the demo instance every 20 minutes with a countdown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DEMO_RESET_MINUTES (default 20, 0 = off) resets the database to the sample data. Demo accounts get fixed IDs so sessions survive; requests hold a read lock so the reset never runs under a half-finished request (streams excluded). DEMO_RESET goes to every connection, /auth/status announces the next reset. The app shows "✨ Demo · Reset in MM:SS" in place of the version, then clears cached answers, remounts the views, reloads in the background and shows a short notice; if no event arrives (long polling) it asks the server after the countdown. Co-Authored-By: Claude Opus 5.5 --- Doku/architecture.md | 1 + README.md | 1 + frontend/src/App.tsx | 80 +++++++++++++++++++- frontend/src/components/DemoCountdown.tsx | 38 ++++++++++ frontend/src/lib/types.ts | 4 + internal/api/demo.go | 77 +++++++++++++++++++ internal/api/demo_test.go | 90 +++++++++++++++++++++++ internal/api/handlers_auth.go | 11 ++- internal/api/server.go | 6 ++ internal/config/config.go | 12 +++ internal/storage/users.go | 17 ++++- internal/ws/hub.go | 20 +++++ 12 files changed, 348 insertions(+), 9 deletions(-) create mode 100644 frontend/src/components/DemoCountdown.tsx create mode 100644 internal/api/demo.go create mode 100644 internal/api/demo_test.go diff --git a/Doku/architecture.md b/Doku/architecture.md index cb85596..29ce533 100644 --- a/Doku/architecture.md +++ b/Doku/architecture.md @@ -64,6 +64,7 @@ Das Frontend liegt per `go:embed` im Binary ([cmd/server/main.go](../cmd/server/ | `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 | +| `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) | | `LEGAL_IMPRINT_URL`, `LEGAL_PRIVACY_URL` | leer | Impressum/Datenschutz als externer Link (nur `http(s)`) | diff --git a/README.md b/README.md index bd1696a..9e2c5ce 100644 --- a/README.md +++ b/README.md @@ -161,6 +161,7 @@ Rufe `http://localhost:8080` auf. Beim ersten Start führt ein Assistent durch d | `LEGAL_IMPRINT_URL`, `LEGAL_PRIVACY_URL` | leer | Impressum/Datenschutz als externer Link (nur `http(s)`) | | `LEGAL_IMPRINT_CONTENT`, `LEGAL_PRIVACY_CONTENT` (oder `…_CONTENT_FILE` mit Dateipfad) | leer | Impressum/Datenschutz als Markdown, angezeigt in einem Lesefenster. Ohne URL und Text bleibt der Link unsichtbar. | | `MINDA_DEMO` | `false` | Legt Demo-Konten `admin/admin` (mit Beispieldaten) und `user/user` an; eine Notiz und zwei Listen sind zwischen beiden geteilt, um das gemeinsame Bearbeiten auszuprobieren. **Nie auf öffentlichen Servern!** | +| `DEMO_RESET_MINUTES` | `20` | Nur im Demo-Modus: setzt die Datenbank in diesem Abstand auf die Beispieldaten zurück (`0` = nie). Angemeldete bleiben angemeldet, die App zeigt einen Countdown und lädt danach neu. | * **Reverse-Proxy:** Am besten WebSocket-Upgrade und ungepufferte SSE-Streams durchreichen. Blockiert ein Proxy beides, funktioniert MindSift trotzdem über Long-Polling bzw. regelmäßige Abfragen, nur etwas träger. Beispiele für Nginx, Traefik, Caddy und Cloudflare: [Doku/reverse-proxy.md](Doku/reverse-proxy.md). * **Datensicherung:** Alle Daten liegen in `data/mindsift.db` (+ `-wal`/`-shm`). Sichere sie mit `sqlite3 mindsift.db ".backup 'backup.db'"` oder bei gestopptem Container. Zusätzlich gibt es einen JSON-Export pro Benutzer. diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e4012f6..49c0c55 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -51,7 +51,8 @@ import { WorkspaceView } from './features/workspace/WorkspaceView'; import { SharedListsView } from './features/lists/SharedListsView'; import { SetupAndAuthView } from './features/auth/SetupAndAuthView'; import { OfflineBanner } from './components/OfflineBanner'; -import { outboxCount } from './lib/offline'; +import { outboxCount, clearOfflineData } from './lib/offline'; +import { DemoCountdown } from './components/DemoCountdown'; import { UserSettingsView } from './features/settings/UserSettingsView'; import { AdminSettingsView } from './features/settings/AdminSettingsView'; import { HelpView } from './features/help/HelpView'; @@ -122,6 +123,12 @@ export const App: React.FC = () => { instanceName: 'MindSift', }); const [user, setUser] = useState(null); + // Demo mode: when the next reset happens (local clock), and a counter that remounts all views after it + const [demoDeadline, setDemoDeadline] = useState(null); + const [dataEpoch, setDataEpoch] = useState(0); + const [demoNotice, setDemoNotice] = useState(null); + const lastDemoResetRef = useRef(0); + const handleDemoResetRef = useRef<() => void>(() => {}); // Link from the password reset mail (?resetToken=…); removed from the address bar right away const [resetToken, setResetToken] = useState(() => { try { @@ -551,11 +558,50 @@ export const App: React.FC = () => { } }, [user?.id]); + useEffect(() => { + setDemoDeadline( + authStatus.demoMode && typeof authStatus.demoResetInSeconds === 'number' + ? Date.now() + authStatus.demoResetInSeconds * 1000 + : null + ); + }, [authStatus.demoMode, authStatus.demoResetInSeconds]); + + // The demo database was reset: drop cached answers of the old data, remount the views (they load + // fresh data), refresh counters and the countdown. Sessions stay valid (fixed demo account IDs). + const handleDemoReset = useCallback(() => { + if (Date.now() - lastDemoResetRef.current < 30000) return; // event and countdown may both report it + lastDemoResetRef.current = Date.now(); + void clearOfflineData(); + setDataEpoch((n) => n + 1); + refreshAll(); + api.getAuthStatus().then(setAuthStatus).catch(() => {}); + setDemoNotice('Demo-Instanz wurde zurückgesetzt'); + window.setTimeout(() => setDemoNotice(null), 4000); + }, [refreshAll]); + handleDemoResetRef.current = handleDemoReset; + + // Countdown reached zero but no DEMO_RESET arrived (e.g. long polling): ask the server shortly after + const handleDemoCountdownElapsed = useCallback(() => { + window.setTimeout(() => { + api + .getAuthStatus() + .then((st) => { + if ((st.demoResetInSeconds ?? 0) > 60) handleDemoResetRef.current(); + else setAuthStatus(st); + }) + .catch(() => {}); + }, 5000); + }, []); + // Refresh stats on task/doc/list real-time events (counters in the navigation) useEffect(() => { const handleWs = (e: any) => { const msg = e.detail; if (!msg) return; + if (msg.event === 'DEMO_RESET') { + handleDemoResetRef.current(); + return; + } if ( typeof msg.event === 'string' && ['TASK_', 'DOC_', 'SPACE_', 'ITEM_', 'LIST_'].some((prefix) => msg.event.startsWith(prefix)) @@ -742,7 +788,16 @@ export const App: React.FC = () => { {authStatus.instanceName || 'MindSift'} - {APP_VERSION} + {demoDeadline ? ( + + ) : ( + {APP_VERSION} + )}
{ {authStatus.instanceName || 'MindSift'} - {APP_VERSION} + {demoDeadline ? ( + + ) : ( + {APP_VERSION} + )} @@ -1053,11 +1117,19 @@ export const App: React.FC = () => { + {demoNotice && ( +
+ ✨ {demoNotice} +
+ )} {/* Main Container */}
setActiveTab('triage')}> -
+
{activeTab === 'triage' && ( void; +} + +const fmt = (ms: number) => { + const total = Math.max(0, Math.round(ms / 1000)); + return `${String(Math.floor(total / 60)).padStart(2, '0')}:${String(total % 60).padStart(2, '0')}`; +}; + +// "✨ Demo · Reset in MM:SS" in place of the version under the app name while the demo mode resets itself +export const DemoCountdown: React.FC = ({ deadline, minutes, className = '', onElapsed }) => { + const [now, setNow] = useState(() => Date.now()); + + useEffect(() => { + const timer = window.setInterval(() => setNow(Date.now()), 1000); + return () => window.clearInterval(timer); + }, []); + + const left = deadline - now; + useEffect(() => { + if (left <= 0) onElapsed?.(); + // Runs when the deadline passes, not on every tick + }, [left <= 0, deadline]); + + return ( + + ✨ Demo · {left > 0 ? `Reset in ${fmt(left)}` : 'wird zurückgesetzt…'} + + ); +}; diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index a372c14..8959c78 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -10,6 +10,10 @@ export interface AuthStatus { sharingMode?: SharingMode; // "Passwort vergessen?" works (SMTP and BASE_URL configured) passwordReset?: boolean; + // Demo mode with periodic reset: next reset (ISO) and seconds until then + demoNextReset?: string; + demoResetInSeconds?: number; + demoResetMinutes?: number; // Impressum / Datenschutz of this instance (LEGAL_* environment variables) legal?: { imprintUrl?: string; diff --git a/internal/api/demo.go b/internal/api/demo.go new file mode 100644 index 0000000..018ddae --- /dev/null +++ b/internal/api/demo.go @@ -0,0 +1,77 @@ +package api + +import ( + "log/slog" + "net/http" + "strings" + "sync" + "time" +) + +// In demo mode the database is reset to the sample data every DEMO_RESET_MINUTES. Requests hold a read +// lock while they run and the reset takes the write lock, so no request sees half-dropped tables. +// Long-lived streams (WebSocket, SSE, long polling) are left out, otherwise the reset would wait for +// them forever; they reconnect or retry on their own. The demo accounts keep their IDs, so sessions +// stay valid; clients get DEMO_RESET and reload their data. + +type demoState struct { + gate sync.RWMutex // read: a running request, write: the reset + + mu sync.Mutex + nextReset time.Time +} + +func isLongLivedRequest(r *http.Request) bool { + p := r.URL.Path + return strings.HasPrefix(p, "/api/v1/ws") || p == "/api/v1/events" || + (strings.HasPrefix(p, "/api/v1/docs/") && strings.HasSuffix(p, "/changes")) +} + +func (s *Server) demoResetGate(next http.Handler) http.Handler { + if !s.cfg.DemoMode { + return next + } + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if isLongLivedRequest(r) || !strings.HasPrefix(r.URL.Path, "/api/") { + next.ServeHTTP(w, r) + return + } + s.demo.gate.RLock() + defer s.demo.gate.RUnlock() + next.ServeHTTP(w, r) + }) +} + +// demoNextReset is zero when no periodic reset runs. +func (s *Server) demoNextReset() time.Time { + s.demo.mu.Lock() + defer s.demo.mu.Unlock() + return s.demo.nextReset +} + +func (s *Server) setDemoNextReset(t time.Time) { + s.demo.mu.Lock() + s.demo.nextReset = t + s.demo.mu.Unlock() +} + +func (s *Server) resetDemo() { + s.demo.gate.Lock() + err := s.store.ResetAndSeedDemo() + s.demo.gate.Unlock() + if err != nil { + slog.Error("Demo-Reset fehlgeschlagen", "error", err) + return + } + s.wsHub.BroadcastSystem("DEMO_RESET") +} + +func (s *Server) startDemoResetTicker() { + interval := time.Duration(s.cfg.DemoResetMinutes) * time.Minute + for { + next := time.Now().Add(interval) + s.setDemoNextReset(next) + time.Sleep(time.Until(next)) + s.resetDemo() + } +} diff --git a/internal/api/demo_test.go b/internal/api/demo_test.go new file mode 100644 index 0000000..7dfb165 --- /dev/null +++ b/internal/api/demo_test.go @@ -0,0 +1,90 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/sandrodach/mindsift/internal/config" + "github.com/sandrodach/mindsift/internal/storage" + "github.com/sandrodach/mindsift/internal/ws" +) + +func TestDemoResetKeepsSessionsAndRestoresSampleData(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "demo_reset.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() + call := func(method, path, token, body string) *httptest.ResponseRecorder { + req := httptest.NewRequest(method, path, strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + return rec + } + var login struct{ Token string } + _ = json.Unmarshal(call("POST", "/api/v1/auth/login", "", `{"login":"admin","password":"admin"}`).Body.Bytes(), &login) + countDocs := func() int { + var docs []json.RawMessage + _ = json.Unmarshal(call("GET", "/api/v1/documents", login.Token, "").Body.Bytes(), &docs) + return len(docs) + } + seeded := countDocs() + if seeded == 0 { + t.Fatal("expected sample notes") + } + if rec := call("POST", "/api/v1/documents", login.Token, `{"title":"Testnotiz"}`); rec.Code >= 300 { + t.Fatalf("create note: %d %s", rec.Code, rec.Body.String()) + } + + // Requests running during the reset must neither fail half-way nor see missing tables + var wg sync.WaitGroup + failures := make(chan int, 50) + for i := 0; i < 50; i++ { + wg.Add(1) + go func() { + defer wg.Done() + if rec := call("GET", "/api/v1/documents", login.Token, ""); rec.Code != http.StatusOK { + failures <- rec.Code + } + }() + } + srv.resetDemo() + wg.Wait() + close(failures) + for code := range failures { + t.Errorf("request during reset failed with %d", code) + } + + if rec := call("GET", "/api/v1/auth/me", login.Token, ""); rec.Code != http.StatusOK { + t.Fatalf("session must survive the reset, got %d", rec.Code) + } + if n := countDocs(); n != seeded { + t.Errorf("reset should restore the %d sample notes, got %d", seeded, n) + } + + srv.setDemoNextReset(time.Now().Add(90 * time.Second)) + var st struct { + DemoNextReset string `json:"demoNextReset"` + DemoResetInSeconds int `json:"demoResetInSeconds"` + } + _ = json.Unmarshal(call("GET", "/api/v1/auth/status", "", "").Body.Bytes(), &st) + if st.DemoNextReset == "" || st.DemoResetInSeconds < 85 || st.DemoResetInSeconds > 90 { + t.Errorf("status should announce the next reset: %+v", st) + } +} diff --git a/internal/api/handlers_auth.go b/internal/api/handlers_auth.go index b48dd3e..4810456 100644 --- a/internal/api/handlers_auth.go +++ b/internal/api/handlers_auth.go @@ -37,7 +37,7 @@ func (s *Server) handleAuthStatus(w http.ResponseWriter, r *http.Request) { instanceName = sys.InstanceName } } - writeJSON(w, http.StatusOK, map[string]interface{}{ + status := map[string]interface{}{ "needsSetup": count == 0, "allowRegistration": allowReg, "instanceName": instanceName, @@ -51,7 +51,14 @@ func (s *Server) handleAuthStatus(w http.ResponseWriter, r *http.Request) { "privacyUrl": s.cfg.Legal.PrivacyURL, "hasPrivacyContent": s.cfg.Legal.PrivacyContent != "", }, - }) + } + // Countdown to the next demo reset; seconds as well, so client clocks do not matter + if next := s.demoNextReset(); !next.IsZero() { + status["demoNextReset"] = next.UTC().Format(time.RFC3339) + status["demoResetInSeconds"] = int(time.Until(next).Seconds()) + status["demoResetMinutes"] = s.cfg.DemoResetMinutes + } + writeJSON(w, http.StatusOK, status) } // handleLegalText serves the configured Markdown of the imprint or privacy notice (public, no login). diff --git a/internal/api/server.go b/internal/api/server.go index 38cd352..644f875 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -28,6 +28,8 @@ type Server struct { leases *collab.LeaseManager // block leases and open sessions of notes notifier *collab.Notifier // wakes long-poll requests on note changes + + demo demoState // periodic reset in demo mode (demo.go) } func NewServer(cfg *config.Config, store *storage.Store, wsHub *ws.Hub, staticFS fs.FS) *Server { @@ -46,6 +48,9 @@ func NewServer(cfg *config.Config, store *storage.Store, wsHub *ws.Hub, staticFS } go srv.startAlarmTicker() go srv.startCompaction() + if cfg.DemoMode && cfg.DemoResetMinutes > 0 { + go srv.startDemoResetTicker() + } return srv } @@ -70,6 +75,7 @@ func (s *Server) Router() http.Handler { r := chi.NewRouter() r.Use(middleware.RealIP) + r.Use(s.demoResetGate) r.Use(middleware.Recoverer) r.Use(s.apiLoggingMiddleware) r.Use(cors.Handler(cors.Options{ diff --git a/internal/config/config.go b/internal/config/config.go index 030f8c1..98853ac 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -2,6 +2,7 @@ package config import ( "crypto/rand" + "strconv" "encoding/hex" "log/slog" "os" @@ -22,6 +23,8 @@ type Config struct { LogLevel string Debug bool DemoMode bool + // DemoResetMinutes: in demo mode the database is reset to the sample data this often (0 = never) + DemoResetMinutes int // Optional SMTP environment variables (can also be overridden/configured in DB system_settings) SMTPHost string @@ -161,6 +164,7 @@ func Load() *Config { LogLevel: logLevel, Debug: debug, DemoMode: demoMode, + DemoResetMinutes: demoResetMinutes(), SMTPHost: smtpHost, SMTPPort: smtpPort, SMTPUser: smtpUser, @@ -197,3 +201,11 @@ func getEnv(key, fallback string) string { } return fallback } + +func demoResetMinutes() int { + n, err := strconv.Atoi(strings.TrimSpace(getEnv("DEMO_RESET_MINUTES", "20"))) + if err != nil || n < 0 { + return 20 + } + return n +} diff --git a/internal/storage/users.go b/internal/storage/users.go index ae2bec6..0bf2dc4 100644 --- a/internal/storage/users.go +++ b/internal/storage/users.go @@ -67,6 +67,10 @@ func (s *Store) CreateUser(email, passwordHash, displayName, role string) (*doma } func (s *Store) CreateUserWithUsername(email, username, passwordHash, displayName, role string) (*domain.User, error) { + return s.createUserWithID(domain.NewID("usr"), email, username, passwordHash, displayName, role) +} + +func (s *Store) createUserWithID(id, email, username, passwordHash, displayName, role string) (*domain.User, error) { if role != "admin" && role != "member" { role = "member" } @@ -76,7 +80,7 @@ func (s *Store) CreateUserWithUsername(email, username, passwordHash, displayNam cleanUsername = strings.Split(cleanEmail, "@")[0] } u := &domain.User{ - ID: domain.NewID("usr"), + ID: id, Email: cleanEmail, Username: cleanUsername, PasswordHash: passwordHash, @@ -125,6 +129,13 @@ func (s *Store) GetUserByLogin(login string) (*domain.User, error) { } // EnsureDemoAccounts seeds admin/admin and user/user accounts for demo evaluation +// Demo accounts keep the same IDs across resets, so the sessions of people trying the demo stay valid +// when the database is reset underneath them. +const ( + DemoAdminID = "usr_demo_admin" + DemoUserID = "usr_demo_user" +) + func (s *Store) EnsureDemoAccounts() error { s.initMu.Lock() defer s.initMu.Unlock() @@ -142,7 +153,7 @@ func (s *Store) EnsureDemoAccounts() error { adminCreated := false adminUser, err := s.GetUserByLogin("admin") if err != nil || adminUser == nil { - adminUser, err = s.CreateUserWithUsername("admin@mindsift.local", "admin", adminHash, "Admin", "admin") + adminUser, err = s.createUserWithID(DemoAdminID, "admin@mindsift.local", "admin", adminHash, "Admin", "admin") if err != nil { return fmt.Errorf("failed to create demo admin: %w", err) } @@ -155,7 +166,7 @@ func (s *Store) EnsureDemoAccounts() error { // 2. Regular user (user / user) normalUser, err := s.GetUserByLogin("user") if err != nil || normalUser == nil { - normalUser, err = s.CreateUserWithUsername("user@mindsift.local", "user", userHash, "User", "member") + normalUser, err = s.createUserWithID(DemoUserID, "user@mindsift.local", "user", userHash, "User", "member") if err != nil { return fmt.Errorf("failed to create demo user: %w", err) } diff --git a/internal/ws/hub.go b/internal/ws/hub.go index 02d11a0..fca2e72 100644 --- a/internal/ws/hub.go +++ b/internal/ws/hub.go @@ -371,6 +371,26 @@ func (h *Hub) BroadcastToUser(userID, event string, payload interface{}) { } } +// BroadcastSystem sends an event to every connection. Only for events without user data (e.g. the +// demo reset notice). +func (h *Hub) BroadcastSystem(event string) { + data, err := json.Marshal(domain.WSMessage{Event: event, Timestamp: domain.NowISO()}) + if err != nil { + return + } + h.mu.RLock() + defer h.mu.RUnlock() + if h.closed { + return + } + for client := range h.allClients { + client.trySend(data) + } + for sseClient := range h.sseClients { + sseClient.trySend(data) + } +} + // BroadcastToUsers sends one event to every connection of the given users (duplicates are ignored). func (h *Hub) BroadcastToUsers(userIDs []string, event string, payload interface{}) { seen := make(map[string]bool, len(userIDs)) From 11c14fa9ffcacbfef6963502c31a32b70710c12f Mon Sep 17 00:00:00 2001 From: Sandro Date: Thu, 29 Oct 2026 14:26:02 +0100 Subject: [PATCH 12/26] feat(auth): lock logins after repeated failures, trust proxies explicitly (FLAW-09) Failed logins are counted per account+address (3), per account (20) and per address (30) within 15 minutes; a full bucket answers 429 with Retry-After for 15 minutes. Locking the account+address bucket first means an attacker cannot lock the owner out from elsewhere. Unknown names count like accounts. Success clears the account buckets, a password reset (mail or admin) lifts the lock. LOGIN_PROTECTION=false switches it off. chi's RealIP believed X-Forwarded-For from anyone. The client address now comes from the headers only when the connection is from a trusted proxy: TRUSTED_PROXIES=private (default: loopback, Docker, LAN), all (old behaviour), none, or CIDRs. A proxy that sends no client address is logged once and per-address limits are skipped for it. Co-Authored-By: Claude Opus 5.5 --- Doku/architecture.md | 3 + Doku/flaws.md | 5 +- Doku/reverse-proxy.md | 17 +++- README.md | 2 + internal/api/clientip.go | 144 +++++++++++++++++++++++++++++++ internal/api/handlers_admin.go | 1 + internal/api/handlers_auth.go | 14 +++ internal/api/loginguard.go | 147 ++++++++++++++++++++++++++++++++ internal/api/loginguard_test.go | 136 +++++++++++++++++++++++++++++ internal/api/password_reset.go | 1 + internal/api/server.go | 6 +- internal/config/config.go | 57 +++++++------ 12 files changed, 503 insertions(+), 30 deletions(-) create mode 100644 internal/api/clientip.go create mode 100644 internal/api/loginguard.go create mode 100644 internal/api/loginguard_test.go diff --git a/Doku/architecture.md b/Doku/architecture.md index 29ce533..19160f7 100644 --- a/Doku/architecture.md +++ b/Doku/architecture.md @@ -59,6 +59,8 @@ Das Frontend liegt per `go:embed` im Binary ([cmd/server/main.go](../cmd/server/ | `DATA_DIR` | `./data` (Container: `/data`) | Verzeichnis der Datenbank | | `DATABASE_URL` | leer | `sqlite:///pfad/datei.db` oder Pfad; ohne Angabe `DATA_DIR/mindsift.db`. Postgres-URLs werden **nicht** unterstützt (siehe Hinweis) | | `JWT_SECRET` | zufällig erzeugt, gespeichert in `$DATA_DIR/jwt.secret` | Signaturschlüssel der Tokens; bekannte Platzhalter werden ignoriert ([FLAW-04](flaws.md)) | +| `TRUSTED_PROXIES` | `private` | Proxys, deren `X-Forwarded-For`/`X-Real-IP` geglaubt wird (`private`, `all`, `none`, CIDR-Liste); `internal/api/clientip.go` | +| `LOGIN_PROTECTION` | `true` | Login-Sperre nach Fehlversuchen (`internal/api/loginguard.go`), `false` = aus | | `BASE_URL` | `http://localhost:` | Basis-URL der Instanz; explizit gesetzt Voraussetzung für das Passwort-Zurücksetzen per Mail | | `TZ` | `Europe/Berlin` | Zeitzone für Fälligkeiten und Alarme; die Zeitzonendaten sind ins Binary eingebettet (`time/tzdata`) | | `ALLOW_REGISTRATION` | `false` | Startwert; die Einstellung im Admin-UI (`system_settings`) hat Vorrang | @@ -75,6 +77,7 @@ Hinweis: `DATABASE_URL` mit `postgres…` führt dazu, dass der Standardpfad in ## Authentifizierung - **Login** mit E-Mail, Benutzername (aus dem E-Mail-Präfix) oder `login`. Passwörter: bcrypt (`DefaultCost`). +- **Login-Sperre:** Fehlversuche zählen im Speicher in drei Töpfen: Konto + Adresse (3), Konto (20), Adresse (30), je innerhalb von 15 Minuten; ist einer voll, gibt es 15 Minuten lang `429` mit `Retry-After`. Unbekannte Namen zählen wie Konten (kein Erraten, welche existieren). Erfolg leert die Kontotöpfe, ein Passwort-Reset (Mail oder Admin) hebt die Sperre auf. Die Client-Adresse kommt nur von vertrauenswürdigen Proxys (`TRUSTED_PROXIES`). - **JWT:** HS256, Laufzeit 30 Tage, Claims `userId`, `email`, `displayName`, `tokenVersion`, `jti`. Gültig nur, wenn Token nicht in `revoked_tokens` steht, das Konto aktiv ist und `tokenVersion` mit `users.token_version` übereinstimmt. Neue Passwörter brauchen mindestens 8 Zeichen (`auth.ValidatePassword`). `token_version` wird bei Passwortänderung, Rollenwechsel, Deaktivierung und „Alle Sitzungen beenden“ erhöht. - **Logout** schreibt den Token-Hash in `revoked_tokens`. - **API-Keys:** `mindsift_sec_`, gespeichert als SHA-256, nur das Präfix bleibt sichtbar. Der Key gilt als Bearer-Token oder in `X-API-Key` und darf nur Daten anliefern (`POST /inbox`, `POST /webhooks/ingest`); alle anderen Endpunkte antworten mit `403` ([FLAW-07](flaws.md)). diff --git a/Doku/flaws.md b/Doku/flaws.md index 51237a1..f4c58cb 100644 --- a/Doku/flaws.md +++ b/Doku/flaws.md @@ -18,7 +18,7 @@ Schweregrade: **Hoch** (Datenabfluss oder Rechteumgehung möglich), **Mittel** ( | FLAW-06 | Aufgaben | ~~Jeder Editor einer Aufgabenliste kann sie per `teamId` an weitere Gruppen freigeben~~ | **Behoben** | | FLAW-07 | Auth | ~~Ein API-Key ist ein Vollzugriff~~ – Keys dürfen nur noch einliefern; Scopes fehlen noch | Niedrig | | FLAW-08 | Auth | ~~Webhook-Ingest mit JWT prüft weder Sperrliste noch `token_version` noch `is_active`~~ | **Behoben** | -| FLAW-09 | Auth | Kein Rate-Limit, keine Sperre bei Fehlversuchen; ~~Mindestpasswort 4 Zeichen~~ | Mittel | +| FLAW-09 | Auth | ~~Kein Rate-Limit, keine Sperre bei Fehlversuchen, Mindestpasswort 4 Zeichen~~ | **Behoben** | | FLAW-10 | Freigaben | ~~`sharing_mode` wird bei Freigaben nicht durchgesetzt; Benutzerverzeichnis leakt alle Konten~~ | **Behoben** | | FLAW-11 | Workspace | ~~Block-Upsert (`ON CONFLICT(id)`) prüft das Dokument nicht~~ | **Behoben** | | FLAW-12 | Benachrichtigungen | ~~`notify_before_minutes` wirkungslos; Alarm feuert nach Umplanung nie wieder; endlose Wiederholung bei Fehlern~~ | **Behoben** | @@ -90,7 +90,8 @@ Schweregrade: **Hoch** (Datenabfluss oder Rechteumgehung möglich), **Mittel** ( - **Maßnahme:** Dieselbe Prüfkette wie in `authMiddleware` verwenden (gemeinsame Funktion statt Kopien; siehe auch `authenticateUserFromReq`). ### FLAW-09: Kein Schutz gegen Brute-Force -- **Teilweise behoben:** Neue Passwörter brauchen überall mindestens 8 Zeichen (Setup, Registrierung, Profil, Admin-Anlage und -Reset, Reset per Mail), geprüft zentral in `auth.ValidatePassword` und vorab im Browser (`lib/password.ts`). Bestehende kürzere Passwörter funktionieren beim Anmelden weiter. Offen: Rate-Limit und Sperre. +- **Status: Behoben.** Login-Sperre nach Fehlversuchen (Konto + Adresse 3, Konto 20, Adresse 30, je 15 Minuten, `429` mit `Retry-After`); `X-Forwarded-For` wird nur noch von vertrauenswürdigen Proxys übernommen (`TRUSTED_PROXIES`). Abschaltbar mit `LOGIN_PROTECTION=false`. Tests: `internal/api/loginguard_test.go`. +- Neue Passwörter brauchen überall mindestens 8 Zeichen (Setup, Registrierung, Profil, Admin-Anlage und -Reset, Reset per Mail), geprüft zentral in `auth.ValidatePassword` und vorab im Browser (`lib/password.ts`). Bestehende kürzere Passwörter funktionieren beim Anmelden weiter. - **Fundstellen:** `handleLogin`, `handleRegister`, `handleFirstBootSetup`. - **Problem:** Weder Rate-Limit noch Sperre; `middleware.RealIP` vertraut `X-Forwarded-For` ungeprüft. Mindestpasswortlänge ist 4 Zeichen. Der Login-Pfad ist so über das Internet raten- und durchprobierbar. - **Maßnahme:** Rate-Limit je IP und Konto, längeres Mindestpasswort. diff --git a/Doku/reverse-proxy.md b/Doku/reverse-proxy.md index 864449c..05af823 100644 --- a/Doku/reverse-proxy.md +++ b/Doku/reverse-proxy.md @@ -11,6 +11,20 @@ Dieses Dokument beschreibt die empfohlene Konfiguration von Reverse-Proxies und | **WebSockets** | 443 (WSS) / 80 (WS) | `Upgrade $http_upgrade`
`Connection "Upgrade"`
`proxy_read_timeout 3600s` | Bidirektionale Live-Updates (Aufgaben, Listen, Notizen) | | **Server-Sent Events (SSE)** | 443 (HTTPS) / 80 (HTTP) | `X-Accel-Buffering: no`
`proxy_buffering off`
`proxy_cache off` | Fallback bei blockiertem WebSocket-Upgrade | | **REST API & Uploads** | 443 (HTTPS) / 80 (HTTP) | `client_max_body_size 64M` | Notizen, Import/Export (JSON) | +| **Client-IP** | – | `X-Real-IP` / `X-Forwarded-For` | Login-Sperre nach Fehlversuchen trifft den richtigen Absender | + +### Client-IP und `TRUSTED_PROXIES` + +MindSift sperrt Anmeldungen nach Fehlversuchen auch pro IP-Adresse. Hinter einem Proxy kommt jede Verbindung vom Proxy; die echte Adresse steht in `X-Forwarded-For` bzw. `X-Real-IP`. MindSift glaubt diesen Kopfzeilen nur, wenn die Verbindung von einem vertrauenswürdigen Proxy kommt, sonst könnte jeder, der den Port direkt erreicht, eine beliebige Adresse vortäuschen. + +| `TRUSTED_PROXIES` | Wirkung | +|---|---| +| `private` (Standard) | Proxys aus localhost, Docker- und Heimnetzen (`10.x`, `172.16–31.x`, `192.168.x`, `fd…`, `100.64.x`). Passt für Proxy im selben Docker-Netz oder auf demselben Host – nichts einzustellen. | +| `private,198.51.100.0/24` | zusätzlich eigene Bereiche, z. B. ein externer Proxy-Server oder die [IP-Bereiche von Cloudflare](https://www.cloudflare.com/ips/) | +| `all` | Kopfzeilen immer glauben (früheres Verhalten) – Notausgang, wenn die Erkennung Probleme macht | +| `none` | Kopfzeilen nie glauben | + +Schickt ein vertrauenswürdiger Proxy gar keine Client-IP mit, steht einmal eine Warnung im Log, und für diese Anfragen gilt nur noch die Sperre pro Konto. Die ganze Login-Sperre lässt sich mit `LOGIN_PROTECTION=false` abschalten. --- @@ -151,7 +165,8 @@ Wird MindSift hinter dem Cloudflare CDN betrieben: - Deaktiviere "Rocket Loader" für MindSift, da asynchrone Skripteingriffe den Start der Single-Page-App stören können. 3. **Cloudflare WAF / Bot Fight Mode:** - Stelle sicher, dass `GET /api/v1/events` und `GET /api/v1/ws*` nicht von "Challenge"-Seiten blockiert werden. -4. **SSE-Unterstützung:** +4. **Client-IP:** Cloudflare liefert die Besucheradresse in `CF-Connecting-IP`/`X-Forwarded-For`. Spricht Cloudflare MindSift direkt an (ohne eigenen Proxy dazwischen), die Cloudflare-Bereiche in `TRUSTED_PROXIES` ergänzen. +5. **SSE-Unterstützung:** - Cloudflare unterstützt Server-Sent Events nativ über HTTP/2 und HTTP/3. Dank der vom MindSift-Backend gesetzten Header `X-Accel-Buffering: no` und `Cache-Control: no-cache` streamt Cloudflare Echtzeit-Events verzögerungsfrei an die Clients. --- diff --git a/README.md b/README.md index 9e2c5ce..53400eb 100644 --- a/README.md +++ b/README.md @@ -153,6 +153,8 @@ Rufe `http://localhost:8080` auf. Beim ersten Start führt ein Assistent durch d | `DATA_DIR` | `/data` (Container) | Ablage der Datenbank `mindsift.db` | | `JWT_SECRET` | zufällig erzeugt (`/data/jwt.secret`) | Signaturschlüssel der Anmelde-Tokens; Platzhalter werden ignoriert | | `BASE_URL` | `http://localhost:8080` | Öffentliche URL. Nur wenn gesetzt (und SMTP eingerichtet ist), gibt es „Passwort vergessen?“ – die Links in den Mails werden ausschließlich daraus gebaut | +| `TRUSTED_PROXIES` | `private` | Wessen `X-Forwarded-For`/`X-Real-IP` geglaubt wird: `private` (Proxy im Docker-/Heimnetz), `all` (immer, früheres Verhalten), `none` oder eigene CIDR-Bereiche ([Doku/reverse-proxy.md](Doku/reverse-proxy.md#client-ip-und-trusted_proxies)) | +| `LOGIN_PROTECTION` | `true` | Sperre nach Fehlversuchen: 3 pro Konto und Adresse, 20 pro Konto, 30 pro Adresse, jeweils 15 Minuten. `false` schaltet sie ab | | `TZ` | `Europe/Berlin` | Zeitzone für Fälligkeiten und Alarme (ganztägige Aufgaben melden sich um 9:00) | | `ALLOW_REGISTRATION` | `false` | Offene Registrierung (im Admin-UI änderbar) | | `LOG_LEVEL` / `DEBUG` | `info` / `false` | Logging | diff --git a/internal/api/clientip.go b/internal/api/clientip.go new file mode 100644 index 0000000..b6dafbf --- /dev/null +++ b/internal/api/clientip.go @@ -0,0 +1,144 @@ +package api + +import ( + "context" + "log/slog" + "net" + "net/http" + "strings" + "sync" +) + +// Client IP behind a reverse proxy. The proxy connects to MindSift and passes the visitor's address in +// X-Forwarded-For / X-Real-IP. Those headers are only believed when the connection comes from a trusted +// proxy (TRUSTED_PROXIES), otherwise anyone reaching the port directly could claim any address and dodge +// the login protection. +// private (default) loopback and private networks (Docker, LAN, CGNAT/Tailscale) +// all always believe the headers (old behaviour, switches the check off) +// none never believe them +// CIDR list e.g. "private,203.0.113.0/24"; Cloudflare and other external proxies go here + +type clientIPKey struct{} + +type clientIPInfo struct { + ip string + // viaProxyWithoutHeader: the connection came from a trusted proxy that did not say who the + // visitor is, so all visitors look the same. Per-IP limits must not apply then. + viaProxyWithoutHeader bool +} + +type proxyTrust struct { + all bool + networks []*net.IPNet +} + +var privateProxyNets = mustCIDRs("127.0.0.0/8", "::1/128", "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "fc00::/7", "100.64.0.0/10") + +func parseProxyTrust(spec string) proxyTrust { + spec = strings.ToLower(strings.TrimSpace(spec)) + if spec == "" { + spec = "private" + } + var t proxyTrust + for _, part := range strings.Split(spec, ",") { + switch part = strings.TrimSpace(part); part { + case "", "none": + case "all", "*": + t.all = true + case "private": + t.networks = append(t.networks, privateProxyNets...) + default: + if !strings.Contains(part, "/") { + if strings.Contains(part, ":") { + part += "/128" + } else { + part += "/32" + } + } + if _, n, err := net.ParseCIDR(part); err == nil { + t.networks = append(t.networks, n) + } else { + slog.Warn("TRUSTED_PROXIES: Eintrag ignoriert", "entry", part) + } + } + } + return t +} + +func (t proxyTrust) trusts(ip net.IP) bool { + if t.all { + return true + } + for _, n := range t.networks { + if n.Contains(ip) { + return true + } + } + return false +} + +func peerIP(remoteAddr string) string { + if host, _, err := net.SplitHostPort(remoteAddr); err == nil { + return host + } + return remoteAddr +} + +// resolveClientIP returns the visitor's address for a request coming from peer. +func (t proxyTrust) resolveClientIP(peer string, h http.Header) clientIPInfo { + peerAddr := net.ParseIP(peer) + if peerAddr == nil || !t.trusts(peerAddr) { + return clientIPInfo{ip: peer} + } + // X-Forwarded-For: client, proxy1, proxy2 – the visitor is the right-most address that is not one of + // our proxies (everything left of it could be made up by the visitor) + if xff := h.Get("X-Forwarded-For"); xff != "" { + var chain []net.IP + for _, part := range strings.Split(xff, ",") { + if ip := net.ParseIP(strings.TrimSpace(part)); ip != nil { + chain = append(chain, ip) + } + } + if len(chain) > 0 { + if t.all { + return clientIPInfo{ip: chain[0].String()} // former behaviour: first address counts + } + for i := len(chain) - 1; i >= 0; i-- { + if !t.trusts(chain[i]) { + return clientIPInfo{ip: chain[i].String()} + } + } + return clientIPInfo{ip: chain[0].String()} // only proxies in the chain: the visitor is in our network + } + } + for _, name := range []string{"X-Real-IP", "True-Client-IP", "CF-Connecting-IP"} { + if ip := net.ParseIP(strings.TrimSpace(h.Get(name))); ip != nil { + return clientIPInfo{ip: ip.String()} + } + } + return clientIPInfo{ip: peer, viaProxyWithoutHeader: !peerAddr.IsLoopback()} +} + +var warnProxyHeaderOnce sync.Once + +// clientIPMiddleware replaces chi's RealIP: r.RemoteAddr becomes the visitor's address. +func (s *Server) clientIPMiddleware(next http.Handler) http.Handler { + trust := parseProxyTrust(s.cfg.TrustedProxies) + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + info := trust.resolveClientIP(peerIP(r.RemoteAddr), r.Header) + if info.viaProxyWithoutHeader { + warnProxyHeaderOnce.Do(func() { + slog.Warn("Reverse-Proxy übergibt keine Client-IP (X-Forwarded-For/X-Real-IP fehlt); Login-Sperren pro IP sind für diese Anfragen aus", "proxy", info.ip) + }) + } + r.RemoteAddr = info.ip + next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), clientIPKey{}, info))) + }) +} + +func clientIPFrom(r *http.Request) clientIPInfo { + if info, ok := r.Context().Value(clientIPKey{}).(clientIPInfo); ok { + return info + } + return clientIPInfo{ip: peerIP(r.RemoteAddr)} +} diff --git a/internal/api/handlers_admin.go b/internal/api/handlers_admin.go index a51476b..b7c884c 100644 --- a/internal/api/handlers_admin.go +++ b/internal/api/handlers_admin.go @@ -217,6 +217,7 @@ func (s *Server) handleAdminResetPassword(w http.ResponseWriter, r *http.Request writeError(w, http.StatusInternalServerError, "Passwort-Hash fehlgeschlagen") return } + s.loginGuard.unlockAccount(targetUserID) if err := s.store.UpdateUserPassword(targetUserID, hash); err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return diff --git a/internal/api/handlers_auth.go b/internal/api/handlers_auth.go index 4810456..bdbf922 100644 --- a/internal/api/handlers_auth.go +++ b/internal/api/handlers_auth.go @@ -261,16 +261,30 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { } user, err := s.store.GetUserByLogin(identifier) + // Unknown names are counted under the typed login, so locking looks the same as for real accounts + account := "login:" + strings.ToLower(identifier) + if err == nil { + account = user.ID + } + ip := clientIPFrom(r) + if wait := s.loginGuard.lockedFor(account, ip); wait > 0 { + slog.Warn("Login gesperrt nach Fehlversuchen", "login", identifier, "remote_addr", r.RemoteAddr, "wait", wait.Round(time.Second)) + writeLoginLocked(w, wait) + return + } if err != nil { + s.loginGuard.recordFailure(account, ip) slog.Warn("Login fehlgeschlagen: Benutzer existiert nicht", "login", identifier, "remote_addr", r.RemoteAddr) writeError(w, http.StatusUnauthorized, "Benutzername/E-Mail oder Passwort ungültig") return } if !auth.CheckPassword(req.Password, user.PasswordHash) { + s.loginGuard.recordFailure(account, ip) slog.Warn("Login fehlgeschlagen: Falsches Passwort", "login", identifier, "user_id", user.ID, "display_name", user.DisplayName, "remote_addr", r.RemoteAddr) writeError(w, http.StatusUnauthorized, "Benutzername/E-Mail oder Passwort ungültig") return } + s.loginGuard.recordSuccess(account, ip) if !user.IsActive { slog.Warn("Login verweigert: Benutzerkonto ist deaktiviert", "login", identifier, "user_id", user.ID, "display_name", user.DisplayName, "remote_addr", r.RemoteAddr) writeError(w, http.StatusForbidden, "Dieses Benutzerkonto ist deaktiviert") diff --git a/internal/api/loginguard.go b/internal/api/loginguard.go new file mode 100644 index 0000000..b5897c8 --- /dev/null +++ b/internal/api/loginguard.go @@ -0,0 +1,147 @@ +package api + +import ( + "fmt" + "math" + "net/http" + "strconv" + "strings" + "sync" + "time" +) + +// Brute-force protection for the login (FLAW-09). Failed attempts are counted in memory in three +// buckets; when one is full, logins for it are refused with 429 for the lock time: +// account + IP 3 failures the attacker's address is locked out of this account, the real owner +// logging in from elsewhere is not +// account 20 failures stops attacks spread over many addresses +// IP 30 failures stops one address trying many accounts +// A bucket counts failures within the lock window and starts over afterwards. A successful login +// clears the account buckets; a password reset clears the account completely. LOGIN_PROTECTION=false +// switches all of it off. Counters are lost on restart, which is fine for this purpose. + +const ( + loginLockWindow = 15 * time.Minute + loginLimitAccountIP = 3 + loginLimitAccount = 20 + loginLimitIP = 30 + loginGuardCleanupEach = 5 * time.Minute +) + +type loginBucket struct { + count int + windowStart time.Time + lockedUntil time.Time +} + +type loginGuard struct { + enabled bool + now func() time.Time + + mu sync.Mutex + buckets map[string]*loginBucket + lastCleanup time.Time +} + +func newLoginGuard(enabled bool) *loginGuard { + return &loginGuard{enabled: enabled, now: time.Now, buckets: map[string]*loginBucket{}} +} + +type loginKey struct { + key string + limit int +} + +// loginKeys: account is the user ID (or the typed login for unknown names, so both look the same). +// The IP buckets are left out when the IP is not known (proxy without client header). +func loginKeys(account string, ip clientIPInfo) []loginKey { + keys := []loginKey{{"acct:" + account, loginLimitAccount}} + if !ip.viaProxyWithoutHeader && ip.ip != "" { + keys = append(keys, loginKey{"acctip:" + account + "|" + ip.ip, loginLimitAccountIP}, loginKey{"ip:" + ip.ip, loginLimitIP}) + } + return keys +} + +// lockedFor returns how long the login is still refused (0 = allowed). +func (g *loginGuard) lockedFor(account string, ip clientIPInfo) time.Duration { + if !g.enabled { + return 0 + } + g.mu.Lock() + defer g.mu.Unlock() + now := g.now() + var wait time.Duration + for _, k := range loginKeys(account, ip) { + if b := g.buckets[k.key]; b != nil && b.lockedUntil.After(now) && b.lockedUntil.Sub(now) > wait { + wait = b.lockedUntil.Sub(now) + } + } + return wait +} + +func (g *loginGuard) recordFailure(account string, ip clientIPInfo) { + if !g.enabled { + return + } + g.mu.Lock() + defer g.mu.Unlock() + now := g.now() + g.cleanupLocked(now) + for _, k := range loginKeys(account, ip) { + b := g.buckets[k.key] + if b == nil || now.Sub(b.windowStart) > loginLockWindow { + b = &loginBucket{windowStart: now} + g.buckets[k.key] = b + } + b.count++ + if b.count >= k.limit { + b.lockedUntil = now.Add(loginLockWindow) + } + } +} + +// recordSuccess clears the counters of this account (the IP bucket stays, so one valid login cannot +// be used to keep trying other accounts). +func (g *loginGuard) recordSuccess(account string, ip clientIPInfo) { + if !g.enabled { + return + } + g.mu.Lock() + defer g.mu.Unlock() + delete(g.buckets, "acct:"+account) + delete(g.buckets, "acctip:"+account+"|"+ip.ip) +} + +// unlockAccount lifts every lock of the account, e.g. after a password reset by mail or by an admin. +func (g *loginGuard) unlockAccount(account string) { + g.mu.Lock() + defer g.mu.Unlock() + for k := range g.buckets { + if k == "acct:"+account || strings.HasPrefix(k, "acctip:"+account+"|") { + delete(g.buckets, k) + } + } +} + +func (g *loginGuard) cleanupLocked(now time.Time) { + if now.Sub(g.lastCleanup) < loginGuardCleanupEach { + return + } + g.lastCleanup = now + for k, b := range g.buckets { + if now.Sub(b.windowStart) > loginLockWindow && !b.lockedUntil.After(now) { + delete(g.buckets, k) + } + } +} + +func writeLoginLocked(w http.ResponseWriter, wait time.Duration) { + minutes := int(math.Ceil(wait.Minutes())) + w.Header().Set("Retry-After", strconv.Itoa(int(math.Ceil(wait.Seconds())))) + unit := "Minuten" + if minutes == 1 { + unit = "Minute" + } + writeError(w, http.StatusTooManyRequests, + fmt.Sprintf("Zu viele fehlgeschlagene Anmeldeversuche. Die Anmeldung ist für noch %d %s gesperrt.", minutes, unit)) +} diff --git a/internal/api/loginguard_test.go b/internal/api/loginguard_test.go new file mode 100644 index 0000000..596b751 --- /dev/null +++ b/internal/api/loginguard_test.go @@ -0,0 +1,136 @@ +package api + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/sandrodach/mindsift/internal/auth" +) + +func TestResolveClientIP(t *testing.T) { + h := func(kv ...string) http.Header { + out := http.Header{} + for i := 0; i < len(kv); i += 2 { + out.Set(kv[i], kv[i+1]) + } + return out + } + private := parseProxyTrust("private") + cases := []struct { + name string + trust proxyTrust + peer string + hdr http.Header + want string + noHdr bool + }{ + {"proxy in docker network passes the visitor", private, "172.18.0.2", h("X-Forwarded-For", "203.0.113.7"), "203.0.113.7", false}, + {"spoofed left part of the chain is ignored", private, "172.18.0.2", h("X-Forwarded-For", "1.2.3.4, 203.0.113.7, 10.0.0.5"), "203.0.113.7", false}, + {"X-Real-IP from a trusted proxy", private, "127.0.0.1", h("X-Real-IP", "198.51.100.9"), "198.51.100.9", false}, + {"direct visitor cannot fake an address", private, "203.0.113.50", h("X-Forwarded-For", "1.2.3.4"), "203.0.113.50", false}, + {"proxy without header is flagged", private, "172.18.0.2", h(), "172.18.0.2", true}, + {"all: headers always believed", parseProxyTrust("all"), "203.0.113.50", h("X-Forwarded-For", "1.2.3.4, 5.6.7.8"), "1.2.3.4", false}, + {"none: headers never believed", parseProxyTrust("none"), "172.18.0.2", h("X-Forwarded-For", "1.2.3.4"), "172.18.0.2", false}, + {"external proxy by CIDR", parseProxyTrust("private,198.51.100.0/24"), "198.51.100.1", h("CF-Connecting-IP", "203.0.113.8"), "203.0.113.8", false}, + } + for _, c := range cases { + got := c.trust.resolveClientIP(c.peer, c.hdr) + if got.ip != c.want || got.viaProxyWithoutHeader != c.noHdr { + t.Errorf("%s: got %+v, want %s (noHeader=%v)", c.name, got, c.want, c.noHdr) + } + } +} + +func TestLoginGuardBuckets(t *testing.T) { + g := newLoginGuard(true) + now := time.Date(2026, 10, 29, 12, 0, 0, 0, time.UTC) + g.now = func() time.Time { return now } + attacker := clientIPInfo{ip: "203.0.113.66"} + owner := clientIPInfo{ip: "198.51.100.10"} + + for i := 0; i < loginLimitAccountIP; i++ { + g.recordFailure("usr_admin", attacker) + } + if g.lockedFor("usr_admin", attacker) != loginLockWindow { + t.Fatalf("3 failures from one address must lock that address out of the account") + } + if g.lockedFor("usr_admin", owner) != 0 { + t.Fatalf("the owner logging in from elsewhere must not be locked out") + } + + // Spread over many addresses: the account itself locks at 20 + for i := 0; i < loginLimitAccount; i++ { + g.recordFailure("usr_admin", clientIPInfo{ip: "192.0.2." + string(rune('0'+i%10)) + string(rune('0'+i/10))}) + } + if g.lockedFor("usr_admin", owner) == 0 { + t.Fatalf("20 failures across addresses must lock the account") + } + g.unlockAccount("usr_admin") + if g.lockedFor("usr_admin", owner) != 0 || g.lockedFor("usr_admin", attacker) != 0 { + t.Fatalf("unlockAccount must lift account and account+IP locks") + } + + // The lock ends by itself + for i := 0; i < loginLimitAccountIP; i++ { + g.recordFailure("usr_ben", attacker) + } + now = now.Add(loginLockWindow + time.Second) + if g.lockedFor("usr_ben", attacker) != 0 { + t.Fatalf("lock must expire after the window") + } + + // Without a known client IP only the account bucket counts + proxied := clientIPInfo{ip: "172.18.0.2", viaProxyWithoutHeader: true} + for i := 0; i < 10; i++ { + g.recordFailure("usr_carla", proxied) + } + if g.lockedFor("usr_carla", proxied) != 0 { + t.Fatalf("per-IP limits must not apply when the proxy hides the client IP") + } + + off := newLoginGuard(false) + for i := 0; i < 50; i++ { + off.recordFailure("usr_admin", attacker) + } + if off.lockedFor("usr_admin", attacker) != 0 { + t.Fatalf("LOGIN_PROTECTION=false must never lock") + } +} + +func TestLoginLockedAfterThreeFailures(t *testing.T) { + srv, store, _ := setupTestServer(t) + router := srv.Router() + _, _ = store.CreateUser("lock@example.com", mustHash(t, "richtig123"), "Lock", "member") + login := func(pw, from string) *httptest.ResponseRecorder { + req := httptest.NewRequest("POST", "/api/v1/auth/login", strings.NewReader(`{"login":"lock@example.com","password":"`+pw+`"}`)) + req.Header.Set("Content-Type", "application/json") + req.RemoteAddr = from + ":4711" + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + return rec + } + for i := 0; i < 3; i++ { + if rec := login("falsch", "203.0.113.66"); rec.Code != http.StatusUnauthorized { + t.Fatalf("attempt %d: want 401, got %d", i+1, rec.Code) + } + } + rec := login("richtig123", "203.0.113.66") + if rec.Code != http.StatusTooManyRequests || rec.Header().Get("Retry-After") == "" || !strings.Contains(rec.Body.String(), "15 Minuten") { + t.Fatalf("locked login: want 429 with Retry-After, got %d %s", rec.Code, rec.Body.String()) + } + if rec := login("richtig123", "198.51.100.10"); rec.Code != http.StatusOK { + t.Fatalf("owner from another address must still log in, got %d", rec.Code) + } +} + +func mustHash(t *testing.T, pw string) string { + t.Helper() + h, err := auth.HashPassword(pw) + if err != nil { + t.Fatal(err) + } + return h +} diff --git a/internal/api/password_reset.go b/internal/api/password_reset.go index 4840929..f4f4767 100644 --- a/internal/api/password_reset.go +++ b/internal/api/password_reset.go @@ -86,6 +86,7 @@ func (s *Server) handleResetPassword(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusInternalServerError, "Passwort konnte nicht gespeichert werden") return } + s.loginGuard.unlockAccount(userID) slog.Info("Passwort per E-Mail-Link zurückgesetzt", "user_id", userID) writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) } diff --git a/internal/api/server.go b/internal/api/server.go index 644f875..6d8ba14 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -29,7 +29,8 @@ type Server struct { leases *collab.LeaseManager // block leases and open sessions of notes notifier *collab.Notifier // wakes long-poll requests on note changes - demo demoState // periodic reset in demo mode (demo.go) + demo demoState // periodic reset in demo mode (demo.go) + loginGuard *loginGuard // brute-force protection (loginguard.go) } func NewServer(cfg *config.Config, store *storage.Store, wsHub *ws.Hub, staticFS fs.FS) *Server { @@ -41,6 +42,7 @@ func NewServer(cfg *config.Config, store *storage.Store, wsHub *ws.Hub, staticFS leases: collab.NewLeaseManager(nil), notifier: collab.NewNotifier(), } + srv.loginGuard = newLoginGuard(!cfg.DisableLoginProtection) wsHub.SetRoomAuthorizer(srv.canJoinRoom) // Next occurrences of repeating tasks appear live on every connected client store.OnTaskCreated = func(th *domain.Thought) { @@ -74,7 +76,7 @@ func (s *Server) taskAudience(th *domain.Thought) []string { func (s *Server) Router() http.Handler { r := chi.NewRouter() - r.Use(middleware.RealIP) + r.Use(s.clientIPMiddleware) r.Use(s.demoResetGate) r.Use(middleware.Recoverer) r.Use(s.apiLoggingMiddleware) diff --git a/internal/config/config.go b/internal/config/config.go index 98853ac..35ea501 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -2,29 +2,34 @@ package config import ( "crypto/rand" - "strconv" "encoding/hex" "log/slog" "os" "path/filepath" + "strconv" "strings" ) type Config struct { - Port string - DataDir string - DatabasePath string - JWTSecret string - BaseURL string + Port string + DataDir string + DatabasePath string + JWTSecret string + BaseURL string // BaseURLSet: BASE_URL was configured explicitly. Links in mails (password reset) are only built // from it, never from the request's Host header. - BaseURLSet bool + BaseURLSet bool AllowRegistration bool LogLevel string Debug bool DemoMode bool // DemoResetMinutes: in demo mode the database is reset to the sample data this often (0 = never) DemoResetMinutes int + // TrustedProxies: whose X-Forwarded-For/X-Real-IP is believed ("private", "all", "none" or CIDRs) + TrustedProxies string + // DisableLoginProtection: LOGIN_PROTECTION=false switches off locking logins after repeated + // failures (the zero value keeps it on) + DisableLoginProtection bool // Optional SMTP environment variables (can also be overridden/configured in DB system_settings) SMTPHost string @@ -154,24 +159,26 @@ func Load() *Config { smtpSkipVerify := strings.ToLower(getEnv("SMTP_SKIP_VERIFY", "false")) == "true" return &Config{ - Port: port, - DataDir: dataDir, - DatabasePath: dbPath, - JWTSecret: jwtSecret, - BaseURL: baseURL, - BaseURLSet: strings.TrimSpace(os.Getenv("BASE_URL")) != "", - AllowRegistration: allowReg, - LogLevel: logLevel, - Debug: debug, - DemoMode: demoMode, - DemoResetMinutes: demoResetMinutes(), - SMTPHost: smtpHost, - SMTPPort: smtpPort, - SMTPUser: smtpUser, - SMTPPass: smtpPass, - SMTPFrom: smtpFrom, - SMTPTLS: smtpTLS, - SMTPSkipVerify: smtpSkipVerify, + Port: port, + DataDir: dataDir, + DatabasePath: dbPath, + JWTSecret: jwtSecret, + BaseURL: baseURL, + BaseURLSet: strings.TrimSpace(os.Getenv("BASE_URL")) != "", + AllowRegistration: allowReg, + LogLevel: logLevel, + Debug: debug, + DemoMode: demoMode, + DemoResetMinutes: demoResetMinutes(), + TrustedProxies: getEnv("TRUSTED_PROXIES", "private"), + DisableLoginProtection: strings.ToLower(getEnv("LOGIN_PROTECTION", "true")) == "false", + SMTPHost: smtpHost, + SMTPPort: smtpPort, + SMTPUser: smtpUser, + SMTPPass: smtpPass, + SMTPFrom: smtpFrom, + SMTPTLS: smtpTLS, + SMTPSkipVerify: smtpSkipVerify, Legal: LegalTexts{ ImprintURL: legalURL("LEGAL_IMPRINT_URL"), ImprintContent: legalContent("LEGAL_IMPRINT_CONTENT"), From eabb018c642045f2cfef8fd61b86bda98c7e4063 Mon Sep 17 00:00:00 2001 From: Sandro Date: Thu, 29 Oct 2026 14:27:42 +0100 Subject: [PATCH 13/26] feat(capture): hold mobile + button to record voice thoughts directly to inbox --- Doku/ui-patterns.md | 11 + frontend/src/App.tsx | 16 +- .../src/features/capture/HoldToRecordFab.tsx | 359 ++++++++++++++++++ .../__tests__/HoldToRecordFab.test.tsx | 257 +++++++++++++ frontend/src/features/help/HelpView.tsx | 2 +- frontend/src/test/setup.ts | 13 + 6 files changed, 649 insertions(+), 9 deletions(-) create mode 100644 frontend/src/features/capture/HoldToRecordFab.tsx create mode 100644 frontend/src/features/capture/__tests__/HoldToRecordFab.test.tsx diff --git a/Doku/ui-patterns.md b/Doku/ui-patterns.md index 4b5a34b..ef49c9e 100644 --- a/Doku/ui-patterns.md +++ b/Doku/ui-patterns.md @@ -68,3 +68,14 @@ Komponente: [CountBadge.tsx](../frontend/src/components/CountBadge.tsx), CSS-Kla - `index.html` enthält in `#root` einen reinen HTML-Platzhalter (vollflächig Blau, Logo in der Mitte, kein Text). React ersetzt ihn beim ersten Rendern, dazwischen blitzt nichts Weißes auf. - `meta theme-color` startet blau und wird von [`theme.ts`](../frontend/src/lib/theme.ts) nach dem Start und bei jedem Theme-Wechsel auf `--bg-app` des aktiven Themes gesetzt, damit die Statusleiste zum Header passt. +## +-Button: Tippen und Halten + +Komponente: [HoldToRecordFab.tsx](../frontend/src/features/capture/HoldToRecordFab.tsx) + +- **Kurz tippen (< 350 ms):** Führt wie bisher die primäre Aktion der aktuellen Ansicht (`primaryAction`) oder den mobilen Erfassungsdialog (`openMobileTriageCapture`) aus. +- **Gedrückt halten (≥ 350 ms):** Startet direkt eine Sprachaufnahme über die Web Speech API (`de-DE`, `continuous`, `interimResults`). Der Button wird zum pulsierenden Mikrofon (`bg-status-err`), darüber zeigt eine schwebende Pille „Ich höre zu… (loslassen speichert)“ samt Live-Transkript. +- **Loslassen:** Stoppt die Aufnahme, wartet kurz auf das letzte Wort und speichert den Text unabhängig von der aktiven Ansicht immer als Gedanken in die Inbox (`source: 'voice'`, `target: 'inbox'`). +- **Nach oben ziehen (> 60 px):** Schaltet die Pille in den Abbruch-Zustand („🗑️ Zum Abbrechen loslassen“); Loslassen dort verwirft die Aufnahme (`abort()`). +- **Fallback:** Unterstützt der Browser keine Spracherkennung oder ist das Mikrofon gesperrt (`not-allowed`), öffnet Halten wie beim Tippen den Dialog und blendet eine kurze Statusmeldung über der Bottom-Leiste ein. + + diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e4012f6..799600a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -44,6 +44,7 @@ import { ThemeId, } from './lib/theme'; import { InstantCaptureBar } from './features/capture/InstantCaptureBar'; +import { HoldToRecordFab } from './features/capture/HoldToRecordFab'; import { CountBadge } from './components/CountBadge'; import { TriageDeck } from './features/triage/TriageDeck'; import { TasksView } from './features/triage/TasksView'; @@ -1174,9 +1175,8 @@ export const App: React.FC = () => { {/* Col 3 (EXACT CENTER): + Quick-Add FAB */}
- + onTranscript={async (text) => { + const thought = await api.captureThought(text, 'normal', 'voice', '', { target: 'inbox' }); + handleCaptured(thought, 'inbox'); + }} + />
{/* Col 4: Aufgaben */} diff --git a/frontend/src/features/capture/HoldToRecordFab.tsx b/frontend/src/features/capture/HoldToRecordFab.tsx new file mode 100644 index 0000000..43b2f7a --- /dev/null +++ b/frontend/src/features/capture/HoldToRecordFab.tsx @@ -0,0 +1,359 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { Mic, Plus } from 'lucide-react'; +import { triggerHaptic } from '../../lib/api'; + +export interface HoldToRecordFabProps { + onTap: () => void; + onTranscript: (text: string) => Promise | void; +} + +const HOLD_DELAY_MS = 350; +const CANCEL_DRAG_PX = 60; +const STOP_TIMEOUT_MS = 1500; +const TOAST_DURATION_MS = 2500; + +export const HoldToRecordFab: React.FC = ({ onTap, onTranscript }) => { + const [isRecording, setIsRecording] = useState(false); + const [isCancelZone, setIsCancelZone] = useState(false); + const [liveTranscript, setLiveTranscript] = useState(''); + const [toastMessage, setToastMessage] = useState(null); + + const isPressedRef = useRef(false); + const didEnterHoldRef = useRef(false); + const fallbackHandledRef = useRef(false); + const isCancelZoneRef = useRef(false); + const abortedRef = useRef(false); + const isStoppingRef = useRef(false); + const hasEndedRef = useRef(false); + const finalizedRef = useRef(false); + const startYRef = useRef(0); + const transcriptRef = useRef(''); + + const recognitionRef = useRef(null); + const holdTimerRef = useRef(null); + const stopTimerRef = useRef(null); + const toastTimerRef = useRef(null); + + const clearHoldTimer = () => { + if (holdTimerRef.current !== null) { + window.clearTimeout(holdTimerRef.current); + holdTimerRef.current = null; + } + }; + + const clearStopTimer = () => { + if (stopTimerRef.current !== null) { + window.clearTimeout(stopTimerRef.current); + stopTimerRef.current = null; + } + }; + + const showToast = (msg: string) => { + if (toastTimerRef.current !== null) { + window.clearTimeout(toastTimerRef.current); + } + setToastMessage(msg); + toastTimerRef.current = window.setTimeout(() => { + setToastMessage(null); + toastTimerRef.current = null; + }, TOAST_DURATION_MS); + }; + + useEffect(() => { + return () => { + clearHoldTimer(); + clearStopTimer(); + if (toastTimerRef.current !== null) { + window.clearTimeout(toastTimerRef.current); + } + if (recognitionRef.current) { + try { + recognitionRef.current.abort?.(); + } catch { + // ignore cleanup errors + } + } + }; + }, []); + + const finalizeRecording = async () => { + if (finalizedRef.current) return; + finalizedRef.current = true; + clearStopTimer(); + recognitionRef.current = null; + + if (abortedRef.current) return; + + const text = transcriptRef.current.trim(); + setLiveTranscript(''); + if (!text) { + showToast('Nichts erkannt'); + return; + } + + triggerHaptic(25); + showToast('Im Quick Dump gespeichert'); + try { + await onTranscript(text); + } catch { + showToast('Fehler beim Speichern'); + } + }; + + const startVoiceRecording = () => { + const SpeechRecognition = + (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition; + + if (!SpeechRecognition) { + fallbackHandledRef.current = true; + showToast('Spracheingabe wird von diesem Browser nicht unterstützt'); + onTap(); + return; + } + + try { + const recognition = new SpeechRecognition(); + recognition.lang = 'de-DE'; + recognition.interimResults = true; + recognition.continuous = true; + + transcriptRef.current = ''; + abortedRef.current = false; + isStoppingRef.current = false; + hasEndedRef.current = false; + finalizedRef.current = false; + isCancelZoneRef.current = false; + + recognition.onresult = (event: any) => { + let combined = ''; + for (let i = 0; i < event.results.length; i++) { + combined += event.results[i][0].transcript; + } + transcriptRef.current = combined; + setLiveTranscript(combined); + }; + + recognition.onerror = (event: any) => { + const errCode = event?.error; + if (errCode === 'not-allowed' || errCode === 'service-not-allowed') { + abortedRef.current = true; + clearStopTimer(); + setIsRecording(false); + setIsCancelZone(false); + setLiveTranscript(''); + if (!fallbackHandledRef.current) { + fallbackHandledRef.current = true; + showToast('Mikrofon nicht freigegeben'); + onTap(); + } + return; + } + if (isStoppingRef.current) { + void finalizeRecording(); + } + }; + + recognition.onend = () => { + hasEndedRef.current = true; + if (isStoppingRef.current) { + void finalizeRecording(); + } + }; + + recognitionRef.current = recognition; + setLiveTranscript(''); + setIsCancelZone(false); + setIsRecording(true); + triggerHaptic([15, 60, 25]); + recognition.start(); + } catch { + fallbackHandledRef.current = true; + setIsRecording(false); + showToast('Spracheingabe wird von diesem Browser nicht unterstützt'); + onTap(); + } + }; + + const handlePointerDown = (e: React.PointerEvent) => { + if (e.pointerType === 'mouse' && e.button !== 0) return; + + try { + e.currentTarget.setPointerCapture?.(e.pointerId); + } catch { + // ignore if unsupported in environment + } + + clearHoldTimer(); + clearStopTimer(); + + isPressedRef.current = true; + didEnterHoldRef.current = false; + fallbackHandledRef.current = false; + isCancelZoneRef.current = false; + startYRef.current = e.clientY; + + holdTimerRef.current = window.setTimeout(() => { + holdTimerRef.current = null; + if (!isPressedRef.current) return; + didEnterHoldRef.current = true; + startVoiceRecording(); + }, HOLD_DELAY_MS); + }; + + const handlePointerMove = (e: React.PointerEvent) => { + if (!isPressedRef.current || !didEnterHoldRef.current || fallbackHandledRef.current) return; + const deltaUp = startYRef.current - e.clientY; + const inCancel = deltaUp > CANCEL_DRAG_PX; + if (inCancel !== isCancelZoneRef.current) { + isCancelZoneRef.current = inCancel; + setIsCancelZone(inCancel); + triggerHaptic(10); + } + }; + + const handlePointerUp = (e: React.PointerEvent) => { + try { + e.currentTarget.releasePointerCapture?.(e.pointerId); + } catch { + // ignore + } + + if (!isPressedRef.current) return; + isPressedRef.current = false; + clearHoldTimer(); + + if (!didEnterHoldRef.current) { + onTap(); + return; + } + + if (fallbackHandledRef.current) { + return; + } + + setIsRecording(false); + setIsCancelZone(false); + + if (isCancelZoneRef.current) { + abortedRef.current = true; + clearStopTimer(); + setLiveTranscript(''); + triggerHaptic(15); + try { + recognitionRef.current?.abort?.(); + } catch { + // ignore + } + recognitionRef.current = null; + return; + } + + isStoppingRef.current = true; + if (hasEndedRef.current || !recognitionRef.current) { + void finalizeRecording(); + return; + } + + stopTimerRef.current = window.setTimeout(() => { + void finalizeRecording(); + }, STOP_TIMEOUT_MS); + + try { + recognitionRef.current.stop(); + } catch { + void finalizeRecording(); + } + }; + + const handlePointerCancel = (e: React.PointerEvent) => { + try { + e.currentTarget.releasePointerCapture?.(e.pointerId); + } catch { + // ignore + } + + if (!isPressedRef.current) return; + isPressedRef.current = false; + clearHoldTimer(); + clearStopTimer(); + + if (didEnterHoldRef.current && !fallbackHandledRef.current) { + abortedRef.current = true; + setIsRecording(false); + setIsCancelZone(false); + setLiveTranscript(''); + try { + recognitionRef.current?.abort?.(); + } catch { + // ignore + } + recognitionRef.current = null; + } + }; + + const displayedTranscript = + liveTranscript.length > 180 ? `…${liveTranscript.slice(-180)}` : liveTranscript; + + return ( + <> + {isRecording && ( +
+ {isCancelZone ? ( +
🗑️ Zum Abbrechen loslassen
+ ) : ( +
+
+ Ich höre zu… (loslassen speichert) +
+ {displayedTranscript ? ( +
+ {displayedTranscript} +
+ ) : ( +
Nach oben ziehen zum Abbrechen
+ )} +
+ )} +
+ )} + + {!isRecording && toastMessage && ( +
+ {toastMessage} +
+ )} + + + + ); +}; diff --git a/frontend/src/features/capture/__tests__/HoldToRecordFab.test.tsx b/frontend/src/features/capture/__tests__/HoldToRecordFab.test.tsx new file mode 100644 index 0000000..4ee6863 --- /dev/null +++ b/frontend/src/features/capture/__tests__/HoldToRecordFab.test.tsx @@ -0,0 +1,257 @@ +import React from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, fireEvent, act } from '@testing-library/react'; +import { HoldToRecordFab } from '../HoldToRecordFab'; + +class FakeSpeechRecognition { + static instances: FakeSpeechRecognition[] = []; + + lang = ''; + interimResults = false; + continuous = false; + started = false; + stopped = false; + aborted = false; + + onresult: ((event: any) => void) | null = null; + onerror: ((event: any) => void) | null = null; + onend: (() => void) | null = null; + + constructor() { + FakeSpeechRecognition.instances.push(this); + } + + start() { + this.started = true; + } + + stop() { + this.stopped = true; + this.onend?.(); + } + + abort() { + this.aborted = true; + this.onend?.(); + } + + emitResult(transcript: string) { + this.onresult?.({ + results: [[{ transcript }]], + }); + } + + emitError(error: string) { + this.onerror?.({ error }); + } +} + +describe('HoldToRecordFab', () => { + beforeEach(() => { + vi.useFakeTimers(); + FakeSpeechRecognition.instances = []; + (window as any).SpeechRecognition = FakeSpeechRecognition; + delete (window as any).webkitSpeechRecognition; + }); + + afterEach(() => { + vi.useRealTimers(); + delete (window as any).SpeechRecognition; + delete (window as any).webkitSpeechRecognition; + }); + + it('calls onTap on a short tap (< 350 ms)', () => { + const onTap = vi.fn(); + const onTranscript = vi.fn(); + + render(); + const btn = screen.getByRole('button', { + name: /Schnell erfassen – tippen zum Schreiben, gedrückt halten zum Sprechen/i, + }); + + fireEvent.pointerDown(btn, { clientY: 600, button: 0 }); + act(() => { + vi.advanceTimersByTime(150); + }); + fireEvent.pointerUp(btn, { clientY: 600 }); + + expect(onTap).toHaveBeenCalledTimes(1); + expect(onTranscript).not.toHaveBeenCalled(); + expect(FakeSpeechRecognition.instances).toHaveLength(0); + }); + + it('starts recording on hold (>= 350 ms) and calls onTranscript with recognized text on release', async () => { + const onTap = vi.fn(); + const onTranscript = vi.fn().mockResolvedValue(undefined); + + render(); + const btn = screen.getByRole('button', { + name: /Schnell erfassen – tippen zum Schreiben, gedrückt halten zum Sprechen/i, + }); + + fireEvent.pointerDown(btn, { clientY: 600, button: 0 }); + act(() => { + vi.advanceTimersByTime(350); + }); + + expect(FakeSpeechRecognition.instances).toHaveLength(1); + const rec = FakeSpeechRecognition.instances[0]; + expect(rec.started).toBe(true); + expect(rec.lang).toBe('de-DE'); + expect(rec.interimResults).toBe(true); + expect(rec.continuous).toBe(true); + expect(screen.getByText(/Ich höre zu… \(loslassen speichert\)/i)).toBeTruthy(); + + act(() => { + rec.emitResult('Milch und Kaffee einkaufen'); + }); + expect(screen.getByText('Milch und Kaffee einkaufen')).toBeTruthy(); + + await act(async () => { + fireEvent.pointerUp(btn, { clientY: 600 }); + }); + + expect(rec.stopped).toBe(true); + expect(onTap).not.toHaveBeenCalled(); + expect(onTranscript).toHaveBeenCalledTimes(1); + expect(onTranscript).toHaveBeenCalledWith('Milch und Kaffee einkaufen'); + expect(screen.getByRole('status').textContent).toContain('Im Quick Dump gespeichert'); + }); + + it('cancels recording when dragged up by more than 60 px and released', () => { + const onTap = vi.fn(); + const onTranscript = vi.fn(); + + render(); + const btn = screen.getByRole('button', { + name: /Schnell erfassen – tippen zum Schreiben, gedrückt halten zum Sprechen/i, + }); + + fireEvent.pointerDown(btn, { clientY: 600, button: 0 }); + act(() => { + vi.advanceTimersByTime(350); + }); + + const rec = FakeSpeechRecognition.instances[0]; + act(() => { + rec.emitResult('Verwerfen Test'); + }); + + // Drag up by 80px (> 60px threshold) + fireEvent.pointerMove(btn, { clientY: 520 }); + expect(screen.getByText(/🗑️ Zum Abbrechen loslassen/i)).toBeTruthy(); + + fireEvent.pointerUp(btn, { clientY: 520 }); + + expect(rec.aborted).toBe(true); + expect(onTap).not.toHaveBeenCalled(); + expect(onTranscript).not.toHaveBeenCalled(); + }); + + it('resumes normal recording if dragged above 60 px and then pulled back down before release', async () => { + const onTap = vi.fn(); + const onTranscript = vi.fn().mockResolvedValue(undefined); + + render(); + const btn = screen.getByRole('button', { + name: /Schnell erfassen – tippen zum Schreiben, gedrückt halten zum Sprechen/i, + }); + + fireEvent.pointerDown(btn, { clientY: 600, button: 0 }); + act(() => { + vi.advanceTimersByTime(350); + }); + + const rec = FakeSpeechRecognition.instances[0]; + act(() => { + rec.emitResult('Doch behalten'); + }); + + // Drag up into cancel zone + fireEvent.pointerMove(btn, { clientY: 510 }); + expect(screen.getByText(/🗑️ Zum Abbrechen loslassen/i)).toBeTruthy(); + + // Drag back down below threshold + fireEvent.pointerMove(btn, { clientY: 580 }); + expect(screen.getByText(/Ich höre zu… \(loslassen speichert\)/i)).toBeTruthy(); + + await act(async () => { + fireEvent.pointerUp(btn, { clientY: 580 }); + }); + + expect(rec.aborted).toBe(false); + expect(onTranscript).toHaveBeenCalledWith('Doch behalten'); + }); + + it('shows "Nichts erkannt" and does not save when transcript is empty', async () => { + const onTap = vi.fn(); + const onTranscript = vi.fn(); + + render(); + const btn = screen.getByRole('button', { + name: /Schnell erfassen – tippen zum Schreiben, gedrückt halten zum Sprechen/i, + }); + + fireEvent.pointerDown(btn, { clientY: 600, button: 0 }); + act(() => { + vi.advanceTimersByTime(350); + }); + await act(async () => { + fireEvent.pointerUp(btn, { clientY: 600 }); + }); + + expect(onTap).not.toHaveBeenCalled(); + expect(onTranscript).not.toHaveBeenCalled(); + expect(screen.getByRole('status').textContent).toContain('Nichts erkannt'); + }); + + it('falls back to onTap and shows message when microphone permission is denied', () => { + const onTap = vi.fn(); + const onTranscript = vi.fn(); + + render(); + const btn = screen.getByRole('button', { + name: /Schnell erfassen – tippen zum Schreiben, gedrückt halten zum Sprechen/i, + }); + + fireEvent.pointerDown(btn, { clientY: 600, button: 0 }); + act(() => { + vi.advanceTimersByTime(350); + }); + + const rec = FakeSpeechRecognition.instances[0]; + act(() => { + rec.emitError('not-allowed'); + }); + fireEvent.pointerUp(btn, { clientY: 600 }); + + expect(onTap).toHaveBeenCalledTimes(1); + expect(onTranscript).not.toHaveBeenCalled(); + expect(screen.getByRole('status').textContent).toContain('Mikrofon nicht freigegeben'); + }); + + it('falls back to onTap and shows a status message when SpeechRecognition is unsupported', () => { + delete (window as any).SpeechRecognition; + delete (window as any).webkitSpeechRecognition; + + const onTap = vi.fn(); + const onTranscript = vi.fn(); + + render(); + const btn = screen.getByRole('button', { + name: /Schnell erfassen – tippen zum Schreiben, gedrückt halten zum Sprechen/i, + }); + + fireEvent.pointerDown(btn, { clientY: 600, button: 0 }); + act(() => { + vi.advanceTimersByTime(350); + }); + fireEvent.pointerUp(btn, { clientY: 600 }); + + expect(onTap).toHaveBeenCalledTimes(1); + expect(onTranscript).not.toHaveBeenCalled(); + expect(screen.getByRole('status').textContent).toContain( + 'Spracheingabe wird von diesem Browser nicht unterstützt' + ); + }); +}); diff --git a/frontend/src/features/help/HelpView.tsx b/frontend/src/features/help/HelpView.tsx index 9c0df9a..d6b428c 100644 --- a/frontend/src/features/help/HelpView.tsx +++ b/frontend/src/features/help/HelpView.tsx @@ -413,7 +413,7 @@ export const HelpView: React.FC = ({ onBack }) => { Sofortiger Fokus: Drücke irgendwo in der App C oder / (sofern du nicht bereits in einem Textfeld tippst).
  • - Sprach-Diktat: Klicke auf das Mikrofon-Symbol () für kontinuierliche Spracheingabe über die Web Speech API. Gesprochene Gedanken werden automatisch mit dem Tag #diktat versehen. + Sprach-Diktat: Klicke auf das Mikrofon-Symbol () für kontinuierliche Spracheingabe über die Web Speech API. Gesprochene Gedanken werden automatisch mit dem Tag #diktat versehen. Auf dem Smartphone kannst du außerdem den runden +-Button in der unteren Leiste gedrückt halten, um direkt einzusprechen – beim Loslassen landet der Text sofort im Posteingang (nach oben ziehen bricht ab).
  • Ziel-Auswahl: Gedanken landen standardmäßig im Posteingang. Über die Optionen kannst du sie jedoch sofort als Aufgabe (mit Priorität und Fälligkeit), Listeneintrag (z.B. auf die Einkaufsliste) oder neue Notiz routen. diff --git a/frontend/src/test/setup.ts b/frontend/src/test/setup.ts index 01a7765..c376651 100644 --- a/frontend/src/test/setup.ts +++ b/frontend/src/test/setup.ts @@ -4,6 +4,19 @@ import { cleanup } from '@testing-library/react'; // jsdom has no layout engine, so scrolling is a no-op here Element.prototype.scrollIntoView = () => {}; +if (typeof window !== 'undefined' && !window.PointerEvent) { + class PolyfillPointerEvent extends MouseEvent { + pointerId: number; + pointerType: string; + constructor(type: string, params: PointerEventInit = {}) { + super(type, params); + this.pointerId = params.pointerId ?? 1; + this.pointerType = params.pointerType ?? 'touch'; + } + } + (window as any).PointerEvent = PolyfillPointerEvent; +} + afterEach(() => { cleanup(); }); From 3900c6104e804ba3f434231ba1b251b1fab9c862 Mon Sep 17 00:00:00 2001 From: Sandro Date: Thu, 29 Oct 2026 14:28:39 +0100 Subject: [PATCH 14/26] fix(security): list items need write access to their list (FLAW-27) Creating, checking, editing, deleting, reordering and clearing list items did not check the list at all: any logged-in user with a list or item ID could change it, viewers of shared lists as well. All item endpoints and converting a thought into a list item now require owner or editor rights and that the item belongs to the list (404 without access, 403 for viewers). Co-Authored-By: Claude Opus 5.5 --- Doku/flaws.md | 6 ++++ internal/api/handlers_lists.go | 35 ++++++++++++++++++++++ internal/storage/lists.go | 45 +++++++++++++++++++++++++++++ internal/storage/thoughts.go | 4 +++ internal/storage/visibility_test.go | 35 ++++++++++++++++++++++ 5 files changed, 125 insertions(+) diff --git a/Doku/flaws.md b/Doku/flaws.md index f4c58cb..e8be04d 100644 --- a/Doku/flaws.md +++ b/Doku/flaws.md @@ -36,6 +36,7 @@ Schweregrade: **Hoch** (Datenabfluss oder Rechteumgehung möglich), **Mittel** ( | FLAW-24 | Gruppen | ~~Organisations-Endpunkte ohne Berechtigungsprüfung~~ | **Behoben** | | FLAW-25 | Workspace | ~~Struktur-Aktionen an fremd gesperrten Bullets nur teilweise ausgeführt~~ | **Behoben** | | FLAW-26 | Workspace | ~~Nach Enter landet sehr schnell Getipptes noch im vorherigen Bullet~~ | **Behoben** | +| FLAW-27 | Listen | ~~Listeneinträge ohne Berechtigungsprüfung änderbar (IDOR)~~ | **Behoben** | --- @@ -209,3 +210,8 @@ Schweregrade: **Hoch** (Datenabfluss oder Rechteumgehung möglich), **Mittel** ( ## Aus der früheren Fassung entfernt Die frühere `flaws.md` führte als „gelöst“, was im Code nicht existiert: In-Memory-DocRooms, `clientId`-Echo-Schutz, Cursor-Presence, 1,5-s-Debounce-Flush, `internal/ws/docroom.go`, `frontend/src/lib/useDocumentSync.ts` und Yjs/CRDT. Der Code enthält nur noch `internal/ws/hub.go` (Räume, Benutzer-Kanäle, SSE-Fallback). Die CRDT-Tabellen werden beim Start gelöscht (`DROP TABLE document_crdt_*`). Dokumente werden per REST gespeichert (`PATCH /documents/{id}/blocks`) und als `DOC_BLOCKS_PATCHED` verteilt. Die frühere Aussage zur Token-Versionierung (`token_version`, `revoked_tokens`) trifft dagegen zu und ist in [sharing-and-permissions.md](sharing-and-permissions.md) und [architecture.md](architecture.md) beschrieben. + +### FLAW-27: Listeneinträge ohne Berechtigungsprüfung +- **Status: Behoben.** Gefunden beim Bau der API-Key-Scopes (Oktober 2026). +- **Problem:** `POST/PATCH/PUT/DELETE /lists/{id}/items…`, `…/items/reorder` und `…/clear-completed` prüften weder den Zugriff auf die Liste noch, ob der Eintrag zu ihr gehört. Jeder angemeldete Nutzer mit einer Listen- oder Eintrags-ID konnte Einträge anlegen, abhaken, ändern und löschen, auch Leser einer geteilten Liste. Ebenso ließ sich ein Gedanke in einen Eintrag einer fremden Liste umwandeln. +- **Behebung:** `Store.ListRole` / `EnsureListItemsWritable` (Eigentümer oder Bearbeiter, Eintrag muss zur Liste gehören) in allen Eintrags-Handlern und in `UpdateThoughtTriage`; ohne Zugriff `404`, als Leser `403`. Test: `TestListItemsNeedWriteAccess`. diff --git a/internal/api/handlers_lists.go b/internal/api/handlers_lists.go index 300e010..169a8a0 100644 --- a/internal/api/handlers_lists.go +++ b/internal/api/handlers_lists.go @@ -2,12 +2,14 @@ package api import ( "encoding/json" + "errors" "net/http" "strings" "github.com/go-chi/chi/v5" "github.com/sandrodach/mindsift/internal/auth" "github.com/sandrodach/mindsift/internal/domain" + "github.com/sandrodach/mindsift/internal/storage" ) // --- REALTIME LISTS & SHARING --- @@ -138,6 +140,9 @@ func (s *Server) handleDeleteListShare(w http.ResponseWriter, r *http.Request) { func (s *Server) handleCreateListItem(w http.ResponseWriter, r *http.Request) { userID := auth.GetUserID(r.Context()) listID := chi.URLParam(r, "id") + if !s.listItemsWriteAllowed(w, r, "") { + return + } var req struct { Content string `json:"content"` Category string `json:"category"` @@ -162,6 +167,9 @@ func (s *Server) handleToggleListItem(w http.ResponseWriter, r *http.Request) { userID := auth.GetUserID(r.Context()) listID := chi.URLParam(r, "id") itemID := chi.URLParam(r, "itemId") + if !s.listItemsWriteAllowed(w, r, itemID) { + return + } var req struct { IsCompleted bool `json:"isCompleted"` @@ -190,6 +198,9 @@ func (s *Server) handleToggleListItem(w http.ResponseWriter, r *http.Request) { func (s *Server) handleUpdateListItem(w http.ResponseWriter, r *http.Request) { listID := chi.URLParam(r, "id") itemID := chi.URLParam(r, "itemId") + if !s.listItemsWriteAllowed(w, r, itemID) { + return + } var req struct { Content *string `json:"content"` @@ -214,6 +225,9 @@ func (s *Server) handleUpdateListItem(w http.ResponseWriter, r *http.Request) { func (s *Server) handleDeleteListItem(w http.ResponseWriter, r *http.Request) { listID := chi.URLParam(r, "id") itemID := chi.URLParam(r, "itemId") + if !s.listItemsWriteAllowed(w, r, itemID) { + return + } _, err := s.store.DeleteListItem(itemID) if err != nil { @@ -230,6 +244,9 @@ func (s *Server) handleDeleteListItem(w http.ResponseWriter, r *http.Request) { func (s *Server) handleClearCompletedItems(w http.ResponseWriter, r *http.Request) { listID := chi.URLParam(r, "id") + if !s.listItemsWriteAllowed(w, r, "") { + return + } removed, err := s.store.ClearCompletedListItems(listID) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) @@ -245,6 +262,9 @@ func (s *Server) handleClearCompletedItems(w http.ResponseWriter, r *http.Reques func (s *Server) handleReorderListItems(w http.ResponseWriter, r *http.Request) { listID := chi.URLParam(r, "id") + if !s.listItemsWriteAllowed(w, r, "") { + return + } var req struct { Items []domain.ListItemReorderItem `json:"items"` } @@ -264,3 +284,18 @@ func (s *Server) handleReorderListItems(w http.ResponseWriter, r *http.Request) }) writeJSON(w, http.StatusOK, map[string]bool{"success": true}) } + +// listItemsWriteAllowed answers 404 (no access, or item not in this list) or 403 (viewer) and returns +// false; every request changing list items goes through it. +func (s *Server) listItemsWriteAllowed(w http.ResponseWriter, r *http.Request, itemID string) bool { + err := s.store.EnsureListItemsWritable(auth.GetUserID(r.Context()), chi.URLParam(r, "id"), itemID) + switch { + case err == nil: + return true + case errors.Is(err, storage.ErrListReadOnly): + writeError(w, http.StatusForbidden, err.Error()) + default: + writeError(w, http.StatusNotFound, err.Error()) + } + return false +} diff --git a/internal/storage/lists.go b/internal/storage/lists.go index 6a92a74..d0da1bf 100644 --- a/internal/storage/lists.go +++ b/internal/storage/lists.go @@ -500,3 +500,48 @@ func (s *Store) ReorderListItems(listID string, items []domain.ListItemReorderIt _, _ = tx.Exec(`UPDATE lists SET updated_at = ? WHERE id = ?`, domain.NowISO(), listID) return tx.Commit() } + +// ErrListNotFound: the list does not exist or the user has no access to it (deliberately the same). +var ErrListNotFound = errors.New("Liste nicht gefunden") + +// ErrListReadOnly: the list is shared with the user as viewer. +var ErrListReadOnly = errors.New("Diese Liste ist für dich nur lesbar") + +// ListRole is the user's role in a list: owner, editor, viewer or "" without access. +func (s *Store) ListRole(userID, listID string) string { + var role string + err := s.db.QueryRow(` + SELECT CASE WHEN l.user_id = ? THEN 'owner' + WHEN MAX(CASE WHEN ls.role = 'editor' THEN 1 ELSE 0 END) = 1 THEN 'editor' + ELSE 'viewer' END + FROM lists l + LEFT JOIN list_shares ls ON ls.list_id = l.id AND ( + (ls.target_type = 'user' AND ls.target_id = ?) + OR (ls.target_type = 'team' AND ls.target_id IN (SELECT team_id FROM team_members WHERE user_id = ?)) + ) + WHERE l.id = ? AND (l.user_id = ? OR ls.id IS NOT NULL) + GROUP BY l.id`, userID, userID, userID, listID, userID).Scan(&role) + if err != nil { + return "" + } + return role +} + +// EnsureListItemsWritable checks that the user may change the items of a list (owner or editor) and, +// if itemID is given, that the item belongs to that list. +func (s *Store) EnsureListItemsWritable(userID, listID, itemID string) error { + switch s.ListRole(userID, listID) { + case "": + return ErrListNotFound + case "viewer": + return ErrListReadOnly + } + if itemID != "" { + var n int + _ = s.db.QueryRow(`SELECT COUNT(*) FROM list_items WHERE id = ? AND list_id = ?`, itemID, listID).Scan(&n) + if n == 0 { + return ErrListNotFound + } + } + return nil +} diff --git a/internal/storage/thoughts.go b/internal/storage/thoughts.go index 8cfa59c..3b8056f 100644 --- a/internal/storage/thoughts.go +++ b/internal/storage/thoughts.go @@ -375,6 +375,10 @@ func (s *Store) UpdateThoughtTriage(userID, thoughtID, action, convertTo, target convertedRefID = doc.ID } } else if convertTo == "list_item" && targetListID != "" { + // Same rule as adding an item in the list itself: owner or editor + if err := s.EnsureListItemsWritable(userID, targetListID, ""); err != nil { + return nil, err + } newStatus = domain.StatusConverted convertedType = "list_item" item, err := s.CreateListItem(targetListID, thought.Content, "", "", 0, userID) diff --git a/internal/storage/visibility_test.go b/internal/storage/visibility_test.go index 801d03c..5ce8645 100644 --- a/internal/storage/visibility_test.go +++ b/internal/storage/visibility_test.go @@ -169,3 +169,38 @@ func TestDataSurvivesRestart(t *testing.T) { t.Fatalf("user_version = %d, want %d", v, schemaVersion) } } + +func TestListItemsNeedWriteAccess(t *testing.T) { + store, err := NewStore(filepath.Join(t.TempDir(), "listacl.db")) + if err != nil { + t.Fatal(err) + } + defer store.Close() + openSharing(t, store) + anna, _ := store.CreateUser("anna@example.com", "hash", "Anna", "member") + ben, _ := store.CreateUser("ben@example.com", "hash", "Ben", "member") + eve, _ := store.CreateUser("eve@example.com", "hash", "Eve", "member") + list, _ := store.CreateList(anna.ID, "Einkauf", "", "shopping") + item, _ := store.CreateListItem(list.ID, "Milch", "", "", 0, anna.ID) + other, _ := store.CreateList(eve.ID, "Eves Liste", "", "shopping") + otherItem, _ := store.CreateListItem(other.ID, "Geheim", "", "", 0, eve.ID) + _, _ = store.AddListShare(anna.ID, list.ID, "user", ben.ID, "viewer") + + if err := store.EnsureListItemsWritable(anna.ID, list.ID, item.ID); err != nil { + t.Errorf("owner must write: %v", err) + } + if err := store.EnsureListItemsWritable(ben.ID, list.ID, item.ID); err != ErrListReadOnly { + t.Errorf("viewer must be read-only, got %v", err) + } + if err := store.EnsureListItemsWritable(eve.ID, list.ID, ""); err != ErrListNotFound { + t.Errorf("stranger must not see the list, got %v", err) + } + if err := store.EnsureListItemsWritable(anna.ID, list.ID, otherItem.ID); err != ErrListNotFound { + t.Errorf("an item of another list must not be reachable through my list, got %v", err) + } + // Turning a thought into an item of someone else's list is refused as well + th, _ := store.CreateThoughtWithDetails(eve.ID, "Spam", "normal", "manual", "", "", "todo", "") + if _, err := store.UpdateThoughtTriage(eve.ID, th.ID, "keep", "list_item", list.ID, "", ""); err == nil { + t.Errorf("thought must not become an item of a list without write access") + } +} From 5c711c2cf77336850bb004962361957227a2a14c Mon Sep 17 00:00:00 2001 From: Sandro Date: Thu, 29 Oct 2026 14:36:17 +0100 Subject: [PATCH 15/26] feat(api-keys): scopes for triage, tasks and list items (FLAW-07) Keys get scopes when created (webhook:triage, webhook:task, webhook:list; default triage, old keys become triage). The webhook ingest learns target task (optional task list) and list_item (needs a list the owner may write to); /inbox checks the scope of its target as well; a missing scope answers 403. Keys can no longer open live streams, which carried all of the owner's events. The visible key UI lived in the account settings while EcosystemView was never rendered; it is replaced by ApiKeysPanel in the settings with scope chips, scope badges per key and a simulator for all three targets. Examples send the key as X-API-Key header, not in the URL. Co-Authored-By: Claude Opus 5.5 --- Doku/api/rest.md | 11 +- Doku/flaws.md | 4 +- frontend/src/features/help/HelpView.tsx | 5 +- .../src/features/settings/ApiKeysPanel.tsx | 380 ++++++++++++++++++ .../src/features/settings/EcosystemView.tsx | 283 ------------- .../features/settings/UserSettingsView.tsx | 170 +------- frontend/src/lib/api.ts | 33 +- frontend/src/lib/types.ts | 5 +- internal/api/handlers_integrations.go | 95 ++++- internal/api/handlers_thoughts.go | 4 + internal/api/realtime.go | 18 +- internal/api/server.go | 67 ++- internal/api/server_test.go | 38 +- internal/storage/apikeys.go | 44 +- internal/storage/sqlite.go | 3 + internal/storage/sqlite_test.go | 2 +- 16 files changed, 644 insertions(+), 518 deletions(-) create mode 100644 frontend/src/features/settings/ApiKeysPanel.tsx delete mode 100644 frontend/src/features/settings/EcosystemView.tsx diff --git a/Doku/api/rest.md b/Doku/api/rest.md index 0fd0306..638617d 100644 --- a/Doku/api/rest.md +++ b/Doku/api/rest.md @@ -1,6 +1,6 @@ # REST-API -Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}`. Authentifizierung über `Authorization: Bearer `. **API-Keys** (`Bearer mindsift_sec_…` bzw. Header `X-API-Key`) haben vorerst genau ein Recht: Daten einliefern über `POST /inbox` (Gedanke oder per `target` direkt Aufgabe) und `POST /webhooks/ingest`; alle anderen Endpunkte antworten mit `403`. Feinere Berechtigungen (Scopes) folgen. 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 `. **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). ## Ö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 | | 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` | Aufgabe per Webhook anlegen, Token als `?token=`, `X-API-Key` oder Bearer. Body: `title`/`content`/`text`/`message`, `priority`, `source`, `tags`, `dueDate`; Klartext-Body wird als Inhalt genommen | +| 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 | | GET | `/ws`, `/ws/spaces/{id}`, `/ws/lists/{id}` | WebSocket, siehe [websocket.md](websocket.md); Token als `?token=` nötig | | GET | `/events` | SSE-Fallback; Token als `?token=` nötig | @@ -40,7 +40,7 @@ Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}` | — | `/import/mindsift?mode=restore` | Wiederherstellen: löscht vor dem Import alle eigenen Notizen, Ordner, Aufgaben, Gedanken und Listen (erst nachdem die Datei vollständig gelesen und geprüft ist) | | DELETE | `/user/data` | Alle eigenen Daten löschen, Konto bleibt (`{"password"}` nötig) | | DELETE | `/user/account` | Eigenes Konto mit allen Daten löschen (`{"password"}` nötig); `403` für den letzten Server-Admin. `/auth/me` liefert dafür `isLastAdmin` | -| GET/POST/DELETE | `/keys`, `/keys/{id}` | API-Keys verwalten (Klartext nur bei Erstellung) | +| GET/POST/DELETE | `/keys`, `/keys/{id}` | API-Keys verwalten (Klartext nur bei Erstellung); `POST` mit `name` und `scopes` (Standard `["webhook:triage"]`) | ### Globale Suche (`GET /api/v1/search?q=`) @@ -197,6 +197,11 @@ curl -X POST https://mindsift.example.com/api/v1/webhooks/ingest \ -H "X-API-Key: mindsift_sec_…" -H "Content-Type: application/json" \ -d '{"title":"Milch kaufen #einkauf","priority":"high","dueDate":"2026-11-01"}' +# Einkaufsliste per API-Key ergänzen (Scope webhook:list) +curl -X POST https://mindsift.example.com/api/v1/webhooks/ingest \ + -H "X-API-Key: mindsift_sec_…" -H "Content-Type: application/json" \ + -d '{"target":"list_item","listId":"lst_…","content":"Hafermilch","quantity":"2"}' + # Aufgabenliste nur lesend freigeben curl -X POST https://mindsift.example.com/api/v1/task-lists/tkl_…/shares \ -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ diff --git a/Doku/flaws.md b/Doku/flaws.md index e8be04d..0992085 100644 --- a/Doku/flaws.md +++ b/Doku/flaws.md @@ -16,7 +16,7 @@ Schweregrade: **Hoch** (Datenabfluss oder Rechteumgehung möglich), **Mittel** ( | FLAW-04 | Auth | ~~Fest eincodiertes Fallback-`JWT_SECRET`~~ | **Behoben** | | FLAW-05 | Benachrichtigungen | ~~Discord-/Webhook-URLs ungeprüft (SSRF)~~ | **Behoben** | | FLAW-06 | Aufgaben | ~~Jeder Editor einer Aufgabenliste kann sie per `teamId` an weitere Gruppen freigeben~~ | **Behoben** | -| FLAW-07 | Auth | ~~Ein API-Key ist ein Vollzugriff~~ – Keys dürfen nur noch einliefern; Scopes fehlen noch | Niedrig | +| FLAW-07 | Auth | ~~Ein API-Key ist ein Vollzugriff~~ | **Behoben** | | FLAW-08 | Auth | ~~Webhook-Ingest mit JWT prüft weder Sperrliste noch `token_version` noch `is_active`~~ | **Behoben** | | FLAW-09 | Auth | ~~Kein Rate-Limit, keine Sperre bei Fehlversuchen, Mindestpasswort 4 Zeichen~~ | **Behoben** | | FLAW-10 | Freigaben | ~~`sharing_mode` wird bei Freigaben nicht durchgesetzt; Benutzerverzeichnis leakt alle Konten~~ | **Behoben** | @@ -79,7 +79,7 @@ Schweregrade: **Hoch** (Datenabfluss oder Rechteumgehung möglich), **Mittel** ( - **Maßnahme:** `teamId`-Änderungen nur für den Eigentümer; das Legacy-Feld `task_lists.team_id` zugunsten von `task_list_shares` abschaffen. ### FLAW-07: API-Key-Scopes ohne Wirkung -- **Teilweise behoben:** API-Keys dürfen nur noch `POST /inbox` und `POST /webhooks/ingest` nutzen (`apiKeyAllowed` in `server.go`), alles andere gibt `403`. Auswählbare Scopes (Triage, Aufgaben, Listen) stehen noch aus. +- **Status: Behoben.** API-Keys dürfen nur `POST /inbox` und `POST /webhooks/ingest` nutzen (`apiKeyAllowed`), und nur für die Ziele ihrer Scopes: `webhook:triage`, `webhook:task`, `webhook:list` (`requireDeliveryScope`, sonst `403`). Live-Streams (WebSocket/SSE) nehmen keine Keys an. Alte Keys bekamen `webhook:triage`. Auswahl und Badges in den Kontoeinstellungen. Test: `TestAPIKeysOnlyDeliverData`. - **Fundstellen:** Tabelle `api_keys.scopes` (Standard `ingest,read`), `authMiddleware`. - **Problem:** Ein Key mit Präfix `mindsift_sec_` authentifiziert als der Benutzer, mit allen Rechten: alle `/api/v1`-Routen, bei Admins auch `/admin/*`, außerdem das Anlegen weiterer Keys. Für Webhooks (nur Aufgaben anlegen) ist das überdimensioniert. - **Maßnahme:** Scopes auswerten; Keys standardmäßig nur auf `/inbox` und `/webhooks/ingest` beschränken. diff --git a/frontend/src/features/help/HelpView.tsx b/frontend/src/features/help/HelpView.tsx index 9c0df9a..5368645 100644 --- a/frontend/src/features/help/HelpView.tsx +++ b/frontend/src/features/help/HelpView.tsx @@ -488,7 +488,10 @@ export const HelpView: React.FC = ({ onBack }) => { content: (

    - Unter Benutzereinstellungen → Webhooks & API kannst du persönliche API-Schlüssel generieren. + Unter Kontoeinstellungen → Private Webhooks & Export kannst du persönliche API-Schlüssel erzeugen. Pro Schlüssel legst du fest, was er einliefern darf: Gedanken in die Inbox, Aufgaben direkt oder Einträge in Listen. Lesen kann ein Schlüssel nie. +

    +

    + Mit dem Feld "target" wählst du das Ziel: triage (Standard), task (optional mit taskListId) oder list_item (mit listId). Der Simulator in den Einstellungen zeigt passende Beispiele.

    Über den Endpunkt POST /api/v1/webhooks/ingest kannst du Gedanken aus iOS-Kurzbefehlen, Smart-Home-Systemen oder Drittanwendungen einspeisen. Sie werden automatisch mit dem Tag #webhook markiert. diff --git a/frontend/src/features/settings/ApiKeysPanel.tsx b/frontend/src/features/settings/ApiKeysPanel.tsx new file mode 100644 index 0000000..94f02d2 --- /dev/null +++ b/frontend/src/features/settings/ApiKeysPanel.tsx @@ -0,0 +1,380 @@ +import React, { useEffect, useState } from 'react'; +import { + Key, + Plus, + Trash2, + Copy, + Check, + Webhook, + Zap, + Terminal, +} from 'lucide-react'; +import { APIKey, ApiKeyScope, PriorityLevel, SharedList, TaskList } from '../../lib/types'; +import { api, triggerHaptic } from '../../lib/api'; + +interface ApiKeysPanelProps { + onIngested?: () => void; +} + +// What a key may deliver; keys never read anything +const SCOPES: { id: ApiKeyScope; label: string; hint: string }[] = [ + { id: 'webhook:triage', label: '📥 Triage / Inbox', hint: 'Gedanken in die Inbox' }, + { id: 'webhook:task', label: '📋 Aufgaben', hint: 'Aufgaben direkt anlegen' }, + { id: 'webhook:list', label: '📝 Listeneinträge', hint: 'Einträge zu Listen hinzufügen' }, +]; +const scopeLabel = (id: string) => SCOPES.find((s) => s.id === id)?.label || id; + +type SimTarget = 'triage' | 'task' | 'list_item'; + +// Personal API keys with scopes, a simulator and copyable examples (settings → Private Webhooks) +export const ApiKeysPanel: React.FC = ({ onIngested }) => { + const [keys, setKeys] = useState([]); + const [newKeyName, setNewKeyName] = useState(''); + const [newKeyScopes, setNewKeyScopes] = useState(['webhook:triage']); + const [newlyCreatedKey, setNewlyCreatedKey] = useState(null); + const [copiedKey, setCopiedKey] = useState(false); + + // Live Webhook Simulator State + const [simTitle, setSimTitle] = useState('Waschmaschine im Keller ist fertig!'); + const [simPriority, setSimPriority] = useState('high'); + const [simTarget, setSimTarget] = useState('triage'); + const [simListId, setSimListId] = useState(''); + const [simTaskListId, setSimTaskListId] = useState(''); + const [simStatus, setSimStatus] = useState(null); + const [lists, setLists] = useState([]); + const [taskLists, setTaskLists] = useState([]); + + const loadKeys = async () => { + const k = await api.getAPIKeys(); + setKeys(k); + }; + + useEffect(() => { + loadKeys(); + api.getAllLists().then(setLists).catch(() => {}); + api.getTaskLists().then(setTaskLists).catch(() => {}); + }, []); + + const toggleScope = (id: ApiKeyScope) => { + setNewKeyScopes((prev) => { + const next = prev.includes(id) ? prev.filter((s) => s !== id) : [...prev, id]; + return next.length > 0 ? next : prev; // at least one + }); + }; + + const handleCreateKey = async (e: React.FormEvent) => { + e.preventDefault(); + triggerHaptic(15); + const created = await api.createAPIKey(newKeyName.trim() || 'Home Assistant', newKeyScopes); + if (created.rawKey) { + setNewlyCreatedKey(created.rawKey); + } + setNewKeyName(''); + setNewKeyScopes(['webhook:triage']); + await loadKeys(); + }; + + const handleDeleteKey = async (id: string) => { + if (!window.confirm('Diesen API-Schlüssel wirklich widerrufen?')) return; + triggerHaptic(20); + await api.deleteAPIKey(id); + await loadKeys(); + }; + + const handleCopy = (text: string) => { + navigator.clipboard.writeText(text); + setCopiedKey(true); + setTimeout(() => setCopiedKey(false), 2000); + }; + + const handleSimulateWebhook = async () => { + triggerHaptic(20); + if (simTarget === 'list_item' && !simListId) { + setSimStatus('❌ Bitte eine Liste wählen.'); + return; + } + try { + // With a freshly created key the simulator tests exactly that key (and its scopes) + await api.testWebhookIngest(newlyCreatedKey, { + title: simTitle, + priority: simPriority, + tags: simTarget === 'list_item' ? [] : ['smarthome', 'automation'], + target: simTarget, + taskListId: simTarget === 'task' ? simTaskListId || undefined : undefined, + listId: simTarget === 'list_item' ? simListId : undefined, + }); + setSimStatus( + simTarget === 'task' + ? '✅ Webhook erfolgreich ausgelöst! Die Aufgabe ist angelegt.' + : simTarget === 'list_item' + ? '✅ Webhook erfolgreich ausgelöst! Der Eintrag steht in der Liste.' + : '✅ Webhook erfolgreich ausgelöst! Neue Karte liegt in deiner Inbox.' + ); + onIngested?.(); + setTimeout(() => setSimStatus(null), 4000); + } catch (err: any) { + setSimStatus(`❌ Fehler: ${err.message}`); + } + }; + + const exampleToken = newlyCreatedKey || 'mindsift_sec_dein_api_key'; + const baseUrl = window.location.origin; + const examplePayload = + simTarget === 'task' + ? `{ + "target": "task", + "title": "Filter der Lüftungsanlage wechseln", + "priority": "high", + "dueDate": "2026-11-15T09:00"${simTaskListId ? `,\n "taskListId": "${simTaskListId}"` : ''} + }` + : simTarget === 'list_item' + ? `{ + "target": "list_item", + "listId": "${simListId || 'ID_DER_LISTE'}", + "content": "Hafermilch", + "quantity": "2" + }` + : `{ + "title": "Kellerfeuchtigkeit über 70%", + "priority": "urgent", + "tags": ["smarthome", "keller"] + }`; + + return ( +

    + {/* API keys */} +
    +
    +
    + +
    +
    +

    Webhooks & Ingestion API-Schlüssel

    +

    + Verbinde Home Assistant, Zigbee2MQTT, n8n oder eigene Cronjobs mit MindSift. Ein Key kann nur Daten + einliefern, nie lesen; was genau, legst du pro Key fest. +

    +
    +
    + +
    +
    + setNewKeyName(e.target.value)} + placeholder="Bezeichnung (z. B. Home Assistant, Proxmox Backup, Rauchmelder)…" + className="w-full sm:flex-1 min-w-0 h-11 sm:h-9 px-3 input-field text-base sm:text-sm" + /> + +
    +
    + Darf einliefern: + {SCOPES.map((sc) => { + const on = newKeyScopes.includes(sc.id); + return ( + + ); + })} +
    +
    + + {newlyCreatedKey && ( +
    +
    +

    Neuer API-Schlüssel erstellt (bitte jetzt kopieren):

    + {newlyCreatedKey} +
    + +
    + )} + + {/* Active Keys List */} +
    + {keys.length === 0 ? ( +

    Noch keine dedizierten API-Schlüssel hinterlegt.

    + ) : ( + keys.map((k) => ( +
    +
    + {k.name} + {k.keyPrefix} + {k.scopes + .split(',') + .filter(Boolean) + .map((sc) => ( + + {scopeLabel(sc)} + + ))} + {k.lastUsedAt && ( + + Zuletzt genutzt: {new Date(k.lastUsedAt).toLocaleDateString('de-DE')} + + )} +
    + +
    + )) + )} +
    +
    + + {/* Simulator */} +
    +
    +
    + +
    +
    +

    Live Smarthome Webhook-Simulator

    +

    + Teste den Webhook-Endpunkt direkt hier + {newlyCreatedKey ? ' – mit dem eben erzeugten Key und seinen Berechtigungen.' : ' – mit deiner Anmeldung.'} +

    +
    +
    + +
    + {( + [ + ['triage', '📥 Inbox'], + ['task', '📋 Aufgabe'], + ['list_item', '📝 Listeneintrag'], + ] as const + ).map(([value, label]) => ( + + ))} + {simTarget === 'list_item' && ( + + )} + {simTarget === 'task' && ( + + )} +
    + + {simTarget === 'triage' && ( +
    + {[ + { label: '🧺 Waschmaschine fertig', text: 'Waschmaschine im Keller ist fertig – Wäsche aufhängen!', prio: 'high' as PriorityLevel }, + { label: '🚨 Rauchmelder Batterie', text: 'Rauchmelder Dachgeschoss meldet niedrige Batterie (12%)', prio: 'urgent' as PriorityLevel }, + { label: '💧 Luftfeuchtigkeit Keller', text: 'Kellerfeuchtigkeit über 70% – Lüften oder Entfeuchter starten', prio: 'urgent' as PriorityLevel }, + ].map((preset) => ( + + ))} +
    + )} + +
    + setSimTitle(e.target.value)} + className="flex-1 h-11 sm:h-9 px-3 input-field text-base sm:text-sm" + /> + +
    + + {simStatus && ( +

    {simStatus}

    + )} + + {/* Code Example Snippets */} +
    +
    + + cURL / Home Assistant REST Command: +
    +
    +{`curl -X POST "${baseUrl}/api/v1/webhooks/ingest" \\
    +  -H "X-API-Key: ${exampleToken}" \\
    +  -H "Content-Type: application/json" \\
    +  -d '${examplePayload}'`}
    +          
    +

    + Der Key gehört in den Header X-API-Key, nicht in die URL, damit er nicht in + Proxy-Logs landet. Fehlt dem Key die Berechtigung für das Ziel, antwortet der Server mit 403. +

    +
    +
    + +
    + ); +}; diff --git a/frontend/src/features/settings/EcosystemView.tsx b/frontend/src/features/settings/EcosystemView.tsx deleted file mode 100644 index bafa526..0000000 --- a/frontend/src/features/settings/EcosystemView.tsx +++ /dev/null @@ -1,283 +0,0 @@ -import React, { useEffect, useState } from 'react'; -import { - Key, - Plus, - Trash2, - Copy, - Check, - Webhook, - Download, - ShieldCheck, - Zap, - Terminal, -} from 'lucide-react'; -import { APIKey, PriorityLevel } from '../../lib/types'; -import { api, getToken, triggerHaptic } from '../../lib/api'; - -interface EcosystemViewProps { - onIngested: () => void; -} - -export const EcosystemView: React.FC = ({ onIngested }) => { - const [keys, setKeys] = useState([]); - const [newKeyName, setNewKeyName] = useState(''); - const [newlyCreatedKey, setNewlyCreatedKey] = useState(null); - const [copiedKey, setCopiedKey] = useState(false); - - // Live Webhook Simulator State - const [simTitle, setSimTitle] = useState('Waschmaschine im Keller ist fertig!'); - const [simPriority, setSimPriority] = useState('high'); - const [simStatus, setSimStatus] = useState(null); - - const loadKeys = async () => { - const k = await api.getAPIKeys(); - setKeys(k); - }; - - useEffect(() => { - loadKeys(); - }, []); - - const handleCreateKey = async (e: React.FormEvent) => { - e.preventDefault(); - triggerHaptic(15); - const created = await api.createAPIKey(newKeyName.trim() || 'Home Assistant'); - if (created.rawKey) { - setNewlyCreatedKey(created.rawKey); - } - setNewKeyName(''); - await loadKeys(); - }; - - const handleDeleteKey = async (id: string) => { - triggerHaptic(20); - await api.deleteAPIKey(id); - await loadKeys(); - }; - - const handleCopy = (text: string) => { - navigator.clipboard.writeText(text); - setCopiedKey(true); - setTimeout(() => setCopiedKey(false), 2000); - }; - - const handleSimulateWebhook = async () => { - triggerHaptic(20); - const tokenToUse = newlyCreatedKey || getToken() || ''; - try { - await api.testWebhookIngest(tokenToUse, simTitle, simPriority, ['smarthome', 'automation']); - setSimStatus('✅ Webhook erfolgreich ausgelöst! Neue Karte liegt in deiner Inbox.'); - onIngested(); - setTimeout(() => setSimStatus(null), 4000); - } catch (err: any) { - setSimStatus(`❌ Fehler: ${err.message}`); - } - }; - - const handleFullExport = async () => { - triggerHaptic(15); - const data = await api.exportAll(); - const blob = new Blob([JSON.stringify(data, null, 2)], { - type: 'application/json;charset=utf-8', - }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `mindsift-backup-${new Date().toISOString().slice(0, 10)}.json`; - a.click(); - URL.revokeObjectURL(url); - }; - - const exampleToken = newlyCreatedKey || 'mindsift_sec_dein_api_key'; - const baseUrl = window.location.origin; - - return ( -
    - {/* Section 1: API Keys for Smarthome & Automation */} -
    -
    -
    - -
    -
    -

    - Webhooks & Ingestion API-Schlüssel -

    -

    - Verbinde Home Assistant, Zigbee2MQTT, n8n oder eigene Cronjobs direkt mit deiner MindSift Inbox. -

    -
    -
    - -
    - setNewKeyName(e.target.value)} - placeholder="Bezeichnung (z. B. Home Assistant, Proxmox Backup, Rauchmelder)…" - className="w-full sm:flex-1 min-w-0 h-11 sm:h-9 px-3 input-field text-base sm:text-sm" - /> - -
    - - {newlyCreatedKey && ( -
    -
    -

    - Neuer API-Schlüssel erstellt (bitte jetzt kopieren): -

    - - {newlyCreatedKey} - -
    - -
    - )} - - {/* Active Keys List */} -
    - {keys.length === 0 ? ( -

    - Noch keine dedizierten API-Schlüssel hinterlegt. -

    - ) : ( - keys.map((k) => ( -
    -
    - {k.name} - {k.keyPrefix} - {k.lastUsedAt && ( - - Zuletzt genutzt: {new Date(k.lastUsedAt).toLocaleDateString('de-DE')} - - )} -
    - -
    - )) - )} -
    -
    - - {/* Section 2: Interactive Smarthome Webhook Simulator */} -
    -
    -
    - -
    -
    -

    - Live Smarthome Webhook-Simulator -

    -

    - Teste den Webhook-Endpunkt direkt hier – das Event landet sofort als Karte im Triage-Deck. -

    -
    -
    - -
    - {[ - { label: '🧺 Waschmaschine fertig', text: 'Waschmaschine im Keller ist fertig – Wäsche aufhängen!', prio: 'high' as PriorityLevel }, - { label: '🚨 Rauchmelder Batterie', text: 'Rauchmelder Dachgeschoss meldet niedrige Batterie (12%)', prio: 'urgent' as PriorityLevel }, - { label: '💧 Luftfeuchtigkeit Keller', text: 'Kellerfeuchtigkeit über 70% – Lüften oder Entfeuchter starten', prio: 'urgent' as PriorityLevel }, - ].map((preset) => ( - - ))} -
    - -
    - setSimTitle(e.target.value)} - className="flex-1 h-9 px-3 input-field text-sm" - /> - -
    - - {simStatus && ( -

    {simStatus}

    - )} - - {/* Code Example Snippets */} -
    -
    - - cURL / Home Assistant REST Command: -
    -
    -{`curl -X POST "${baseUrl}/api/v1/webhooks/ingest?token=${exampleToken}" \\
    -  -H "Content-Type: application/json" \\
    -  -d '{
    -    "title": "Kellerfeuchtigkeit über 70%",
    -    "priority": "urgent",
    -    "tags": ["smarthome", "keller"]
    -  }'`}
    -          
    -
    -
    - - {/* Section 3: Data Sovereignty & Export */} -
    -
    -
    - -
    -
    -

    - 100 % Datensouveränität & Komplett-Export -

    -

    - Exportiere jederzeit alle Gedanken, Aufgaben, Dokumente und geteilten Listen als portables JSON-Archiv. -

    -
    -
    - - -
    -
    - ); -}; diff --git a/frontend/src/features/settings/UserSettingsView.tsx b/frontend/src/features/settings/UserSettingsView.tsx index f347c15..b83c188 100644 --- a/frontend/src/features/settings/UserSettingsView.tsx +++ b/frontend/src/features/settings/UserSettingsView.tsx @@ -3,9 +3,7 @@ import { User as UserIcon, Bell, Key, - Plus, Trash2, - Terminal, Download, ArrowLeft, Check, @@ -20,7 +18,8 @@ import { Pencil, Copy, } from 'lucide-react'; -import { APIKey, AuthStatus, User, UserTheme } from '../../lib/types'; +import { AuthStatus, User, UserTheme } from '../../lib/types'; +import { ApiKeysPanel } from './ApiKeysPanel'; import { api, clearToken, triggerHaptic } from '../../lib/api'; import { passwordProblem } from '../../lib/password'; import { customThemeId, ThemeId, ThemeOption } from '../../lib/theme'; @@ -208,12 +207,6 @@ export const UserSettingsView: React.FC = ({ const [savingAlarms, setSavingAlarms] = useState(false); const [testingChannel, setTestingChannel] = useState(null); - // --- Webhooks & API Keys State --- - const [apiKeys, setApiKeys] = useState([]); - const [newKeyName, setNewKeyName] = useState(''); - const [newlyCreatedKey, setNewlyCreatedKey] = useState(null); - const [copiedLabel, setCopiedLabel] = useState(null); - useEffect(() => { setDisplayName(currentUser.displayName || ''); setEmail(currentUser.email || ''); @@ -225,19 +218,6 @@ export const UserSettingsView: React.FC = ({ setNotifyBeforeMinutes(currentUser.notifyBeforeMinutes ?? 30); }, [currentUser]); - useEffect(() => { - loadKeys(); - }, []); - - const loadKeys = async () => { - try { - const keys = await api.getAPIKeys(); - setApiKeys(keys); - } catch { - // ignore - } - }; - const toggleAlarmChannel = (ch: string) => { setAlarmChannels((prev) => prev.includes(ch) ? prev.filter((item) => item !== ch) : [...prev, ch] @@ -382,34 +362,7 @@ export const UserSettingsView: React.FC = ({ } }; - // --- Handlers: Webhooks & Export --- - const handleCreateAPIKey = async (e: React.FormEvent) => { - e.preventDefault(); - triggerHaptic(15); - try { - const created = await api.createAPIKey(newKeyName.trim() || 'Eigenes Skript / Webhook'); - if (created.rawKey) { - setNewlyCreatedKey(created.rawKey); - } - setNewKeyName(''); - loadKeys(); - } catch (err: any) { - alert(err.message); - } - }; - - const handleDeleteAPIKey = async (id: string) => { - if (!window.confirm('Diesen API-Schlüssel wirklich widerrufen?')) return; - await api.deleteAPIKey(id); - loadKeys(); - }; - - const handleCopy = (text: string, label: string) => { - navigator.clipboard.writeText(text); - setCopiedLabel(label); - setTimeout(() => setCopiedLabel(null), 2000); - }; - + // --- Handlers: Export --- // Works on mobile browsers too: the link must be in the document and the URL alive until the click ran const saveBlob = (blob: Blob, filename: string) => { const url = URL.createObjectURL(blob); @@ -448,20 +401,6 @@ export const UserSettingsView: React.FC = ({ } }; - const baseUrl = window.location.origin; - const activeKeyPlaceholder = - newlyCreatedKey || (apiKeys[0]?.keyPrefix ? apiKeys[0].keyPrefix + '••••••••' : 'DEIN_API_KEY'); - - const curlTriageExample = `curl -X POST ${baseUrl}/api/v1/inbox \\ - -H "X-API-Key: ${activeKeyPlaceholder}" \\ - -H "Content-Type: application/json" \\ - -d '{"content": "Kellerfeuchtigkeit über 68% gestiegen – Entfeuchter prüfen #smarthome #alarm", "priority": "urgent"}'`; - - const curlTaskExample = `curl -X POST ${baseUrl}/api/v1/inbox \\ - -H "X-API-Key: ${activeKeyPlaceholder}" \\ - -H "Content-Type: application/json" \\ - -d '{"content": "Server-Alarm prüfen #monitoring", "priority": "urgent", "target": "task"}'`; - const roleBadgeLabel = currentUser.role === 'admin' ? 'Server-Admin' @@ -1226,108 +1165,7 @@ export const UserSettingsView: React.FC = ({

    - {/* Create API Key */} -
    - setNewKeyName(e.target.value)} - placeholder="Bezeichnung des Schlüssels (z.B. Proxmox Monitoring, Discord-Forwarder)…" - className="w-full sm:flex-1 min-w-0 h-11 sm:h-9 px-3 input-field text-base sm:text-xs" - /> - -
    - - {newlyCreatedKey && ( -
    -
    -

    - Neuer API-Schlüssel erstellt (bitte jetzt kopieren, wird nur einmal im - Klartext angezeigt): -

    - {newlyCreatedKey} -
    - -
    - )} - - {/* Existing Keys List */} -
    - {apiKeys.length === 0 ? ( -
    - Noch keine persönlichen API-Schlüssel vorhanden. -
    - ) : ( - apiKeys.map((k) => ( -
    -
    -

    {k.name}

    -

    - Präfix: {k.keyPrefix}•••••••• · Erstellt:{' '} - {new Date(k.createdAt).toLocaleDateString()} -

    -
    - -
    - )) - )} -
    - - {/* Curl Examples */} -
    -
    -
    - - - Curl: In Triage-Deck einspeisen (erhält Webhook-Badge) - - -
    -
    -                    {curlTriageExample}
    -                  
    -
    - -
    -
    - - - Curl: Direkt als Aufgabe anlegen - - -
    -
    -                    {curlTaskExample}
    -                  
    -
    -
    + {/* Full Data Backup */}
    diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 45bae9c..9609836 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -4,6 +4,7 @@ import { DashboardStats, Document, TrashedDocument, + ApiKeyScope, Backlink, DocumentBlock, DocShare, @@ -1108,10 +1109,10 @@ export const api = { return ensureArray(res); }, - async createAPIKey(name: string): Promise { + async createAPIKey(name: string, scopes: ApiKeyScope[]): Promise { return request('/api/v1/keys', { method: 'POST', - body: JSON.stringify({ name }), + body: JSON.stringify({ name, scopes }), }); }, @@ -1119,19 +1120,23 @@ export const api = { await request(`/api/v1/keys/${id}`, { method: 'DELETE' }); }, + /** Simulator: sends like an external system would, with the given key or else the own session. */ async testWebhookIngest( - token: string, - title: string, - priority: PriorityLevel, - tags: string[] - ): Promise<{ status: string; thought: Thought }> { - return request<{ status: string; thought: Thought }>( - `/api/v1/webhooks/ingest?token=${encodeURIComponent(token)}`, - { - method: 'POST', - body: JSON.stringify({ title, priority, tags }), - } - ); + apiKey: string | null, + payload: { + title: string; + priority?: PriorityLevel; + tags?: string[]; + target?: 'triage' | 'task' | 'list_item'; + taskListId?: string; + listId?: string; + } + ): Promise<{ status: string }> { + return request<{ status: string }>('/api/v1/webhooks/ingest', { + method: 'POST', + headers: apiKey ? { 'X-API-Key': apiKey } : {}, + body: JSON.stringify(payload), + }); }, async searchAll(q: string): Promise { diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 8959c78..37e74c7 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -477,13 +477,16 @@ export interface UserTheme { updatedAt: string; } +/** What an API key may deliver; keys never read data. */ +export type ApiKeyScope = 'webhook:triage' | 'webhook:task' | 'webhook:list'; + export interface APIKey { id: string; userId: string; name: string; keyPrefix: string; rawKey?: string; - scopes: string; + scopes: string; // comma separated ApiKeyScope values lastUsedAt?: string; createdAt: string; } diff --git a/internal/api/handlers_integrations.go b/internal/api/handlers_integrations.go index 487ebbe..c26717c 100644 --- a/internal/api/handlers_integrations.go +++ b/internal/api/handlers_integrations.go @@ -1,7 +1,9 @@ package api import ( + "context" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -9,6 +11,7 @@ import ( "github.com/go-chi/chi/v5" "github.com/sandrodach/mindsift/internal/auth" + "github.com/sandrodach/mindsift/internal/storage" ) // --- API KEYS & WEBHOOK INGESTION --- @@ -26,11 +29,12 @@ func (s *Server) handleGetAPIKeys(w http.ResponseWriter, r *http.Request) { func (s *Server) handleCreateAPIKey(w http.ResponseWriter, r *http.Request) { userID := auth.GetUserID(r.Context()) var req struct { - Name string `json:"name"` + Name string `json:"name"` + Scopes []string `json:"scopes"` } _ = json.NewDecoder(r.Body).Decode(&req) - key, err := s.store.CreateAPIKey(userID, req.Name) + key, err := s.store.CreateAPIKey(userID, req.Name, req.Scopes) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return @@ -59,11 +63,14 @@ func (s *Server) handleWebhookIngest(w http.ResponseWriter, r *http.Request) { } // Same checks as every other request (revoked/outdated sessions, inactive accounts) - user, _ := s.authenticateToken(token) + user, keyScopes := s.authenticateToken(token) if user == nil { writeError(w, http.StatusUnauthorized, "Ungültiger Webhook-Token oder API-Key") return } + if keyScopes != nil { + r = r.WithContext(context.WithValue(r.Context(), apiKeyScopesKey{}, keyScopes)) + } var req struct { Title string `json:"title"` @@ -75,6 +82,13 @@ func (s *Server) handleWebhookIngest(w http.ResponseWriter, r *http.Request) { Tags []string `json:"tags"` DueDate string `json:"dueDate"` DueDate2 string `json:"due_date"` + // Where it goes: "triage" (default, inbox), "task" or "list_item"; "type" is accepted as well + Target string `json:"target"` + Type string `json:"type"` + TaskListID string `json:"taskListId"` + ListID string `json:"listId"` + Category string `json:"category"` + Quantity string `json:"quantity"` } bodyBytes, _ := io.ReadAll(r.Body) @@ -120,8 +134,20 @@ func (s *Server) handleWebhookIngest(w http.ResponseWriter, r *http.Request) { } } - if req.Priority == "" { - req.Priority = "high" + target := strings.ToLower(strings.TrimSpace(req.Target)) + if target == "" { + target = strings.ToLower(strings.TrimSpace(req.Type)) + } + switch target { + case "", "triage", "inbox": + target = "triage" + case "task", "list_item": + default: + writeError(w, http.StatusBadRequest, "Unbekanntes Ziel – erlaubt: triage, task, list_item") + return + } + if !requireDeliveryScope(w, r, target) { + return } source := strings.ToLower(strings.TrimSpace(req.Source)) @@ -129,6 +155,18 @@ func (s *Server) handleWebhookIngest(w http.ResponseWriter, r *http.Request) { source = "webhook" } + if target == "list_item" { + s.ingestListItem(w, user.ID, req.ListID, bodyText, req.Category, req.Quantity) + return + } + if target == "task" { + s.ingestTask(w, user.ID, bodyText, req.Priority, source, dueDate, req.TaskListID) + return + } + + if req.Priority == "" { + req.Priority = "high" + } thought, err := s.store.CreateThought(user.ID, bodyText, req.Priority, source, dueDate) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) @@ -142,3 +180,50 @@ func (s *Server) handleWebhookIngest(w http.ResponseWriter, r *http.Request) { "thought": thought, }) } + +// ingestTask creates a task directly (scope webhook:task), optionally in a task list the owner of +// the key may write to. +func (s *Server) ingestTask(w http.ResponseWriter, userID, content, priority, source, dueDate, taskListID string) { + if priority == "" { + priority = "normal" + } + if taskListID != "" && !s.store.CanEditTaskList(userID, taskListID) { + writeError(w, http.StatusForbidden, storage.ErrTaskListReadOnly.Error()) + return + } + thought, err := s.store.CreateThoughtWithDetails(userID, content, priority, source, dueDate, taskListID, "todo", "") + if err != nil { + writeStoreError(w, http.StatusBadRequest, err) + return + } + task, err := s.store.UpdateThoughtTriage(userID, thought.ID, "keep", "task", "", dueDate, taskListID) + if err != nil { + writeStoreError(w, http.StatusBadRequest, err) + return + } + s.wsHub.BroadcastToUsers(s.taskAudience(task), "TASK_CREATED", task) + writeJSON(w, http.StatusCreated, map[string]interface{}{"status": "ingested", "task": task}) +} + +// ingestListItem adds an item to a list (scope webhook:list) the owner of the key may write to. +func (s *Server) ingestListItem(w http.ResponseWriter, userID, listID, content, category, quantity string) { + if strings.TrimSpace(listID) == "" { + writeError(w, http.StatusBadRequest, "Feld 'listId' ist für Listeneinträge erforderlich") + return + } + if err := s.store.EnsureListItemsWritable(userID, listID, ""); err != nil { + status := http.StatusNotFound + if errors.Is(err, storage.ErrListReadOnly) { + status = http.StatusForbidden + } + writeError(w, status, err.Error()) + return + } + item, err := s.store.CreateListItem(listID, content, category, quantity, 0, userID) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + s.wsHub.BroadcastListEvent(listID, "ITEM_CREATED", item) + writeJSON(w, http.StatusCreated, map[string]interface{}{"status": "ingested", "item": item}) +} diff --git a/internal/api/handlers_thoughts.go b/internal/api/handlers_thoughts.go index 27cfbb2..9bed8a6 100644 --- a/internal/api/handlers_thoughts.go +++ b/internal/api/handlers_thoughts.go @@ -60,6 +60,10 @@ func (s *Server) handleCaptureInbox(w http.ResponseWriter, r *http.Request) { } } + if !requireDeliveryScope(w, r, strings.TrimSpace(req.Target)) { + return + } + thought, err := s.store.CreateThoughtWithDetails(userID, req.Content, req.Priority, source, dueDate, taskListID, "todo", "") if err != nil { writeStoreError(w, http.StatusBadRequest, err) diff --git a/internal/api/realtime.go b/internal/api/realtime.go index 03d17d1..27079db 100644 --- a/internal/api/realtime.go +++ b/internal/api/realtime.go @@ -16,21 +16,9 @@ func (s *Server) authenticateUserFromReq(r *http.Request) string { token = strings.TrimPrefix(authHdr, "Bearer ") } } - if token != "" { - if strings.HasPrefix(token, "mindsift_sec_") { - if user, err := s.store.ValidateAPIKeyAndGetUser(token); err == nil && user != nil && user.IsActive { - return user.ID - } - } - if claims, err := auth.ValidateToken(s.cfg.JWTSecret, token); err == nil && claims != nil { - if !s.store.IsTokenRevoked(token) { - if user, err := s.store.GetUserByID(claims.UserID); err == nil && user != nil && user.IsActive { - if claims.TokenVersion == 0 || claims.TokenVersion == user.TokenVersion { - return claims.UserID - } - } - } - } + // Live streams carry the user's data, so API keys (which only deliver) are not accepted here + if user, keyScopes := s.authenticateToken(token); user != nil && keyScopes == nil { + return user.ID } return "" } diff --git a/internal/api/server.go b/internal/api/server.go index 6d8ba14..bcb17a2 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -1,6 +1,7 @@ package api import ( + "context" "encoding/json" "errors" "io/fs" @@ -382,34 +383,64 @@ func (s *Server) apiLoggingMiddleware(next http.Handler) http.Handler { // authenticateToken resolves a session JWT or an API key (mindsift_sec_…) to an active user. JWTs are // checked against the revocation list and token_version; viaAPIKey tells the caller which it was. -func (s *Server) authenticateToken(token string) (user *domain.User, viaAPIKey bool) { +// authenticateToken checks a session token or an API key. keyScopes is nil for sessions and the key's +// scopes for API keys. +func (s *Server) authenticateToken(token string) (user *domain.User, keyScopes []string) { token = strings.TrimSpace(token) if token == "" { - return nil, false + return nil, nil } if strings.HasPrefix(token, "mindsift_sec_") { - u, err := s.store.ValidateAPIKeyAndGetUser(token) + u, scopes, err := s.store.ValidateAPIKey(token) if err != nil || u == nil || !u.IsActive { - return nil, true + return nil, nil } - return u, true + return u, scopes } claims, err := auth.ValidateToken(s.cfg.JWTSecret, token) if err != nil || claims == nil || s.store.IsTokenRevoked(token) { - return nil, false + return nil, nil } u, err := s.store.GetUserByID(claims.UserID) if err != nil || u == nil || !u.IsActive { - return nil, false + return nil, nil } if claims.TokenVersion != 0 && claims.TokenVersion != u.TokenVersion { - return nil, false + return nil, nil } - return u, false + return u, nil } -// apiKeyAllowed: until API keys get scopes, a key has exactly one right – delivering data into MindSift -// (POST /api/v1/inbox; /api/v1/webhooks/ingest authenticates on its own). Everything else needs a login. +type apiKeyScopesKey struct{} + +// requireDeliveryScope: API keys need the scope for the target they deliver to; sessions may do all. +// target: "inbox"/"triage", "task", "list_item". +func requireDeliveryScope(w http.ResponseWriter, r *http.Request, target string) bool { + scopes, isKey := r.Context().Value(apiKeyScopesKey{}).([]string) + if !isKey { + return true + } + need := storage.ScopeTriage + switch target { + case "task": + need = storage.ScopeTask + case "list_item": + need = storage.ScopeList + case "document": + need = "" // notes cannot be created with a key + } + for _, sc := range scopes { + if need != "" && sc == need { + return true + } + } + writeError(w, http.StatusForbidden, "API-Key besitzt keine Berechtigung für diese Aktion") + return false +} + +// apiKeyAllowed: an API key only delivers data into MindSift (POST /api/v1/inbox; /api/v1/webhooks/ingest +// authenticates on its own); what it may deliver is decided by its scopes (requireDeliveryScope). +// Everything else needs a login. func apiKeyAllowed(r *http.Request) bool { return r.Method == http.MethodPost && r.URL.Path == "/api/v1/inbox" } @@ -422,16 +453,20 @@ func (s *Server) authMiddleware(next http.Handler) http.Handler { } else { token = r.Header.Get("X-API-Key") } - user, viaAPIKey := s.authenticateToken(token) + user, keyScopes := s.authenticateToken(token) if user == nil { writeError(w, http.StatusUnauthorized, "Nicht autorisiert – bitte anmelden") return } - if viaAPIKey && !apiKeyAllowed(r) { - writeError(w, http.StatusForbidden, "API-Keys dürfen nur Daten einliefern (POST /api/v1/inbox oder /api/v1/webhooks/ingest)") - return + ctx := auth.WithUserID(r.Context(), user.ID) + if keyScopes != nil { + if !apiKeyAllowed(r) { + writeError(w, http.StatusForbidden, "API-Keys dürfen nur Daten einliefern (POST /api/v1/inbox oder /api/v1/webhooks/ingest)") + return + } + ctx = context.WithValue(ctx, apiKeyScopesKey{}, keyScopes) } - next.ServeHTTP(w, r.WithContext(auth.WithUserID(r.Context(), user.ID))) + next.ServeHTTP(w, r.WithContext(ctx)) }) } diff --git a/internal/api/server_test.go b/internal/api/server_test.go index c424365..66257b5 100644 --- a/internal/api/server_test.go +++ b/internal/api/server_test.go @@ -796,7 +796,7 @@ func TestWebhookTriageBadgeAndIngestion(t *testing.T) { _ = json.Unmarshal(rec.Body.Bytes(), &authResp) // 2. Create API key for user - apiKey, err := store.CreateAPIKey(authResp.User.ID, "Home Assistant Webhook") + apiKey, err := store.CreateAPIKey(authResp.User.ID, "Home Assistant Webhook", nil) if err != nil || apiKey == nil || apiKey.RawKey == "" { t.Fatalf("failed to create API key: %v", err) } @@ -1416,13 +1416,13 @@ func TestLegalTextsArePublic(t *testing.T) { } } -// API keys have exactly one right until scopes exist: delivering data. Revoked sessions are refused +// API keys only deliver data, and only what their scopes allow (FLAW-07). Revoked sessions are refused // everywhere, the webhook ingest included. func TestAPIKeysOnlyDeliverData(t *testing.T) { srv, store, token := setupTestServer(t) router := srv.Router() claims, _ := auth.ValidateToken(testJWTSecret, token) - key, err := store.CreateAPIKey(claims.UserID, "Home Assistant") + key, err := store.CreateAPIKey(claims.UserID, "Home Assistant", nil) if err != nil { t.Fatalf("CreateAPIKey: %v", err) } @@ -1441,8 +1441,36 @@ func TestAPIKeysOnlyDeliverData(t *testing.T) { if code := do(http.MethodPost, "/api/v1/inbox", key.RawKey, `{"content":"Waschmaschine fertig"}`); code >= 300 { t.Fatalf("API key must be able to post into the inbox, got %d", code) } - if code := do(http.MethodPost, "/api/v1/inbox", key.RawKey, `{"content":"Filter wechseln","target":"task"}`); code >= 300 { - t.Fatalf("API key must be able to create a task via the inbox, got %d", code) + // Default key = triage only + if code := do(http.MethodPost, "/api/v1/inbox", key.RawKey, `{"content":"Filter wechseln","target":"task"}`); code != http.StatusForbidden { + t.Fatalf("triage-only key must not create tasks, got %d", code) + } + if code := do(http.MethodPost, "/api/v1/webhooks/ingest", key.RawKey, `{"content":"Milch","target":"list_item","listId":"x"}`); code != http.StatusForbidden { + t.Fatalf("triage-only key must not add list items, got %d", code) + } + full, _ := store.CreateAPIKey(claims.UserID, "Alles", []string{"webhook:task", "webhook:list"}) + if code := do(http.MethodPost, "/api/v1/webhooks/ingest", full.RawKey, `{"title":"Filter wechseln","target":"task","priority":"high"}`); code != http.StatusCreated { + t.Fatalf("task scope must create a task, got %d", code) + } + if code := do(http.MethodPost, "/api/v1/webhooks/ingest", full.RawKey, `{"content":"Rauchmelder"}`); code != http.StatusForbidden { + t.Fatalf("key without triage scope must not fill the inbox, got %d", code) + } + list, _ := store.CreateList(claims.UserID, "Einkauf", "", "shopping") + if code := do(http.MethodPost, "/api/v1/webhooks/ingest", full.RawKey, `{"content":"Milch","target":"list_item","listId":"`+list.ID+`","quantity":"2"}`); code != http.StatusCreated { + t.Fatalf("list scope must add the item, got %d", code) + } + if items, _ := store.GetListItems(list.ID); len(items) != 1 || items[0].Quantity != "2" { + t.Fatalf("list item not stored: %+v", items) + } + other, _ := store.CreateUser("other@example.com", "hash", "Other", "member") + foreign, _ := store.CreateList(other.ID, "Fremd", "", "shopping") + if code := do(http.MethodPost, "/api/v1/webhooks/ingest", full.RawKey, `{"content":"Spam","target":"list_item","listId":"`+foreign.ID+`"}`); code != http.StatusNotFound { + t.Fatalf("key must not write into lists its owner cannot see, got %d", code) + } + // Keys never open live streams (those carry the user's data) + streamReq := httptest.NewRequest(http.MethodGet, "/api/v1/events?token="+full.RawKey, nil) + if uid := srv.authenticateUserFromReq(streamReq); uid != "" { + t.Fatalf("API key must not authenticate a live stream") } if code := do(http.MethodGet, "/api/v1/documents", key.RawKey, ""); code != http.StatusForbidden { t.Fatalf("API key must not read notes, got %d", code) diff --git a/internal/storage/apikeys.go b/internal/storage/apikeys.go index 80a2c84..435bb59 100644 --- a/internal/storage/apikeys.go +++ b/internal/storage/apikeys.go @@ -10,7 +10,32 @@ import ( // --- API KEYS & WEBHOOKS --- -func (s *Store) CreateAPIKey(userID, name string) (*domain.APIKey, error) { +// Scopes of an API key: what it may deliver. A key never reads data. +const ( + ScopeTriage = "webhook:triage" // thoughts into the inbox + ScopeTask = "webhook:task" // tasks directly + ScopeList = "webhook:list" // items into lists +) + +// NormalizeScopes keeps the known scopes in a fixed order; none at all means triage only. +func NormalizeScopes(scopes []string) []string { + want := map[string]bool{} + for _, sc := range scopes { + want[strings.ToLower(strings.TrimSpace(sc))] = true + } + out := []string{} + for _, sc := range []string{ScopeTriage, ScopeTask, ScopeList} { + if want[sc] { + out = append(out, sc) + } + } + if len(out) == 0 { + out = []string{ScopeTriage} + } + return out +} + +func (s *Store) CreateAPIKey(userID, name string, scopes []string) (*domain.APIKey, error) { if strings.TrimSpace(name) == "" { name = "Smarthome / Webhook Key" } @@ -22,7 +47,7 @@ func (s *Store) CreateAPIKey(userID, name string) (*domain.APIKey, error) { KeyPrefix: prefix, KeyHash: hash, RawKey: rawKey, - Scopes: "ingest,read", + Scopes: strings.Join(NormalizeScopes(scopes), ","), CreatedAt: domain.NowISO(), } @@ -64,12 +89,19 @@ func (s *Store) DeleteAPIKey(userID, keyID string) error { } func (s *Store) ValidateAPIKeyAndGetUser(rawKey string) (*domain.User, error) { + u, _, err := s.ValidateAPIKey(rawKey) + return u, err +} + +// ValidateAPIKey returns the owner of a key and the key's scopes. +func (s *Store) ValidateAPIKey(rawKey string) (*domain.User, []string, error) { hash := auth.HashAPIKey(strings.TrimSpace(rawKey)) - var keyID, userID string - err := s.db.QueryRow(`SELECT id, user_id FROM api_keys WHERE key_hash = ?`, hash).Scan(&keyID, &userID) + var keyID, userID, scopes string + err := s.db.QueryRow(`SELECT id, user_id, scopes FROM api_keys WHERE key_hash = ?`, hash).Scan(&keyID, &userID, &scopes) if err != nil { - return nil, errors.New("invalid API key") + return nil, nil, errors.New("invalid API key") } _, _ = s.db.Exec(`UPDATE api_keys SET last_used_at = ? WHERE id = ?`, domain.NowISO(), keyID) - return s.GetUserByID(userID) + u, err := s.GetUserByID(userID) + return u, NormalizeScopes(strings.Split(scopes, ",")), err } diff --git a/internal/storage/sqlite.go b/internal/storage/sqlite.go index 5c2cd75..00c221c 100644 --- a/internal/storage/sqlite.go +++ b/internal/storage/sqlite.go @@ -497,6 +497,9 @@ func (s *Store) migrate() error { _, _ = s.db.Exec(`UPDATE team_members SET role = 'member' WHERE role NOT IN ('admin', 'member')`) _, _ = s.db.Exec(`UPDATE users SET role = 'member' WHERE role = 'team_admin'`) + // Keys from before scopes existed ("ingest,read") may deliver into the inbox, as before + _, _ = s.db.Exec(`UPDATE api_keys SET scopes = 'webhook:triage' WHERE scopes NOT LIKE '%webhook:%'`) + // Block tree, operation log and idempotency tables for collaborative editing s.migrateDocSync() diff --git a/internal/storage/sqlite_test.go b/internal/storage/sqlite_test.go index a3d957b..2a94316 100644 --- a/internal/storage/sqlite_test.go +++ b/internal/storage/sqlite_test.go @@ -87,7 +87,7 @@ func TestStoreLifecycle(t *testing.T) { } // 4. Test Webhook API Key generation & validation - apiKey, err := store.CreateAPIKey(user.ID, "Home Assistant") + apiKey, err := store.CreateAPIKey(user.ID, "Home Assistant", nil) if err != nil { t.Fatalf("CreateAPIKey failed: %v", err) } From f57a9884d12db808fe5a2853c5e49fa736773486 Mon Sep 17 00:00:00 2001 From: Sandro Date: Thu, 29 Oct 2026 14:46:58 +0100 Subject: [PATCH 16/26] fix(security): stream tickets instead of session tokens in URLs (FLAW-22) WebSocket and EventSource put the 30-day session token into the URL, where proxies log it. The app now gets a one-time ticket right before connecting (POST /auth/stream-ticket, 60 s, bound to the token version); ?token= is no longer accepted for streams, non-browser clients may use the Authorization header. With BASE_URL set, CORS allows only that origin and WebSocket upgrades only it or the requested host; credentials are off (no cookies exist). Logout, ending all sessions, a new password and deactivation close all live streams of the account; clients with a valid session reconnect with a fresh ticket. Co-Authored-By: Claude Opus 5.5 --- Doku/api/rest.md | 5 +- Doku/api/websocket.md | 2 +- Doku/flaws.md | 3 +- Doku/reverse-proxy.md | 3 +- .../src/features/lists/SharedListsView.tsx | 2 +- frontend/src/lib/__tests__/realtime.test.ts | 18 +++- frontend/src/lib/api.ts | 68 ++++++++++--- internal/api/handlers_admin.go | 4 + internal/api/handlers_auth.go | 4 + internal/api/password_reset.go | 1 + internal/api/realtime.go | 21 +++- internal/api/realtime_test.go | 24 ++++- internal/api/server.go | 15 ++- internal/api/server_test.go | 9 +- internal/api/streamticket.go | 97 +++++++++++++++++++ internal/api/streamticket_test.go | 73 ++++++++++++++ internal/ws/hub.go | 27 +++++- 17 files changed, 335 insertions(+), 41 deletions(-) create mode 100644 internal/api/streamticket.go create mode 100644 internal/api/streamticket_test.go diff --git a/Doku/api/rest.md b/Doku/api/rest.md index 638617d..c7a311a 100644 --- a/Doku/api/rest.md +++ b/Doku/api/rest.md @@ -16,8 +16,9 @@ Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}` | 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 | -| GET | `/ws`, `/ws/spaces/{id}`, `/ws/lists/{id}` | WebSocket, siehe [websocket.md](websocket.md); Token als `?token=` nötig | -| GET | `/events` | SSE-Fallback; Token als `?token=` nötig | +| 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 | ## Konto und Allgemeines (angemeldet) diff --git a/Doku/api/websocket.md b/Doku/api/websocket.md index a1f1312..1a2badd 100644 --- a/Doku/api/websocket.md +++ b/Doku/api/websocket.md @@ -20,7 +20,7 @@ Der Client versucht zuerst WebSocket und wechselt in folgenden Fällen für die Ohne WebSocket und SSE nutzt die Notiz-Synchronisation Long-Polling (`GET /docs/{id}/changes`, bis 25 s), und wenn auch das dreimal scheitert, eine Abfrage alle 3 s (siehe [modules/workspace.md](../modules/workspace.md)). -**Authentifizierung:** Token als `?token=` oder `Authorization`-Header. Ohne gültiges Token antworten `/ws` und `/events` mit `401`. Das Token wird nur beim Verbinden geprüft ([FLAW-22](../flaws.md)). +**Authentifizierung:** Browser holen sich direkt vor dem Verbinden ein Einmal-Ticket (`POST /auth/stream-ticket` mit dem normalen `Authorization`-Header → `{ticket, expiresIn: 60}`) und hängen es als `?ticket=` an; es gilt 60 Sekunden und einmal und wird ungültig, sobald alle Sitzungen beendet werden. Andere Clients dürfen stattdessen den `Authorization`-Header schicken. Sitzungs-Tokens in der URL und API-Keys werden nicht angenommen (`401`). Bei Logout, „Alle Sitzungen beenden“, neuem Passwort und Deaktivierung trennt der Server alle Streams des Kontos; Clients mit gültiger Sitzung verbinden sich mit neuem Ticket neu. Mit gesetzter `BASE_URL` nimmt der WebSocket nur Verbindungen von dieser Adresse oder vom angefragten Host an ([FLAW-22](../flaws.md)). **Räume:** Jeder Beitritt (Raum in der URL, `JOIN_ROOM`, SSE-Startparameter `rooms`/`docId`, `POST /events/rooms`) wird geprüft: `doc:` nur mit Lesezugriff auf die Notiz, sonst muss die ID eine lesbare geteilte Liste sein. Nicht erlaubte Räume werden ignoriert, `POST /events/rooms` antwortet dann mit `403`. SSE-Räume kann nur der Besitzer der jeweiligen `sseId` ändern. diff --git a/Doku/flaws.md b/Doku/flaws.md index 0992085..52c9da1 100644 --- a/Doku/flaws.md +++ b/Doku/flaws.md @@ -31,7 +31,7 @@ Schweregrade: **Hoch** (Datenabfluss oder Rechteumgehung möglich), **Mittel** ( | FLAW-19 | Qualität | Frontend-Tests nur für den Outliner, `lint` ist ein Platzhalter | Niedrig | | FLAW-20 | Qualität | ~~Sehr große Dateien~~; duplizierte Zugriffslogik | Niedrig | | FLAW-21 | Repo | ~~`LICENSE` fehlt, obwohl README MIT angibt~~ | **Behoben** | -| FLAW-22 | Netzwerk | Token in der URL (WS/SSE), CORS `*`, `CheckOrigin` immer `true` | Niedrig | +| FLAW-22 | Netzwerk | ~~Token in der URL (WS/SSE), CORS `*`, `CheckOrigin` immer `true`~~ | **Behoben** | | FLAW-23 | SQLite | Single-Writer-Engpass bei vielen gleichzeitigen Schreibern | Niedrig | | FLAW-24 | Gruppen | ~~Organisations-Endpunkte ohne Berechtigungsprüfung~~ | **Behoben** | | FLAW-25 | Workspace | ~~Struktur-Aktionen an fremd gesperrten Bullets nur teilweise ausgeführt~~ | **Behoben** | @@ -174,6 +174,7 @@ Schweregrade: **Hoch** (Datenabfluss oder Rechteumgehung möglich), **Mittel** ( - README und Badge nennen die MIT-Lizenz und verlinken auf `LICENSE`; die Datei existiert nicht im Repository. ### FLAW-22: Netzwerk-Details +- **Status: Behoben.** WebSocket/SSE nutzen Einmal-Tickets (60 s, einmalig, an `token_version` gebunden) statt des Sitzungs-Tokens in der URL; `?token=` wird für Streams nicht mehr angenommen. Mit `BASE_URL` erlaubt CORS nur diese Adresse und der WebSocket nur sie oder den angefragten Host; Credentials sind generell aus (es gibt keine Cookies). Logout, „Alle Sitzungen beenden“, Passwortwechsel und Deaktivierung trennen alle Streams des Kontos (`Hub.DisconnectUser`). Tests: `streamticket_test.go`, `TestLogoutEndsLiveStreams`. Ohne `BASE_URL` bleibt die Origin-Prüfung aus, weil Proxys den `Host` oft umschreiben. - `authenticateUserFromReq` akzeptiert das Token als `?token=` (nötig für `WebSocket`/`EventSource`, die keine Header setzen). Tokens landen dadurch in Proxy- und Zugriffslogs. - `AllowedOrigins: ["*"]` (Auth läuft über `Authorization`-Header, nicht über Cookies, das Risiko ist daher begrenzt) und `CheckOrigin` liefert immer `true`. - Ein einmal verbundener WebSocket/SSE-Stream wird bei Logout oder Token-Widerruf nicht getrennt. diff --git a/Doku/reverse-proxy.md b/Doku/reverse-proxy.md index 05af823..2019fa1 100644 --- a/Doku/reverse-proxy.md +++ b/Doku/reverse-proxy.md @@ -236,7 +236,8 @@ curl -i -N \ -H "Origin: https://mindsift.example.com" \ -H "Sec-WebSocket-Key: SGVsbG8sIHdvcmxkIQ==" \ -H "Sec-WebSocket-Version: 13" \ - https://mindsift.example.com/api/v1/ws?token= + -H "Authorization: Bearer " \ + https://mindsift.example.com/api/v1/ws ``` *Erwartete Antwort:* `101 Switching Protocols` diff --git a/frontend/src/features/lists/SharedListsView.tsx b/frontend/src/features/lists/SharedListsView.tsx index 9bbf9c8..4272a7b 100644 --- a/frontend/src/features/lists/SharedListsView.tsx +++ b/frontend/src/features/lists/SharedListsView.tsx @@ -11,7 +11,7 @@ import { ShoppingCart, } from 'lucide-react'; import { ListItem, SharedList, ListShare, Team, User, WSMessage } from '../../lib/types'; -import { api, buildWebSocketURL, triggerHaptic, createRealtimeConnection, RealtimeTransport } from '../../lib/api'; +import { api, triggerHaptic, createRealtimeConnection, RealtimeTransport } from '../../lib/api'; import { UnifiedShareModal } from '../../components/UnifiedShareModal'; import { AvatarStack, AvatarPerson } from '../../components/AvatarStack'; import { CountBadge } from '../../components/CountBadge'; diff --git a/frontend/src/lib/__tests__/realtime.test.ts b/frontend/src/lib/__tests__/realtime.test.ts index 02f21ba..9f0f9bd 100644 --- a/frontend/src/lib/__tests__/realtime.test.ts +++ b/frontend/src/lib/__tests__/realtime.test.ts @@ -62,7 +62,7 @@ describe('realtime fallback', () => { vi.stubGlobal('EventSource', FakeEventSource); sessionStorage.clear(); localStorage.setItem('mindsift_jwt_token', 'test-token'); - vi.stubGlobal('fetch', vi.fn(async () => new Response('{}', { status: 200 }))); + vi.stubGlobal('fetch', vi.fn(async () => new Response('{"ticket":"t1"}', { status: 200 }))); }); afterEach(() => { vi.useRealTimers(); @@ -73,7 +73,7 @@ describe('realtime fallback', () => { const api = await loadApi(); const states: string[] = []; api.onRealtimeStatus((connected, transport) => states.push(`${connected}:${transport}`)); - api.connectWebSocket(); + await api.connectWebSocket(); FakeWebSocket.instances[0].open(); expect(api.getRealtimeTransport()).toBe('ws'); @@ -87,9 +87,17 @@ describe('realtime fallback', () => { api.disconnectWebSocket(); }); + it('connects with a one-time ticket, never with the session token in the URL', async () => { + const api = await loadApi(); + await api.connectWebSocket(); + expect(FakeWebSocket.instances[0].url).toContain('ticket=t1'); + expect(FakeWebSocket.instances[0].url).not.toContain('test-token'); + api.disconnectWebSocket(); + }); + it('a WebSocket answering PONG stays in use', async () => { const api = await loadApi(); - api.connectWebSocket(); + await api.connectWebSocket(); const ws = FakeWebSocket.instances[0]; ws.open(); for (let i = 0; i < 6; i++) { @@ -104,7 +112,7 @@ describe('realtime fallback', () => { it('SSE only counts as connected once the first event arrives', async () => { sessionStorage.setItem('mindsift_ws_blocked', 'true'); const api = await loadApi(); - api.connectWebSocket(); + await api.connectWebSocket(); const es = FakeEventSource.instances[0]; es.open(); expect(api.isRealtimeConnected()).toBe(false); @@ -118,7 +126,7 @@ describe('realtime fallback', () => { it('a buffering proxy (stream opens, no events) switches SSE off for this tab', async () => { sessionStorage.setItem('mindsift_ws_blocked', 'true'); const api = await loadApi(); - api.connectWebSocket(); + await api.connectWebSocket(); const es = FakeEventSource.instances[0]; es.open(); await vi.advanceTimersByTimeAsync(5_500); diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 9609836..03887c5 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1209,19 +1209,28 @@ let isRealtimeConnectedState = false; let manualDisconnect = false; let wsReconnectDelay = 2000; -export function buildWebSocketURL(listId?: string): string { - const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; - const token = getToken() || ''; - if (listId) { - return `${protocol}//${window.location.host}/api/v1/ws/lists/${encodeURIComponent(listId)}?token=${encodeURIComponent(token)}`; +// WebSocket and EventSource cannot send headers, so instead of the session token (which would land in +// proxy logs) every connection gets a one-time ticket, valid for a minute (POST /auth/stream-ticket). +async function fetchStreamTicket(): Promise { + try { + const res = await request<{ ticket?: string }>('/api/v1/auth/stream-ticket', { method: 'POST' }); + return res?.ticket || null; + } catch { + return null; } - return `${protocol}//${window.location.host}/api/v1/ws?token=${encodeURIComponent(token)}`; } -export function buildSSEURL(listId?: string, docId?: string, rooms?: string[]): string { - const token = getToken() || ''; +export function buildWebSocketURL(ticket: string, listId?: string): string { + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + if (listId) { + return `${protocol}//${window.location.host}/api/v1/ws/lists/${encodeURIComponent(listId)}?ticket=${encodeURIComponent(ticket)}`; + } + return `${protocol}//${window.location.host}/api/v1/ws?ticket=${encodeURIComponent(ticket)}`; +} + +export function buildSSEURL(ticket: string, listId?: string, docId?: string, rooms?: string[]): string { const params = new URLSearchParams(); - if (token) params.set('token', token); + params.set('ticket', ticket); if (listId) params.set('listId', listId); if (docId) params.set('docId', docId); if (rooms && rooms.length > 0) params.set('rooms', rooms.join(',')); @@ -1309,7 +1318,9 @@ function switchToSSE(reason: string) { connectSSE(); } -export function connectSSE(rooms?: string[]): void { +let sseTicketPending = false; + +export async function connectSSE(rooms?: string[]): Promise { if (rooms) { rooms.forEach((r) => globalSubscribedRooms.add(r)); } @@ -1328,8 +1339,20 @@ export function connectSSE(rooms?: string[]): void { activeGlobalSSE = null; } + if (sseTicketPending) return; + sseTicketPending = true; + const ticket = await fetchStreamTicket(); + sseTicketPending = false; + if (manualDisconnect || !getToken()) return; + if (!ticket) { + // Server unreachable or session gone: try again later like after a dropped stream + notifyRealtimeStatusChange(false, 'none'); + sseReconnectTimer = setTimeout(() => connectSSE(), sseReconnectDelay); + return; + } + try { - const url = buildSSEURL(undefined, undefined, Array.from(globalSubscribedRooms)); + const url = buildSSEURL(ticket, undefined, undefined, Array.from(globalSubscribedRooms)); const es = new EventSource(url); activeGlobalSSE = es; @@ -1402,15 +1425,16 @@ export function connectSSE(rooms?: string[]): void { } } -export function connectWebSocket(): void { +let wsTicketPending = false; + +export async function connectWebSocket(): Promise { const token = getToken(); if (!token) return; manualDisconnect = false; // If proxy blocked WebSocket previously, do not attempt failing WebSocket; stay on SSE if (isWsBlockedByProxy) { - connectSSE(); - return; + return connectSSE(); } if (activeGlobalWS && (activeGlobalWS.readyState === WebSocket.OPEN || activeGlobalWS.readyState === WebSocket.CONNECTING)) { @@ -1421,8 +1445,22 @@ export function connectWebSocket(): void { clearTimeout(wsReconnectTimer); clearTimeout(sseReconnectTimer); + if (wsTicketPending) return; + wsTicketPending = true; + const ticket = await fetchStreamTicket(); + wsTicketPending = false; + if (manualDisconnect || !getToken()) return; + if (activeGlobalWS && (activeGlobalWS.readyState === WebSocket.OPEN || activeGlobalWS.readyState === WebSocket.CONNECTING)) { + return; + } + if (!ticket) { + notifyRealtimeStatusChange(false, 'none'); + wsReconnectTimer = setTimeout(() => connectWebSocket(), wsReconnectDelay); + return; + } + try { - const url = buildWebSocketURL(); + const url = buildWebSocketURL(ticket); const ws = new WebSocket(url); activeGlobalWS = ws; diff --git a/internal/api/handlers_admin.go b/internal/api/handlers_admin.go index b7c884c..1b19db0 100644 --- a/internal/api/handlers_admin.go +++ b/internal/api/handlers_admin.go @@ -188,6 +188,9 @@ func (s *Server) handleAdminUpdateUser(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, err.Error()) return } + if req.IsActive != nil && !*req.IsActive { + s.wsHub.DisconnectUser(targetUserID) + } writeJSON(w, http.StatusOK, u) } @@ -222,6 +225,7 @@ func (s *Server) handleAdminResetPassword(w http.ResponseWriter, r *http.Request writeError(w, http.StatusInternalServerError, err.Error()) return } + s.wsHub.DisconnectUser(targetUserID) writeJSON(w, http.StatusOK, map[string]bool{"success": true}) } diff --git a/internal/api/handlers_auth.go b/internal/api/handlers_auth.go index bdbf922..8c286de 100644 --- a/internal/api/handlers_auth.go +++ b/internal/api/handlers_auth.go @@ -400,6 +400,8 @@ func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { _ = s.store.RevokeToken(tokenStr, userID, exp) } } + // Streams are not tied to one session; other devices reconnect with a new ticket on their own + s.wsHub.DisconnectUser(userID) writeJSON(w, http.StatusOK, map[string]bool{"success": true}) } @@ -409,6 +411,7 @@ func (s *Server) handleRevokeAllSessions(w http.ResponseWriter, r *http.Request) writeError(w, http.StatusInternalServerError, "Sitzungen konnten nicht beendet werden") return } + s.wsHub.DisconnectUser(userID) writeJSON(w, http.StatusOK, map[string]bool{"success": true}) } @@ -454,6 +457,7 @@ func (s *Server) handleUpdateUserSettings(w http.ResponseWriter, r *http.Request return } _ = s.store.UpdateUserPassword(userID, hash) + s.wsHub.DisconnectUser(userID) } u, err := s.store.UpdateUserFull( userID, diff --git a/internal/api/password_reset.go b/internal/api/password_reset.go index f4f4767..94fd41f 100644 --- a/internal/api/password_reset.go +++ b/internal/api/password_reset.go @@ -87,6 +87,7 @@ func (s *Server) handleResetPassword(w http.ResponseWriter, r *http.Request) { return } s.loginGuard.unlockAccount(userID) + s.wsHub.DisconnectUser(userID) slog.Info("Passwort per E-Mail-Link zurückgesetzt", "user_id", userID) writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) } diff --git a/internal/api/realtime.go b/internal/api/realtime.go index 27079db..4f2b1e0 100644 --- a/internal/api/realtime.go +++ b/internal/api/realtime.go @@ -9,14 +9,25 @@ import ( "github.com/sandrodach/mindsift/internal/auth" ) +// authenticateUserFromReq authenticates a live stream: a one-time ticket (?ticket=, browsers) or a +// session in the Authorization header (other clients). Session tokens in the URL are not accepted any +// more (FLAW-22), API keys never (streams carry the user's data, keys only deliver). func (s *Server) authenticateUserFromReq(r *http.Request) string { - token := r.URL.Query().Get("token") - if token == "" { - if authHdr := r.Header.Get("Authorization"); strings.HasPrefix(authHdr, "Bearer ") { - token = strings.TrimPrefix(authHdr, "Bearer ") + if id := r.URL.Query().Get("ticket"); id != "" { + tk, ok := s.streamTickets.consume(id) + if !ok { + return "" } + u, err := s.store.GetUserByID(tk.userID) + if err != nil || u == nil || !u.IsActive || u.TokenVersion != tk.tokenVersion { + return "" + } + return u.ID + } + token := "" + if authHdr := r.Header.Get("Authorization"); strings.HasPrefix(authHdr, "Bearer ") { + token = strings.TrimPrefix(authHdr, "Bearer ") } - // Live streams carry the user's data, so API keys (which only deliver) are not accepted here if user, keyScopes := s.authenticateToken(token); user != nil && keyScopes == nil { return user.ID } diff --git a/internal/api/realtime_test.go b/internal/api/realtime_test.go index 0f70c48..5029e72 100644 --- a/internal/api/realtime_test.go +++ b/internal/api/realtime_test.go @@ -62,7 +62,8 @@ func newTestUser(t *testing.T, store *storage.Store, email string) (*domain.User func openSSE(t *testing.T, ts *httptest.Server, token string) *sseStream { t.Helper() ctx, cancel := context.WithCancel(context.Background()) - req, _ := http.NewRequestWithContext(ctx, http.MethodGet, ts.URL+"/api/v1/events?token="+token, nil) + req, _ := http.NewRequestWithContext(ctx, http.MethodGet, ts.URL+"/api/v1/events", nil) + req.Header.Set("Authorization", "Bearer "+token) resp, err := http.DefaultClient.Do(req) if err != nil { cancel() @@ -271,8 +272,8 @@ func TestReorderRejectsForeignTasks(t *testing.T) { func dialWS(t *testing.T, ts *httptest.Server, token string) *websocket.Conn { t.Helper() - url := "ws" + strings.TrimPrefix(ts.URL, "http") + "/api/v1/ws?token=" + token - conn, _, err := websocket.DefaultDialer.Dial(url, nil) + url := "ws" + strings.TrimPrefix(ts.URL, "http") + "/api/v1/ws" + conn, _, err := websocket.DefaultDialer.Dial(url, http.Header{"Authorization": {"Bearer " + token}}) if err != nil { t.Fatalf("dial ws: %v", err) } @@ -336,3 +337,20 @@ func TestDocChangesArePushedToTheNoteRoom(t *testing.T) { t.Fatalf("no DOC_SEQ push for the note: %+v", msg) } } + +func TestLogoutEndsLiveStreams(t *testing.T) { + srv, _, ts, owner, token := startRealtimeServer(t) + st := openSSE(t, ts, token) + srv.wsHub.DisconnectUser(owner.ID) + deadline := time.After(2 * time.Second) + for { + select { + case _, ok := <-st.events: + if !ok { + return // stream closed by the server + } + case <-deadline: + t.Fatal("stream still open after DisconnectUser") + } + } +} diff --git a/internal/api/server.go b/internal/api/server.go index bcb17a2..76cbab0 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -30,7 +30,8 @@ type Server struct { leases *collab.LeaseManager // block leases and open sessions of notes notifier *collab.Notifier // wakes long-poll requests on note changes - demo demoState // periodic reset in demo mode (demo.go) + demo demoState // periodic reset in demo mode (demo.go) + streamTickets streamTickets // one-time tickets for WebSocket/SSE (streamticket.go) loginGuard *loginGuard // brute-force protection (loginguard.go) } @@ -45,6 +46,7 @@ func NewServer(cfg *config.Config, store *storage.Store, wsHub *ws.Hub, staticFS } srv.loginGuard = newLoginGuard(!cfg.DisableLoginProtection) wsHub.SetRoomAuthorizer(srv.canJoinRoom) + wsHub.SetOriginCheck(srv.streamOriginAllowed) // Next occurrences of repeating tasks appear live on every connected client store.OnTaskCreated = func(th *domain.Thought) { srv.wsHub.BroadcastToUsers(srv.taskAudience(th), "TASK_CREATED", th) @@ -81,12 +83,18 @@ func (s *Server) Router() http.Handler { r.Use(s.demoResetGate) r.Use(middleware.Recoverer) r.Use(s.apiLoggingMiddleware) + // Browsers of other sites may only call the API when no BASE_URL is set (auth runs over the + // Authorization header, never cookies, so credentials are not allowed either way) + allowedOrigins := []string{"*"} + if s.cfg.BaseURLSet { + allowedOrigins = []string{strings.TrimRight(s.cfg.BaseURL, "/")} + } r.Use(cors.Handler(cors.Options{ - AllowedOrigins: []string{"*"}, + AllowedOrigins: allowedOrigins, AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"}, AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-API-Key"}, ExposedHeaders: []string{"Link"}, - AllowCredentials: true, + AllowCredentials: false, MaxAge: 300, })) @@ -112,6 +120,7 @@ func (s *Server) Router() http.Handler { r.Get("/api/v1/auth/me", s.handleMe) r.Post("/api/v1/events/rooms", s.handleSSERooms) + r.Post("/api/v1/auth/stream-ticket", s.handleStreamTicket) r.Post("/api/v1/auth/logout", s.handleLogout) r.Post("/api/v1/auth/revoke-all", s.handleRevokeAllSessions) r.Patch("/api/v1/auth/settings", s.handleUpdateUserSettings) diff --git a/internal/api/server_test.go b/internal/api/server_test.go index 66257b5..dd84824 100644 --- a/internal/api/server_test.go +++ b/internal/api/server_test.go @@ -891,7 +891,8 @@ func TestSSEGlobalEndpoint(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - req = httptest.NewRequest(http.MethodGet, "/api/v1/events?token="+token, nil).WithContext(ctx) + req = httptest.NewRequest(http.MethodGet, "/api/v1/events", nil).WithContext(ctx) + req.Header.Set("Authorization", "Bearer "+token) rec = httptest.NewRecorder() go func() { @@ -1147,7 +1148,8 @@ func TestTokenBlacklistingAndTokenVersionRevocation(t *testing.T) { } // Also check authenticateUserFromReq rejects token1 - req = httptest.NewRequest(http.MethodGet, "/api/v1/ws?token="+token1, nil) + req = httptest.NewRequest(http.MethodGet, "/api/v1/ws", nil) + req.Header.Set("Authorization", "Bearer "+token1) if uid := srv.authenticateUserFromReq(req); uid != "" { t.Fatalf("authenticateUserFromReq should reject blacklisted token1, got uid %q", uid) } @@ -1468,7 +1470,8 @@ func TestAPIKeysOnlyDeliverData(t *testing.T) { t.Fatalf("key must not write into lists its owner cannot see, got %d", code) } // Keys never open live streams (those carry the user's data) - streamReq := httptest.NewRequest(http.MethodGet, "/api/v1/events?token="+full.RawKey, nil) + streamReq := httptest.NewRequest(http.MethodGet, "/api/v1/events", nil) + streamReq.Header.Set("Authorization", "Bearer "+full.RawKey) if uid := srv.authenticateUserFromReq(streamReq); uid != "" { t.Fatalf("API key must not authenticate a live stream") } diff --git a/internal/api/streamticket.go b/internal/api/streamticket.go new file mode 100644 index 0000000..b491478 --- /dev/null +++ b/internal/api/streamticket.go @@ -0,0 +1,97 @@ +package api + +import ( + "crypto/rand" + "encoding/hex" + "net/http" + "net/url" + "strings" + "sync" + "time" + + "github.com/sandrodach/mindsift/internal/auth" +) + +// WebSocket and EventSource cannot send an Authorization header, so the browser used to put its +// 30-day session token into the URL, where it ends up in proxy and access logs (FLAW-22). Instead the +// app now asks for a stream ticket (POST /auth/stream-ticket, with the normal header) right before it +// connects and passes that: random, valid for 60 seconds, usable once, bound to the session's token +// version. A ticket in a log is worthless. + +const streamTicketTTL = 60 * time.Second + +type streamTicket struct { + userID string + tokenVersion int + expires time.Time +} + +type streamTickets struct { + mu sync.Mutex + tickets map[string]streamTicket +} + +func (t *streamTickets) issue(userID string, tokenVersion int) (string, error) { + buf := make([]byte, 24) + if _, err := rand.Read(buf); err != nil { + return "", err + } + id := hex.EncodeToString(buf) + now := time.Now() + t.mu.Lock() + defer t.mu.Unlock() + if t.tickets == nil { + t.tickets = map[string]streamTicket{} + } + for k, v := range t.tickets { + if now.After(v.expires) { + delete(t.tickets, k) + } + } + t.tickets[id] = streamTicket{userID: userID, tokenVersion: tokenVersion, expires: now.Add(streamTicketTTL)} + return id, nil +} + +// consume returns the ticket and removes it; ok is false for unknown or expired tickets. +func (t *streamTickets) consume(id string) (streamTicket, bool) { + t.mu.Lock() + defer t.mu.Unlock() + tk, ok := t.tickets[id] + delete(t.tickets, id) + if !ok || time.Now().After(tk.expires) { + return streamTicket{}, false + } + return tk, true +} + +func (s *Server) handleStreamTicket(w http.ResponseWriter, r *http.Request) { + user, err := s.store.GetUserByID(auth.GetUserID(r.Context())) + if err != nil || user == nil { + writeError(w, http.StatusUnauthorized, "Nicht autorisiert") + return + } + ticket, err := s.streamTickets.issue(user.ID, user.TokenVersion) + if err != nil { + writeError(w, http.StatusInternalServerError, "Ticket konnte nicht erstellt werden") + return + } + writeJSON(w, http.StatusOK, map[string]interface{}{"ticket": ticket, "expiresIn": int(streamTicketTTL.Seconds())}) +} + +// streamOriginAllowed: with BASE_URL set, browsers may open WebSockets only from that address or the +// requested host itself. Requests without Origin (non-browser clients) pass; they need a ticket anyway. +func (s *Server) streamOriginAllowed(r *http.Request) bool { + origin := r.Header.Get("Origin") + if origin == "" || !s.cfg.BaseURLSet { + return true + } + o, err := url.Parse(origin) + if err != nil { + return false + } + if strings.EqualFold(o.Host, r.Host) { + return true + } + base, err := url.Parse(s.cfg.BaseURL) + return err == nil && strings.EqualFold(o.Host, base.Host) +} diff --git a/internal/api/streamticket_test.go b/internal/api/streamticket_test.go new file mode 100644 index 0000000..e410645 --- /dev/null +++ b/internal/api/streamticket_test.go @@ -0,0 +1,73 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/sandrodach/mindsift/internal/config" +) + +func TestStreamTicketsReplaceTokensInURLs(t *testing.T) { + srv, store, token := setupTestServer(t) + router := srv.Router() + ticket := func(tok string) string { + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/stream-ticket", nil) + req.Header.Set("Authorization", "Bearer "+tok) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + var out struct{ Ticket string } + _ = json.Unmarshal(rec.Body.Bytes(), &out) + return out.Ticket + } + stream := func(query string) string { + return srv.authenticateUserFromReq(httptest.NewRequest(http.MethodGet, "/api/v1/events?"+query, nil)) + } + + tk := ticket(token) + if tk == "" { + t.Fatal("no ticket issued") + } + if stream("ticket="+tk) == "" { + t.Fatal("a fresh ticket must open the stream") + } + if stream("ticket="+tk) != "" { + t.Fatal("a ticket works only once") + } + if stream("token="+token) != "" { + t.Fatal("session tokens in the URL are no longer accepted") + } + + // Ending all sessions makes unused tickets worthless as well + tk = ticket(token) + uid := stream("ticket=" + ticket(token)) + _ = store.RevokeAllUserTokens(uid) + if stream("ticket="+tk) != "" { + t.Fatal("ticket from before 'all sessions ended' must be refused") + } +} + +func TestStreamOriginCheck(t *testing.T) { + srv := &Server{cfg: &config.Config{BaseURL: "https://notes.example.com", BaseURLSet: true}} + check := func(host, origin string) bool { + r := httptest.NewRequest(http.MethodGet, "/api/v1/ws", nil) + r.Host = host + if origin != "" { + r.Header.Set("Origin", origin) + } + return srv.streamOriginAllowed(r) + } + if !check("mindsift:8080", "https://notes.example.com") { + t.Error("BASE_URL origin must pass even if the proxy rewrote Host") + } + if !check("notes.example.com", "https://notes.example.com") { + t.Error("same host must pass") + } + if check("notes.example.com", "https://evil.example.org") { + t.Error("foreign origin must be refused") + } + if !check("notes.example.com", "") { + t.Error("non-browser clients without Origin pass (they still need a ticket)") + } +} diff --git a/internal/ws/hub.go b/internal/ws/hub.go index fca2e72..0d2cfbd 100644 --- a/internal/ws/hub.go +++ b/internal/ws/hub.go @@ -17,10 +17,22 @@ var upgrader = websocket.Upgrader{ ReadBufferSize: 4096, WriteBufferSize: 4096, CheckOrigin: func(r *http.Request) bool { - return true // Self-hosted reverse proxy compatible + return originCheck(r) }, } +// originCheck decides which browser origins may open a WebSocket (see SetOriginCheck). Without a +// configured check every origin is accepted: proxies often rewrite the Host header, so a plain +// same-host comparison would break working setups. +var originCheck = func(*http.Request) bool { return true } + +// SetOriginCheck installs the origin rule for WebSocket upgrades. +func (h *Hub) SetOriginCheck(f func(*http.Request) bool) { + if f != nil { + originCheck = f + } +} + type Client struct { hub *Hub conn *websocket.Conn @@ -371,6 +383,19 @@ func (h *Hub) BroadcastToUser(userID, event string, payload interface{}) { } } +// DisconnectUser ends every live connection of a user (logout, all sessions ended, new password, +// account deactivated). Clients reconnect with a fresh ticket if their session is still valid. +func (h *Hub) DisconnectUser(userID string) { + h.mu.RLock() + defer h.mu.RUnlock() + for client := range h.userRooms[userID] { + client.closeSend() + } + for sseClient := range h.sseUserRooms[userID] { + sseClient.closeSend() + } +} + // BroadcastSystem sends an event to every connection. Only for events without user data (e.g. the // demo reset notice). func (h *Hub) BroadcastSystem(event string) { From dc44f8250c22d357f59320218e425b005f4ea768 Mon Sep 17 00:00:00 2001 From: Sandro Date: Thu, 29 Oct 2026 14:49:35 +0100 Subject: [PATCH 17/26] fix(db): numbered migrations that run once and fail loudly (FLAW-13) schema_migrations records every applied step; the former migrate() is step 3, the baseline that builds the v0.4.1 schema and repairs older databases. Columns are only added when missing, so every other error now stops the start with a clear message instead of being swallowed, and the cleanup statements no longer run on each start. migrations/001_init.sql was never read and is gone. Co-Authored-By: Claude Opus 5.5 --- Dockerfile | 1 - Doku/database.md | 2 +- Doku/flaws.md | 6 +- internal/storage/docsync.go | 9 ++- internal/storage/migrations.go | 112 ++++++++++++++++++++++++++ internal/storage/migrations_test.go | 45 +++++++++++ internal/storage/sqlite.go | 73 +++++++++++------ internal/storage/sqlite_test.go | 8 +- internal/storage/visibility_test.go | 4 +- migrations/001_init.sql | 117 ---------------------------- 10 files changed, 222 insertions(+), 155 deletions(-) create mode 100644 internal/storage/migrations.go create mode 100644 internal/storage/migrations_test.go delete mode 100644 migrations/001_init.sql diff --git a/Dockerfile b/Dockerfile index 1bca035..37123bd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -36,7 +36,6 @@ RUN go get github.com/go-chi/chi/v5@v5.2.0 \ # Copy application source & compiled frontend SPA assets for go:embed COPY cmd/ ./cmd/ COPY internal/ ./internal/ -COPY migrations/ ./migrations/ COPY --from=frontend-builder /app/frontend/dist ./cmd/server/dist # Run go mod tidy, static analysis (go vet), unit tests (go test), and compile final binary diff --git a/Doku/database.md b/Doku/database.md index 462b1e2..7733f93 100644 --- a/Doku/database.md +++ b/Doku/database.md @@ -81,7 +81,7 @@ teams 1─n team_members n─1 users - `users.username` wird aus dem E-Mail-Präfix aufgefüllt. - Notiz-Synchronisation (`migrateDocSync`): neue Spalten an `blocks` (inkl. `deleted_at`; vorhandene Grabsteine erhalten den Migrationszeitpunkt) und `documents` (`head_seq`, `title_version`, `log_floor`), Tabellen `doc_ops` und `doc_op_results`. Blöcke im alten flachen Format (nur `level`/`position`) werden einmalig in den Baum umgewandelt. - Standardwerte in `system_settings` (`INSERT OR IGNORE`): `sharing_mode=organizations`, `allow_registration=false`, `instance_name=MindSift`, SMTP leer. -- **Schema-Version:** `PRAGMA user_version` hält `schemaVersion` (derzeit 2), damit spätere Versionen erkennen, welche Migrationen eine Datenbank schon hat. Daten werden beim Start nie gelöscht (das einmalige Zurücksetzen auf Version 2 ist entfernt, alle Installationen sind darüber hinweg). Nur der Demo-Modus leert die Datenbank bei jedem Start. +- **Migrationen:** [migrations.go](../internal/storage/migrations.go) führt nummerierte Schritte genau einmal aus und vermerkt sie in `schema_migrations` (`id`, `name`, `applied_at`); `PRAGMA user_version` zeigt die höchste angewandte Nummer. Schritt 3 („Basis-Schema v0.4.1“, `migrateBaseline`) baut das komplette Schema auf und bringt ältere Datenbanken auf diesen Stand; Spalten werden nur angelegt, wenn sie fehlen. Schlägt ein Schritt fehl, bricht der Start mit der Fehlermeldung ab, statt mit halbem Schema weiterzulaufen. Neue Änderungen kommen als neuer Schritt ans Ende, alte Schritte werden nie verändert. Daten werden beim Start nie gelöscht; nur der Demo-Modus leert die Datenbank. ## Sicherung diff --git a/Doku/flaws.md b/Doku/flaws.md index 52c9da1..741d644 100644 --- a/Doku/flaws.md +++ b/Doku/flaws.md @@ -22,7 +22,7 @@ Schweregrade: **Hoch** (Datenabfluss oder Rechteumgehung möglich), **Mittel** ( | FLAW-10 | Freigaben | ~~`sharing_mode` wird bei Freigaben nicht durchgesetzt; Benutzerverzeichnis leakt alle Konten~~ | **Behoben** | | FLAW-11 | Workspace | ~~Block-Upsert (`ON CONFLICT(id)`) prüft das Dokument nicht~~ | **Behoben** | | FLAW-12 | Benachrichtigungen | ~~`notify_before_minutes` wirkungslos; Alarm feuert nach Umplanung nie wieder; endlose Wiederholung bei Fehlern~~ | **Behoben** | -| FLAW-13 | Datenbank | Keine Schema-Versionierung; Migrationsfehler werden verschluckt; `migrations/001_init.sql` ist toter Code | Mittel | +| FLAW-13 | Datenbank | ~~Keine Schema-Versionierung; Migrationsfehler werden verschluckt; `migrations/001_init.sql` ist toter Code~~ | **Behoben** | | FLAW-14 | Backend | Fehler von Schreiboperationen werden häufig ignoriert (`_, _ =`) | Mittel | | FLAW-15 | Realtime | ~~`WriteTimeout` von 30 s beendet SSE-Streams~~ | **Behoben** | | FLAW-16 | Setup | `/auth/setup` ist bis zum ersten Konto offen; Demo-Modus legt `admin/admin` an | Mittel | @@ -123,8 +123,8 @@ Schweregrade: **Hoch** (Datenabfluss oder Rechteumgehung möglich), **Mittel** ( - **Maßnahme:** Fälligkeit mit Uhrzeit speichern, `alarm_sent` bei Änderung von `due_date` zurücksetzen, Wiederholungen begrenzen. ### FLAW-13: Migrationen -- **Teilweise behoben:** Das Schema hat eine Version (`PRAGMA user_version`, `schemaVersion` in `sqlite.go`), auf die künftige Migrationen aufsetzen können. Die übrigen Punkte unten gelten weiter. -- **Fundstellen:** `Store.migrate` in [`internal/storage/sqlite.go`](../internal/storage/sqlite.go), [`migrations/001_init.sql`](../migrations/001_init.sql). +- **Status: Behoben.** `schema_migrations` mit nummerierten, einmalig ausgeführten Schritten ([migrations.go](../internal/storage/migrations.go)); das bisherige Schema ist Schritt 3 (Baseline). Spalten werden nur angelegt, wenn sie fehlen, alle anderen Fehler brechen den Start ab; Bereinigungen laufen nicht mehr bei jedem Start. `migrations/001_init.sql` ist entfernt. Test: `TestMigrationsRunOnceAndRepairOldDatabases`. +- **Fundstellen (damals):** `Store.migrate` in `internal/storage/sqlite.go`, `migrations/001_init.sql`. - **Probleme:** - Es gibt keine Versionstabelle. Alle `ALTER TABLE`-Anweisungen laufen bei jedem Start erneut; Fehler werden pauschal ignoriert (`_, _ = s.db.Exec(q)`), echte Fehler fallen nicht auf. - Mehrere `UPDATE`/`DROP TABLE`-Bereinigungen laufen bei jedem Start. diff --git a/internal/storage/docsync.go b/internal/storage/docsync.go index 9f1e363..e3f3c61 100644 --- a/internal/storage/docsync.go +++ b/internal/storage/docsync.go @@ -72,7 +72,7 @@ type docQueryer interface { } // migrateDocSync adds the columns and tables of the collaboration model and converts old flat block lists. -func (s *Store) migrateDocSync() { +func (s *Store) migrateDocSync() error { stmts := []string{ "ALTER TABLE blocks ADD COLUMN parent_id TEXT NOT NULL DEFAULT '';", "ALTER TABLE blocks ADD COLUMN order_key TEXT NOT NULL DEFAULT '';", @@ -110,15 +110,16 @@ func (s *Store) migrateDocSync() { "CREATE INDEX IF NOT EXISTS idx_doc_op_results_created ON doc_op_results(created_at);", } for _, q := range stmts { - if _, err := s.db.Exec(q); err != nil && !strings.Contains(err.Error(), "duplicate column") { - slog.Error("Migration der Notiz-Synchronisation fehlgeschlagen", "error", err, "statement", q) + if err := s.execSchemaStatement(q); err != nil { + return err } } // Tombstones from before deleted_at existed start their retention now if _, err := s.db.Exec(`UPDATE blocks SET deleted_at = ? WHERE deleted = 1 AND deleted_at = ''`, domain.NowISO()); err != nil { - slog.Error("Migration der Notiz-Synchronisation fehlgeschlagen", "error", err) + return err } s.backfillBlockTree() + return nil } // backfillBlockTree converts blocks stored as a flat list (level + position) into parent_id + order_key. diff --git a/internal/storage/migrations.go b/internal/storage/migrations.go new file mode 100644 index 0000000..de6299d --- /dev/null +++ b/internal/storage/migrations.go @@ -0,0 +1,112 @@ +package storage + +import ( + "database/sql" + "fmt" + "log/slog" + "regexp" + "strings" + + "github.com/sandrodach/mindsift/internal/domain" +) + +// Schema migrations (FLAW-13). Every step runs exactly once per database and is recorded in +// schema_migrations; a failing step stops the start with its error instead of being ignored. +// New schema changes are appended as new steps (next free id), never edited into old ones. +// PRAGMA user_version mirrors the highest applied id. Ids 1 and 2 were used by earlier releases +// (database wipes on start), so the first step here is 3. + +type migration struct { + id int + name string + run func(s *Store) error +} + +var migrations = []migration{ + {3, "Basis-Schema v0.4.1", (*Store).migrateBaseline}, +} + +func (s *Store) migrate() error { + if _, err := s.db.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + applied_at TEXT NOT NULL + )`); err != nil { + return err + } + applied := map[int]bool{} + rows, err := s.db.Query(`SELECT id FROM schema_migrations`) + if err != nil { + return err + } + for rows.Next() { + var id int + if rows.Scan(&id) == nil { + applied[id] = true + } + } + _ = rows.Close() + + last := 0 + for _, m := range migrations { + last = m.id + if applied[m.id] { + continue + } + slog.Info("Datenbank-Migration", "id", m.id, "name", m.name) + if err := m.run(s); err != nil { + return fmt.Errorf("Migration %d (%s): %w", m.id, m.name, err) + } + if _, err := s.db.Exec(`INSERT INTO schema_migrations (id, name, applied_at) VALUES (?, ?, ?)`, + m.id, m.name, domain.NowISO()); err != nil { + return err + } + } + _, err = s.db.Exec(fmt.Sprintf(`PRAGMA user_version = %d`, last)) + return err +} + +var addColumnRe = regexp.MustCompile(`(?is)^\s*ALTER\s+TABLE\s+(\w+)\s+ADD\s+COLUMN\s+(\w+)\s+(.+?);?\s*$`) + +// execSchemaStatement runs one schema statement; "ALTER TABLE … ADD COLUMN" only when the column is +// missing, so that a real error is never mistaken for "already there". +func (s *Store) execSchemaStatement(q string) error { + if m := addColumnRe.FindStringSubmatch(q); m != nil { + return s.addColumnIfMissing(m[1], m[2], m[3]) + } + if _, err := s.db.Exec(q); err != nil { + return fmt.Errorf("%w (%s)", err, strings.TrimSpace(q)) + } + return nil +} + +func (s *Store) addColumnIfMissing(table, column, definition string) error { + exists, err := s.columnExists(table, column) + if err != nil || exists { + return err + } + if _, err := s.db.Exec(fmt.Sprintf(`ALTER TABLE %s ADD COLUMN %s %s`, table, column, definition)); err != nil { + return fmt.Errorf("Spalte %s.%s anlegen: %w", table, column, err) + } + return nil +} + +func (s *Store) columnExists(table, column string) (bool, error) { + rows, err := s.db.Query(fmt.Sprintf(`PRAGMA table_info(%s)`, table)) + if err != nil { + return false, err + } + defer rows.Close() + for rows.Next() { + var cid, notNull, pk int + var name, typ string + var dflt sql.NullString + if err := rows.Scan(&cid, &name, &typ, ¬Null, &dflt, &pk); err != nil { + return false, err + } + if strings.EqualFold(name, column) { + return true, nil + } + } + return false, rows.Err() +} diff --git a/internal/storage/migrations_test.go b/internal/storage/migrations_test.go new file mode 100644 index 0000000..1d04668 --- /dev/null +++ b/internal/storage/migrations_test.go @@ -0,0 +1,45 @@ +package storage + +import ( + "errors" + "path/filepath" + "strings" + "testing" +) + +func TestMigrationsRunOnceAndRepairOldDatabases(t *testing.T) { + path := filepath.Join(t.TempDir(), "migrate.db") + store, err := NewStore(path) + if err != nil { + t.Fatal(err) + } + var n int + _ = store.db.QueryRow(`SELECT COUNT(*) FROM schema_migrations`).Scan(&n) + if n != len(migrations) { + t.Fatalf("expected %d recorded migrations, got %d", len(migrations), n) + } + + // A database from before the migration table, missing a later column + if _, err := store.db.Exec(`ALTER TABLE thoughts DROP COLUMN alarm_failures`); err != nil { + t.Fatal(err) + } + _, _ = store.db.Exec(`DROP TABLE schema_migrations`) + _ = store.Close() + + store, err = NewStore(path) + if err != nil { + t.Fatalf("reopen: %v", err) + } + if ok, _ := store.columnExists("thoughts", "alarm_failures"); !ok { + t.Fatal("baseline must add missing columns to older databases") + } + + // A failing step stops the start with its error and is not recorded + saved := migrations + defer func() { migrations = saved }() + migrations = append(append([]migration{}, saved...), migration{999, "kaputt", func(*Store) error { return errors.New("boom") }}) + _ = store.Close() + if _, err := NewStore(path); err == nil || !strings.Contains(err.Error(), "Migration 999") { + t.Fatalf("failing migration must abort the start, got %v", err) + } +} diff --git a/internal/storage/sqlite.go b/internal/storage/sqlite.go index 00c221c..8242198 100644 --- a/internal/storage/sqlite.go +++ b/internal/storage/sqlite.go @@ -57,10 +57,6 @@ func (s *Store) Close() error { return s.db.Close() } -// schemaVersion is stored in PRAGMA user_version so later releases can tell which migrations a database -// already has. (Version 2 once wiped older databases on start; all installations are past that now.) -const schemaVersion = 2 - // dropAllTables removes every table; used by the demo mode reset. func (s *Store) dropAllTables() error { conn, err := s.db.Conn(context.Background()) @@ -94,7 +90,9 @@ func (s *Store) dropAllTables() error { return err } -func (s *Store) migrate() error { +// migrateBaseline is migration 3: the complete schema as of v0.4.1, built so that it also brings any +// older database up to that state. It runs once per database (see migrations.go). +func (s *Store) migrateBaseline() error { schema := ` PRAGMA journal_mode = WAL; PRAGMA synchronous = NORMAL; @@ -463,23 +461,41 @@ func (s *Store) migrate() error { "ALTER TABLE thoughts ADD COLUMN recurrence_spawned INTEGER NOT NULL DEFAULT 0;", } for _, q := range columnMigrations { - _, _ = s.db.Exec(q) + if err := s.execSchemaStatement(q); err != nil { + return err + } } // Clean up legacy space and old document block/crdt tables - _, _ = s.db.Exec(`DROP TABLE IF EXISTS spaces`) - _, _ = s.db.Exec(`DROP TABLE IF EXISTS space_members`) - _, _ = s.db.Exec(`DROP TABLE IF EXISTS space_team_shares`) - _, _ = s.db.Exec(`DROP TABLE IF EXISTS document_crdt_state`) - _, _ = s.db.Exec(`DROP TABLE IF EXISTS document_crdt_updates`) - _, _ = s.db.Exec(`DROP TABLE IF EXISTS document_blocks`) - _, _ = s.db.Exec(`DROP TABLE IF EXISTS doc_block_tags`) + if _, err := s.db.Exec(`DROP TABLE IF EXISTS spaces`); err != nil { + return err + } + if _, err := s.db.Exec(`DROP TABLE IF EXISTS space_members`); err != nil { + return err + } + if _, err := s.db.Exec(`DROP TABLE IF EXISTS space_team_shares`); err != nil { + return err + } + if _, err := s.db.Exec(`DROP TABLE IF EXISTS document_crdt_state`); err != nil { + return err + } + if _, err := s.db.Exec(`DROP TABLE IF EXISTS document_crdt_updates`); err != nil { + return err + } + if _, err := s.db.Exec(`DROP TABLE IF EXISTS document_blocks`); err != nil { + return err + } + if _, err := s.db.Exec(`DROP TABLE IF EXISTS doc_block_tags`); err != nil { + return err + } // Backfill username for existing accounts where username is empty - _, _ = s.db.Exec(`UPDATE users SET username = LOWER(SUBSTR(email, 1, INSTR(email, '@') - 1)) WHERE (username IS NULL OR username = '') AND INSTR(email, '@') > 0`) + if _, err := s.db.Exec(`UPDATE users SET username = LOWER(SUBSTR(email, 1, INSTR(email, '@') - 1)) WHERE (username IS NULL OR username = '') AND INSTR(email, '@') > 0`); err != nil { + return err + } // Default system settings (public registration disabled by default; invite or admin creation required) - _, _ = s.db.Exec(`INSERT OR IGNORE INTO system_settings (key, value) VALUES + if _, err := s.db.Exec(`INSERT OR IGNORE INTO system_settings (key, value) VALUES ('sharing_mode', 'organizations'), ('allow_registration', 'false'), ('instance_name', 'MindSift'), @@ -490,18 +506,30 @@ func (s *Store) migrate() error { ('smtp_from', ''), ('smtp_tls', 'true'), ('smtp_skip_verify', 'false'), - ('allow_private_webhooks', 'false')`) + ('allow_private_webhooks', 'false')`); err != nil { + return err + } // Clean up legacy roles in team_members and users - _, _ = s.db.Exec(`UPDATE team_members SET role = 'admin' WHERE role IN ('team_admin', 'owner')`) - _, _ = s.db.Exec(`UPDATE team_members SET role = 'member' WHERE role NOT IN ('admin', 'member')`) - _, _ = s.db.Exec(`UPDATE users SET role = 'member' WHERE role = 'team_admin'`) + if _, err := s.db.Exec(`UPDATE team_members SET role = 'admin' WHERE role IN ('team_admin', 'owner')`); err != nil { + return err + } + if _, err := s.db.Exec(`UPDATE team_members SET role = 'member' WHERE role NOT IN ('admin', 'member')`); err != nil { + return err + } + if _, err := s.db.Exec(`UPDATE users SET role = 'member' WHERE role = 'team_admin'`); err != nil { + return err + } // Keys from before scopes existed ("ingest,read") may deliver into the inbox, as before - _, _ = s.db.Exec(`UPDATE api_keys SET scopes = 'webhook:triage' WHERE scopes NOT LIKE '%webhook:%'`) + if _, err := s.db.Exec(`UPDATE api_keys SET scopes = 'webhook:triage' WHERE scopes NOT LIKE '%webhook:%'`); err != nil { + return err + } // Block tree, operation log and idempotency tables for collaborative editing - s.migrateDocSync() + if err := s.migrateDocSync(); err != nil { + return err + } // Migrate workspace documents, blocks, folders, and strip legacy folder:* tags into pure DB tables s.migrateWorkspaceToPureDB() @@ -518,8 +546,7 @@ func (s *Store) migrate() error { return err } - _, err := s.db.Exec(fmt.Sprintf(`PRAGMA user_version = %d`, schemaVersion)) - return err + return nil } // EnsureDefaultUser is retained strictly for unit test convenience diff --git a/internal/storage/sqlite_test.go b/internal/storage/sqlite_test.go index 2a94316..61e3e4d 100644 --- a/internal/storage/sqlite_test.go +++ b/internal/storage/sqlite_test.go @@ -408,10 +408,10 @@ func TestFolderManagementAndDeletion(t *testing.T) { t.Fatalf("GetFolders failed: %v", err) } expected := map[string]bool{ - "Archiv": true, - "Projekte": true, - "Projekte/MindSift": true, - "Projekte/MindSift/Sub": true, + "Archiv": true, + "Projekte": true, + "Projekte/MindSift": true, + "Projekte/MindSift/Sub": true, } for _, f := range folders { delete(expected, f) diff --git a/internal/storage/visibility_test.go b/internal/storage/visibility_test.go index 5ce8645..5435153 100644 --- a/internal/storage/visibility_test.go +++ b/internal/storage/visibility_test.go @@ -165,8 +165,8 @@ func TestDataSurvivesRestart(t *testing.T) { } var v int _ = store.db.QueryRow(`PRAGMA user_version`).Scan(&v) - if v != schemaVersion { - t.Fatalf("user_version = %d, want %d", v, schemaVersion) + if want := migrations[len(migrations)-1].id; v != want { + t.Fatalf("user_version = %d, want %d", v, want) } } diff --git a/migrations/001_init.sql b/migrations/001_init.sql deleted file mode 100644 index 9e98593..0000000 --- a/migrations/001_init.sql +++ /dev/null @@ -1,117 +0,0 @@ --- MindSift Initial Database Schema (SQLite WAL / PostgreSQL Compatible) -PRAGMA journal_mode = WAL; -PRAGMA synchronous = NORMAL; -PRAGMA foreign_keys = ON; -PRAGMA busy_timeout = 5000; - -CREATE TABLE IF NOT EXISTS users ( - id TEXT PRIMARY KEY, - email TEXT UNIQUE NOT NULL, - password_hash TEXT NOT NULL, - display_name TEXT NOT NULL, - created_at TEXT NOT NULL -); - -CREATE TABLE IF NOT EXISTS thoughts ( - id TEXT PRIMARY KEY, - user_id TEXT NOT NULL, - content TEXT NOT NULL, - tags TEXT NOT NULL DEFAULT '[]', - priority TEXT NOT NULL DEFAULT 'normal' CHECK(priority IN ('low', 'normal', 'high', 'urgent')), - source TEXT NOT NULL DEFAULT 'manual', - status TEXT NOT NULL DEFAULT 'inbox' CHECK(status IN ('inbox', 'kept', 'discarded', 'converted')), - is_completed INTEGER NOT NULL DEFAULT 0, - converted_type TEXT DEFAULT '', - converted_ref_id TEXT DEFAULT '', - due_date TEXT DEFAULT '', - sort_order INTEGER NOT NULL DEFAULT 0, - created_at TEXT NOT NULL, - triaged_at TEXT DEFAULT '', - FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE -); - -CREATE INDEX IF NOT EXISTS idx_thoughts_user_status ON thoughts(user_id, status, created_at DESC); -CREATE INDEX IF NOT EXISTS idx_thoughts_user_due ON thoughts(user_id, due_date); -CREATE INDEX IF NOT EXISTS idx_thoughts_list_sort ON thoughts(task_list_id, sort_order ASC, created_at DESC); - -CREATE TABLE IF NOT EXISTS spaces ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL, - icon TEXT NOT NULL DEFAULT '🏠', - description TEXT NOT NULL DEFAULT '', - invite_code TEXT UNIQUE NOT NULL, - is_personal INTEGER NOT NULL DEFAULT 0, - owner_id TEXT NOT NULL, - created_at TEXT NOT NULL, - FOREIGN KEY(owner_id) REFERENCES users(id) ON DELETE CASCADE -); - -CREATE TABLE IF NOT EXISTS space_members ( - space_id TEXT NOT NULL, - user_id TEXT NOT NULL, - role TEXT NOT NULL DEFAULT 'editor' CHECK(role IN ('owner', 'editor', 'viewer')), - joined_at TEXT NOT NULL, - PRIMARY KEY (space_id, user_id), - FOREIGN KEY(space_id) REFERENCES spaces(id) ON DELETE CASCADE, - FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE -); - -CREATE TABLE IF NOT EXISTS documents ( - id TEXT PRIMARY KEY, - user_id TEXT NOT NULL, - space_id TEXT DEFAULT '', - title TEXT NOT NULL, - icon TEXT NOT NULL DEFAULT '📄', - content TEXT NOT NULL DEFAULT '', - blocks_json TEXT NOT NULL DEFAULT '[]', - tags TEXT NOT NULL DEFAULT '[]', - is_pinned INTEGER NOT NULL DEFAULT 0, - is_archived INTEGER NOT NULL DEFAULT 0, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE -); - -CREATE INDEX IF NOT EXISTS idx_documents_user ON documents(user_id, is_archived, updated_at DESC); - -CREATE TABLE IF NOT EXISTS lists ( - id TEXT PRIMARY KEY, - space_id TEXT NOT NULL, - title TEXT NOT NULL, - icon TEXT NOT NULL DEFAULT '🛒', - list_type TEXT NOT NULL DEFAULT 'shopping' CHECK(list_type IN ('shopping', 'checklist', 'vacation')), - updated_at TEXT NOT NULL, - created_at TEXT NOT NULL, - FOREIGN KEY(space_id) REFERENCES spaces(id) ON DELETE CASCADE -); - -CREATE INDEX IF NOT EXISTS idx_lists_space ON lists(space_id, updated_at DESC); - -CREATE TABLE IF NOT EXISTS list_items ( - id TEXT PRIMARY KEY, - list_id TEXT NOT NULL, - content TEXT NOT NULL, - category TEXT NOT NULL DEFAULT '', - quantity TEXT NOT NULL DEFAULT '', - is_completed INTEGER NOT NULL DEFAULT 0, - completed_by TEXT NOT NULL DEFAULT '', - completed_by_name TEXT NOT NULL DEFAULT '', - sort_order INTEGER NOT NULL DEFAULT 0, - created_at TEXT NOT NULL, - completed_at TEXT NOT NULL DEFAULT '', - FOREIGN KEY(list_id) REFERENCES lists(id) ON DELETE CASCADE -); - -CREATE INDEX IF NOT EXISTS idx_list_items_sync ON list_items(list_id, is_completed, sort_order ASC); - -CREATE TABLE IF NOT EXISTS api_keys ( - id TEXT PRIMARY KEY, - user_id TEXT NOT NULL, - name TEXT NOT NULL, - key_prefix TEXT NOT NULL, - key_hash TEXT UNIQUE NOT NULL, - scopes TEXT NOT NULL DEFAULT 'ingest,read', - last_used_at TEXT NOT NULL DEFAULT '', - created_at TEXT NOT NULL, - FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE -); From 792ca946f8de26bdcb3ca83ed02353be49bb9997 Mon Sep 17 00:00:00 2001 From: Sandro Date: Thu, 29 Oct 2026 14:51:01 +0100 Subject: [PATCH 18/26] fix(db): transactions wait for the write lock instead of failing (FLAW-23) A load test with 16 concurrent writers lost 12 of 480 writes with SQLITE_BUSY: transactions that read before writing cannot be helped by busy_timeout once another connection writes. _txlock=immediate makes every transaction take the write lock at BEGIN, so it queues instead. Co-Authored-By: Claude Opus 5.5 --- Doku/flaws.md | 3 +- internal/storage/concurrency_test.go | 62 ++++++++++++++++++++++++++++ internal/storage/sqlite.go | 5 ++- 3 files changed, 68 insertions(+), 2 deletions(-) create mode 100644 internal/storage/concurrency_test.go diff --git a/Doku/flaws.md b/Doku/flaws.md index 741d644..9e09788 100644 --- a/Doku/flaws.md +++ b/Doku/flaws.md @@ -32,7 +32,7 @@ Schweregrade: **Hoch** (Datenabfluss oder Rechteumgehung möglich), **Mittel** ( | FLAW-20 | Qualität | ~~Sehr große Dateien~~; duplizierte Zugriffslogik | Niedrig | | FLAW-21 | Repo | ~~`LICENSE` fehlt, obwohl README MIT angibt~~ | **Behoben** | | FLAW-22 | Netzwerk | ~~Token in der URL (WS/SSE), CORS `*`, `CheckOrigin` immer `true`~~ | **Behoben** | -| FLAW-23 | SQLite | Single-Writer-Engpass bei vielen gleichzeitigen Schreibern | Niedrig | +| FLAW-23 | SQLite | ~~`SQLITE_BUSY` bei vielen gleichzeitigen Schreibern~~ | **Behoben** | | FLAW-24 | Gruppen | ~~Organisations-Endpunkte ohne Berechtigungsprüfung~~ | **Behoben** | | FLAW-25 | Workspace | ~~Struktur-Aktionen an fremd gesperrten Bullets nur teilweise ausgeführt~~ | **Behoben** | | FLAW-26 | Workspace | ~~Nach Enter landet sehr schnell Getipptes noch im vorherigen Bullet~~ | **Behoben** | @@ -181,6 +181,7 @@ Schweregrade: **Hoch** (Datenabfluss oder Rechteumgehung möglich), **Mittel** ( - **Maßnahme:** Kurzlebiges, einmaliges Stream-Ticket statt Langzeit-JWT in der URL; Origin gegen `BASE_URL` prüfen. ### FLAW-23: SQLite-Schreibengpass +- **Status: Behoben.** Transaktionen starten mit der Schreibsperre (`_txlock=immediate` im DSN). Vorher scheiterte eine Transaktion, die erst liest und dann schreibt, sofort mit `SQLITE_BUSY`, sobald jemand anderes schrieb (`busy_timeout` greift in diesem Fall nicht); im Lasttest 12 von 480 Schreibvorgängen. Jetzt warten Schreiber bis zu 10 s auf ihren Platz. Es bleibt ein Schreiber zur Zeit, für Familien und Teams ausreichend. Test: `TestConcurrentWritersDoNotFail`. - Eine SQLite-Datei (WAL, `busy_timeout`, bis zu 8 offene Verbindungen) erlaubt nur einen gleichzeitigen Schreiber. Für Familien und kleine Teams unkritisch; bei vielen aktiven Editoren sind `SQLITE_BUSY`-Fehler möglich, die wegen FLAW-14 stumm bleiben. ### FLAW-24: Organisations-Endpunkte ohne Berechtigungsprüfung diff --git a/internal/storage/concurrency_test.go b/internal/storage/concurrency_test.go new file mode 100644 index 0000000..51e1f11 --- /dev/null +++ b/internal/storage/concurrency_test.go @@ -0,0 +1,62 @@ +package storage + +import ( + "fmt" + "path/filepath" + "sync" + "testing" + + "github.com/sandrodach/mindsift/internal/domain" +) + +// Many writers at once on one SQLite file (FLAW-23): transactions that read before they write must +// wait for the write lock instead of failing with SQLITE_BUSY. +func TestConcurrentWritersDoNotFail(t *testing.T) { + store, err := NewStore(filepath.Join(t.TempDir(), "load.db")) + if err != nil { + t.Fatal(err) + } + defer store.Close() + u, _ := store.CreateUser("load@example.com", "hash", "Last", "member") + tl, _ := store.CreateTaskList(u.ID, "", "", "Last", "", "") + var ids []string + for i := 0; i < 20; i++ { + th, _ := store.CreateThoughtWithDetails(u.ID, fmt.Sprintf("Aufgabe %d", i), "normal", "manual", "", tl.ID, "todo", "") + _, _ = store.UpdateThoughtTriage(u.ID, th.ID, "keep", "task", "", "", tl.ID) + ids = append(ids, th.ID) + } + + var wg sync.WaitGroup + errs := make(chan error, 400) + for w := 0; w < 16; w++ { + wg.Add(1) + go func(w int) { + defer wg.Done() + for i := 0; i < 15; i++ { + status := "todo" + if (w+i)%2 == 0 { + status = "done" + } + items := []domain.ThoughtReorderItem{{ID: ids[(w+i)%len(ids)], SortOrder: i, KanbanStatus: &status}} + if err := store.ReorderTasks(tl.ID, items); err != nil { + errs <- fmt.Errorf("reorder: %w", err) + } + if _, err := store.CreateThought(u.ID, fmt.Sprintf("Gedanke %d-%d", w, i), "normal", "manual", ""); err != nil { + errs <- fmt.Errorf("create: %w", err) + } + } + }(w) + } + wg.Wait() + close(errs) + failed := 0 + for err := range errs { + if failed < 3 { + t.Error(err) + } + failed++ + } + if failed > 0 { + t.Fatalf("%d of %d writes failed", failed, 16*15*2) + } +} diff --git a/internal/storage/sqlite.go b/internal/storage/sqlite.go index 8242198..b9c2639 100644 --- a/internal/storage/sqlite.go +++ b/internal/storage/sqlite.go @@ -32,9 +32,12 @@ func NewStore(dbPath string) (*Store, error) { } } + // _txlock=immediate: transactions take the write lock when they begin. A transaction that reads + // first and writes later would otherwise fail at once with SQLITE_BUSY while another one writes + // (busy_timeout cannot help there); now it simply waits its turn (FLAW-23). dsn := dbPath if !strings.Contains(dsn, "?") { - dsn += "?_pragma=busy_timeout(10000)&_pragma=journal_mode(WAL)&_pragma=synchronous(NORMAL)&_pragma=foreign_keys(1)" + dsn += "?_pragma=busy_timeout(10000)&_pragma=journal_mode(WAL)&_pragma=synchronous(NORMAL)&_pragma=foreign_keys(1)&_txlock=immediate" } db, err := sql.Open("sqlite", dsn) From 6b56a335001d5c2288fce59e9cf3b8e5621a7857 Mon Sep 17 00:00:00 2001 From: Sandro Date: Thu, 29 Oct 2026 14:59:23 +0100 Subject: [PATCH 19/26] fix(db): report write errors, multi-step changes all or nothing (FLAW-14) Store.execInTx runs related statements in one transaction and returns the first error. Used for deleting and moving folders (with the trash), shared folders, folder shares and mounts, deleting task lists and accounts, pinning, and the password reset (new password and the end of all other links together). Writes whose success the API reports now pass their error to the client (logout, new password, setup, sharing mode, new group, restore, unlinking a note block), follow-up tasks and alarm markers log failures. Remaining ignored writes are listed as intentional in flaws.md. Found on the way: DeleteTaskList unlinked all tasks before checking ownership, so a stranger with the list ID could empty it; viewers could unlink tasks from note blocks. Both fixed. Co-Authored-By: Claude Opus 5.5 --- Doku/flaws.md | 5 +- internal/api/alarms.go | 4 +- internal/api/handlers_admin.go | 5 +- internal/api/handlers_auth.go | 21 ++++- internal/api/handlers_tasklists.go | 2 +- internal/api/handlers_teams.go | 5 +- internal/api/handlers_thoughts.go | 6 +- internal/api/server.go | 2 +- internal/storage/concurrency_test.go | 33 +++++++ internal/storage/documents.go | 21 +++-- internal/storage/folders.go | 132 ++++++++++++++++----------- internal/storage/password_reset.go | 7 +- internal/storage/recurrence.go | 14 ++- internal/storage/sqlite.go | 32 ++++++- internal/storage/tasklists.go | 27 ++++-- internal/storage/thoughts.go | 23 +++-- internal/storage/trash.go | 22 +++-- internal/storage/users.go | 13 ++- internal/ws/hub.go | 12 +-- 19 files changed, 274 insertions(+), 112 deletions(-) diff --git a/Doku/flaws.md b/Doku/flaws.md index 9e09788..817b2e4 100644 --- a/Doku/flaws.md +++ b/Doku/flaws.md @@ -23,7 +23,7 @@ Schweregrade: **Hoch** (Datenabfluss oder Rechteumgehung möglich), **Mittel** ( | FLAW-11 | Workspace | ~~Block-Upsert (`ON CONFLICT(id)`) prüft das Dokument nicht~~ | **Behoben** | | FLAW-12 | Benachrichtigungen | ~~`notify_before_minutes` wirkungslos; Alarm feuert nach Umplanung nie wieder; endlose Wiederholung bei Fehlern~~ | **Behoben** | | FLAW-13 | Datenbank | ~~Keine Schema-Versionierung; Migrationsfehler werden verschluckt; `migrations/001_init.sql` ist toter Code~~ | **Behoben** | -| FLAW-14 | Backend | Fehler von Schreiboperationen werden häufig ignoriert (`_, _ =`) | Mittel | +| FLAW-14 | Backend | ~~Fehler von Schreiboperationen werden häufig ignoriert (`_, _ =`)~~ | **Behoben** | | FLAW-15 | Realtime | ~~`WriteTimeout` von 30 s beendet SSE-Streams~~ | **Behoben** | | FLAW-16 | Setup | `/auth/setup` ist bis zum ersten Konto offen; Demo-Modus legt `admin/admin` an | Mittel | | FLAW-17 | Workspace | ~~Bearbeiter-Freigaben für Notizen nicht wählbar~~ | **Behoben** | @@ -132,6 +132,9 @@ Schweregrade: **Hoch** (Datenabfluss oder Rechteumgehung möglich), **Mittel** ( - **Maßnahme:** `schema_migrations`-Tabelle mit nummerierten, einmalig ausgeführten Schritten; die SQL-Datei entweder einbetten oder entfernen. ### FLAW-14: Verschluckte Fehler +- **Status: Behoben.** Mehrstufige Änderungen laufen in einer Transaktion (`Store.execInTx`, ganz oder gar nicht) und geben Fehler zurück: Ordner löschen (inkl. Papierkorb), Ordner verschieben, geteilte Ordner anlegen/abgleichen/löschen, Ordner-Freigaben und -Einhängungen, Aufgabenliste löschen, Konto löschen, Anheften, Passwort-Reset (neues Passwort und Entwerten aller Links zusammen). Einzelne Schreibvorgänge, deren Erfolg die API meldet (Abmelden, neues Passwort, Ersteinrichtung, Freigabe-Modus, Gruppe anlegen, Wiederherstellen, Verknüpfung lösen), geben ihren Fehler an den Client. Folgeaufgaben und Alarm-Markierungen protokollieren Fehler. Test: `TestMultiStepChangesAreAllOrNothing`. +- **Bewusst ohne Fehlerbehandlung** (Ausfall ändert nichts Wichtiges): `updated_at`-Stempel von Listen, „zuletzt genutzt“ bei API-Keys, Aufräumen abgelaufener Tokens, Beispieldaten und Demo-Konten, einzelne Schritte des Markdown-Imports (sie landen als „übersprungen“ im Bericht). +- **Nebenbefund (behoben):** `DeleteTaskList` löste alle Aufgaben aus der Liste, **bevor** geprüft wurde, ob der Aufrufer Eigentümer ist; ein Fremder mit der Listen-ID konnte sie so leeren. Ebenso konnten Leser einer Aufgabenliste die Verknüpfung Notiz ↔ Aufgabe lösen. - **Fundstellen:** u. a. `PatchDocumentBlocks` (Upserts/Deletes), `AddTaskListShare`, `RemoveTaskListShare`, viele `_, _ = s.db.Exec`. - **Problem:** Scheitert ein Schreibzugriff (z. B. `SQLITE_BUSY`), antwortet die API trotzdem mit Erfolg und broadcastet den Stand, der nie gespeichert wurde. Fehler lassen sich nur schwer nachvollziehen. - **Beispiel (behoben):** Das Lösen der Verknüpfung zwischen Notiz-Block und Aufgabe („Verknüpfung aufheben“, Löschen eines verknüpften Blocks) schrieb in nicht existierende Spalten `thoughts.doc_id`/`block_id`. Der Fehler wurde verschluckt, die Funktion war nie wirksam. Korrigiert; im neuen Sync-Weg gleicht `ReconcileDocTaskLinks` Häkchen und gelöschte Blöcke ab. diff --git a/internal/api/alarms.go b/internal/api/alarms.go index 9d2bf55..0a2e65e 100644 --- a/internal/api/alarms.go +++ b/internal/api/alarms.go @@ -150,7 +150,9 @@ func (s *Server) startAlarmTicker() { } if sentAny { - _ = s.store.MarkTaskAlarmSent(a.TaskID) + if err := s.store.MarkTaskAlarmSent(a.TaskID); err != nil { + slog.Error("Alarm als gesendet markieren fehlgeschlagen (wird erneut gesendet)", "task_id", a.TaskID, "error", err) + } } else if gaveUp, _ := s.store.RecordTaskAlarmFailure(a.TaskID); gaveUp { slog.Warn("Aufgaben-Alarm nach 3 Fehlversuchen aufgegeben", "task_id", a.TaskID, "title", a.Title) } diff --git a/internal/api/handlers_admin.go b/internal/api/handlers_admin.go index 1b19db0..1960828 100644 --- a/internal/api/handlers_admin.go +++ b/internal/api/handlers_admin.go @@ -319,7 +319,10 @@ func (s *Server) handleAdminUpdateSettings(w http.ResponseWriter, r *http.Reques } // A stricter mode takes away what is no longer allowed (shares, group memberships, assignments) if settings.SharingMode != previousMode { - _, _ = s.store.PruneSharing(settings.SharingMode, false) + if _, err := s.store.PruneSharing(settings.SharingMode, false); err != nil { + writeError(w, http.StatusInternalServerError, "Freigaben konnten nicht angepasst werden: "+err.Error()) + return + } } settings.SMTPConfiguredViaEnv = strings.TrimSpace(s.cfg.SMTPHost) != "" writeJSON(w, http.StatusOK, settings) diff --git a/internal/api/handlers_auth.go b/internal/api/handlers_auth.go index 8c286de..a6f1f0f 100644 --- a/internal/api/handlers_auth.go +++ b/internal/api/handlers_auth.go @@ -128,7 +128,10 @@ func (s *Server) handleFirstBootSetup(w http.ResponseWriter, r *http.Request) { } sys.AllowRegistration = req.AllowRegistration sys.SharingMode = storage.NormalizeSharingMode(req.SharingMode) - _ = s.store.UpdateSystemSettings(sys) + if err := s.store.UpdateSystemSettings(sys); err != nil { + writeError(w, http.StatusInternalServerError, "Einstellungen konnten nicht gespeichert werden: "+err.Error()) + return + } // In organization mode the first admin starts in an organization named after the instance if sys.SharingMode == storage.SharingOrganizations { if org, err := s.store.CreateOrganization(sys.InstanceName); err == nil { @@ -216,7 +219,10 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) { } if validInvite != nil { - _ = s.store.MarkUserInviteUsed(validInvite.ID, user.ID) + // Otherwise the code could be used again + if err := s.store.MarkUserInviteUsed(validInvite.ID, user.ID); err != nil { + slog.Error("Einladungscode konnte nicht als benutzt markiert werden", "invite", validInvite.ID, "error", err) + } if validInvite.OrganizationID != "" { _ = s.store.SetUserOrganization(user.ID, validInvite.OrganizationID) user.OrganizationID = validInvite.OrganizationID @@ -397,7 +403,11 @@ func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { } else { exp = time.Now().Add(30 * 24 * time.Hour) } - _ = s.store.RevokeToken(tokenStr, userID, exp) + // The session must really end; the client is told if it did not + if err := s.store.RevokeToken(tokenStr, userID, exp); err != nil { + writeError(w, http.StatusInternalServerError, "Abmelden fehlgeschlagen") + return + } } } // Streams are not tied to one session; other devices reconnect with a new ticket on their own @@ -456,7 +466,10 @@ func (s *Server) handleUpdateUserSettings(w http.ResponseWriter, r *http.Request writeError(w, http.StatusInternalServerError, "Fehler beim Hashen des Passworts") return } - _ = s.store.UpdateUserPassword(userID, hash) + if err := s.store.UpdateUserPassword(userID, hash); err != nil { + writeError(w, http.StatusInternalServerError, "Passwort konnte nicht gespeichert werden") + return + } s.wsHub.DisconnectUser(userID) } u, err := s.store.UpdateUserFull( diff --git a/internal/api/handlers_tasklists.go b/internal/api/handlers_tasklists.go index 8df81d8..8fac112 100644 --- a/internal/api/handlers_tasklists.go +++ b/internal/api/handlers_tasklists.go @@ -72,7 +72,7 @@ func (s *Server) handleDeleteTaskList(w http.ResponseWriter, r *http.Request) { listID := chi.URLParam(r, "id") audienceBefore := s.store.GetTaskListUserIDs(listID) if err := s.store.DeleteTaskList(userID, listID); err != nil { - writeError(w, http.StatusInternalServerError, err.Error()) + writeError(w, http.StatusNotFound, err.Error()) return } s.broadcastTaskListChange(listID, audienceBefore, "TASK_LIST_DELETED") diff --git a/internal/api/handlers_teams.go b/internal/api/handlers_teams.go index f4885e4..db30a56 100644 --- a/internal/api/handlers_teams.go +++ b/internal/api/handlers_teams.go @@ -52,7 +52,10 @@ func (s *Server) handleCreateTeamDirect(w http.ResponseWriter, r *http.Request) return } // Automatically add creator to team as group admin - _ = s.store.AddTeamMember(team.ID, userID, "admin") + if err := s.store.AddTeamMember(team.ID, userID, "admin"); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } team.Members, _ = s.store.GetTeamMembers(team.ID) writeJSON(w, http.StatusCreated, team) } diff --git a/internal/api/handlers_thoughts.go b/internal/api/handlers_thoughts.go index 9bed8a6..a0ed90a 100644 --- a/internal/api/handlers_thoughts.go +++ b/internal/api/handlers_thoughts.go @@ -284,7 +284,10 @@ func (s *Server) handleUpdateThought(w http.ResponseWriter, r *http.Request) { func (s *Server) handleUnlinkEnhancedTask(w http.ResponseWriter, r *http.Request) { userID := auth.GetUserID(r.Context()) thoughtID := chi.URLParam(r, "id") - s.store.UnlinkTaskFromDocBlock(userID, thoughtID) + if err := s.store.UnlinkTaskFromDocBlock(userID, thoughtID); err != nil { + writeStoreError(w, http.StatusNotFound, err) + return + } writeJSON(w, http.StatusOK, map[string]bool{"unlinked": true}) } @@ -297,7 +300,6 @@ func (s *Server) handleDeleteThought(w http.ResponseWriter, r *http.Request) { return } audience := s.taskAudience(th) - s.store.UnlinkTaskFromDocBlock(userID, thoughtID) if err := s.store.DeleteThought(userID, thoughtID); err != nil { writeStoreError(w, http.StatusInternalServerError, err) return diff --git a/internal/api/server.go b/internal/api/server.go index 76cbab0..8a1d484 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -32,7 +32,7 @@ type Server struct { demo demoState // periodic reset in demo mode (demo.go) streamTickets streamTickets // one-time tickets for WebSocket/SSE (streamticket.go) - loginGuard *loginGuard // brute-force protection (loginguard.go) + loginGuard *loginGuard // brute-force protection (loginguard.go) } func NewServer(cfg *config.Config, store *storage.Store, wsHub *ws.Hub, staticFS fs.FS) *Server { diff --git a/internal/storage/concurrency_test.go b/internal/storage/concurrency_test.go index 51e1f11..165e22e 100644 --- a/internal/storage/concurrency_test.go +++ b/internal/storage/concurrency_test.go @@ -60,3 +60,36 @@ func TestConcurrentWritersDoNotFail(t *testing.T) { t.Fatalf("%d of %d writes failed", failed, 16*15*2) } } + +func TestMultiStepChangesAreAllOrNothing(t *testing.T) { + store, err := NewStore(filepath.Join(t.TempDir(), "atomic.db")) + if err != nil { + t.Fatal(err) + } + defer store.Close() + anna, _ := store.CreateUser("anna@example.com", "hash", "Anna", "member") + eve, _ := store.CreateUser("eve@example.com", "hash", "Eve", "member") + tl, _ := store.CreateTaskList(anna.ID, "", "", "Haushalt", "", "") + task, _ := store.CreateThoughtWithDetails(anna.ID, "Fenster", "normal", "manual", "", tl.ID, "todo", "") + _, _ = store.UpdateThoughtTriage(anna.ID, task.ID, "keep", "task", "", "", tl.ID) + + // A stranger deleting the list must not empty it (it used to unlink all tasks before checking) + if err := store.DeleteTaskList(eve.ID, tl.ID); err == nil { + t.Fatal("foreign user must not delete the task list") + } + if got, _ := store.GetThoughtByID(anna.ID, task.ID); got.TaskListID != tl.ID { + t.Fatalf("task was taken out of the list by a stranger") + } + + // A failing step rolls back the earlier ones + err = store.execInTx( + stmt(`UPDATE thoughts SET content = 'geändert' WHERE id = ?`, task.ID), + stmt(`UPDATE gibt_es_nicht SET x = 1`), + ) + if err == nil { + t.Fatal("expected an error from the second statement") + } + if got, _ := store.GetThoughtByID(anna.ID, task.ID); got.Content != "Fenster" { + t.Fatalf("first step must be rolled back, content is %q", got.Content) + } +} diff --git a/internal/storage/documents.go b/internal/storage/documents.go index 4601b82..6a989d5 100644 --- a/internal/storage/documents.go +++ b/internal/storage/documents.go @@ -630,16 +630,15 @@ func (s *Store) UpdateDocument(userID, docID string, title, icon, folderPath *st if isPinned != nil { doc.IsPinned = *isPinned + pin := []sqlStmt{stmt(`DELETE FROM doc_pins WHERE user_id = ? AND doc_id = ?`, userID, docID)} if *isPinned { - _, _ = s.db.Exec(`INSERT OR IGNORE INTO doc_pins (user_id, doc_id, created_at) VALUES (?, ?, ?)`, userID, docID, domain.NowISO()) - if doc.IsOwner { - _, _ = s.db.Exec(`UPDATE documents SET is_pinned = 1 WHERE id = ?`, docID) - } - } else { - _, _ = s.db.Exec(`DELETE FROM doc_pins WHERE user_id = ? AND doc_id = ?`, userID, docID) - if doc.IsOwner { - _, _ = s.db.Exec(`UPDATE documents SET is_pinned = 0 WHERE id = ?`, docID) - } + pin = []sqlStmt{stmt(`INSERT OR IGNORE INTO doc_pins (user_id, doc_id, created_at) VALUES (?, ?, ?)`, userID, docID, domain.NowISO())} + } + if doc.IsOwner { + pin = append(pin, stmt(`UPDATE documents SET is_pinned = ? WHERE id = ?`, boolInt(*isPinned), docID)) + } + if err := s.execInTx(pin...); err != nil { + return nil, err } } @@ -678,7 +677,9 @@ func (s *Store) DeleteDocument(userID, docID string) error { } if !doc.IsOwner { // Collaborator removing their shared access - _, _ = s.db.Exec(`DELETE FROM doc_user_folders WHERE user_id = ? AND doc_id = ?`, userID, docID) + if _, err := s.db.Exec(`DELETE FROM doc_user_folders WHERE user_id = ? AND doc_id = ?`, userID, docID); err != nil { + return err + } return s.DeleteDocShare(userID, docID, userID) } if doc.IsArchived { diff --git a/internal/storage/folders.go b/internal/storage/folders.go index 5d0250a..023ec11 100644 --- a/internal/storage/folders.go +++ b/internal/storage/folders.go @@ -105,15 +105,19 @@ func (s *Store) CreateFolder(userID, folderPath string) (string, error) { var existsID string err := s.db.QueryRow(`SELECT id FROM workspace_folders WHERE shared_folder_id = ? AND sub_path = ?`, sfID, subPath).Scan(&existsID) if err == sql.ErrNoRows { - _, _ = s.db.Exec(`INSERT INTO workspace_folders (id, user_id, shared_folder_id, path, sub_path, created_at) VALUES (?, ?, ?, ?, ?, ?)`, - id, userID, sfID, clean, subPath, now) + if _, err := s.db.Exec(`INSERT INTO workspace_folders (id, user_id, shared_folder_id, path, sub_path, created_at) VALUES (?, ?, ?, ?, ?, ?)`, + id, userID, sfID, clean, subPath, now); err != nil { + return "", err + } } } else { var existsID string err := s.db.QueryRow(`SELECT id FROM workspace_folders WHERE user_id = ? AND path = ? AND (shared_folder_id = '' OR shared_folder_id IS NULL)`, userID, clean).Scan(&existsID) if err == sql.ErrNoRows { - _, _ = s.db.Exec(`INSERT INTO workspace_folders (id, user_id, shared_folder_id, path, sub_path, created_at) VALUES (?, ?, '', ?, '', ?)`, - id, userID, clean, now) + if _, err := s.db.Exec(`INSERT INTO workspace_folders (id, user_id, shared_folder_id, path, sub_path, created_at) VALUES (?, ?, '', ?, '', ?)`, + id, userID, clean, now); err != nil { + return "", err + } } } @@ -127,6 +131,7 @@ func (s *Store) DeleteFolder(userID, folderPath string) ([]string, error) { } var deletedDocIDs []string + var writes []sqlStmt seenDocID := make(map[string]bool) mounts, _, err := s.getUserFolderMounts(userID) @@ -138,8 +143,7 @@ func (s *Store) DeleteFolder(userID, folderPath string) ([]string, error) { shFolder, sfErr := s.GetSharedFolderByID(userID, sfID) if sfErr == nil && !shFolder.IsOwner { // Collaborator removing mount/share - _ = s.DeleteSharedFolder(userID, sfID) - return []string{}, nil + return []string{}, s.DeleteSharedFolder(userID, sfID) } rows, qErr := s.db.Query(`SELECT id FROM documents WHERE shared_folder_id = ?`, sfID) if qErr == nil { @@ -152,7 +156,7 @@ func (s *Store) DeleteFolder(userID, folderPath string) ([]string, error) { } _ = rows.Close() } - _, _ = s.db.Exec(`DELETE FROM workspace_folders WHERE shared_folder_id = ?`, sfID) + writes = append(writes, stmt(`DELETE FROM workspace_folders WHERE shared_folder_id = ?`, sfID)) } else { // Subfolder inside a shared folder rows, qErr := s.db.Query( @@ -169,8 +173,8 @@ func (s *Store) DeleteFolder(userID, folderPath string) ([]string, error) { } _ = rows.Close() } - _, _ = s.db.Exec(`DELETE FROM workspace_folders WHERE shared_folder_id = ? AND (sub_path = ? OR sub_path LIKE ?)`, - sfID, subPath, subPath+"/%") + writes = append(writes, stmt(`DELETE FROM workspace_folders WHERE shared_folder_id = ? AND (sub_path = ? OR sub_path LIKE ?)`, + sfID, subPath, subPath+"/%")) } } } @@ -195,13 +199,18 @@ func (s *Store) DeleteFolder(userID, folderPath string) ([]string, error) { // The notes of the folder go to the trash; restoring one creates its folder again for _, id := range deletedDocIDs { - _ = s.trashDocument(userID, id) + writes = append(writes, trashStmts(userID, id)...) } // Also clear any personal folder placements for shared notes in this folder - _, _ = s.db.Exec(`UPDATE doc_user_folders SET folder_path = '' WHERE user_id = ? AND (folder_path = ? OR folder_path LIKE ?)`, userID, clean, clean+"/%") - _, _ = s.db.Exec(`DELETE FROM workspace_folders WHERE user_id = ? AND (path = ? OR path LIKE ?)`, userID, clean, clean+"/%") + writes = append(writes, + stmt(`UPDATE doc_user_folders SET folder_path = '' WHERE user_id = ? AND (folder_path = ? OR folder_path LIKE ?)`, userID, clean, clean+"/%"), + stmt(`DELETE FROM workspace_folders WHERE user_id = ? AND (path = ? OR path LIKE ?)`, userID, clean, clean+"/%")) + // All of it or nothing: no folder disappears while its notes stay behind, or the other way round + if err := s.execInTx(writes...); err != nil { + return nil, err + } return deletedDocIDs, nil } @@ -247,7 +256,9 @@ func (s *Store) MoveDocument(userID, docID, targetFolderPath string) (*domain.Do } // Remove personal override if any - _, _ = s.db.Exec(`DELETE FROM doc_user_folders WHERE user_id = ? AND doc_id = ?`, userID, docID) + if _, err := s.db.Exec(`DELETE FROM doc_user_folders WHERE user_id = ? AND doc_id = ?`, userID, docID); err != nil { + return nil, err + } doc.SharedFolderID = targetFolderID doc.SubFolderPath = subPath @@ -355,10 +366,10 @@ func (s *Store) MoveFolder(userID, sourcePath, targetParentPath string) (string, suffix := strings.TrimPrefix(doc.FolderPath, cleanSource) nextDocFolder = newFolderPath + suffix } - _, moveErr := s.MoveDocument(userID, doc.ID, nextDocFolder) - if moveErr == nil { - movedDocIDs = append(movedDocIDs, doc.ID) + if _, moveErr := s.MoveDocument(userID, doc.ID, nextDocFolder); moveErr != nil { + return "", movedDocIDs, fmt.Errorf("Notiz %q verschieben: %w", doc.Title, moveErr) } + movedDocIDs = append(movedDocIDs, doc.ID) } } @@ -366,8 +377,12 @@ func (s *Store) MoveFolder(userID, sourcePath, targetParentPath string) (string, targetSFID, targetSub, isTargetSF := matchMountPath(newFolderPath, mounts) sourceSFID, sourceSub, isSourceSF := matchMountPath(cleanSource, mounts) + var remap []sqlStmt wfRows, qErr := s.db.Query(`SELECT id, user_id, shared_folder_id, path, sub_path FROM workspace_folders`) - if qErr == nil { + if qErr != nil { + return "", movedDocIDs, qErr + } + { type wfRecord struct { id, uid, sfID, path, subPath string } @@ -402,10 +417,10 @@ func (s *Store) MoveFolder(userID, sourcePath, targetParentPath string) (string, newSub = targetSub + "/" + folderName + suffix } if isTargetSF { - _, _ = s.db.Exec(`UPDATE workspace_folders SET shared_folder_id = ?, sub_path = ? WHERE id = ?`, targetSFID, newSub, r.id) + remap = append(remap, stmt(`UPDATE workspace_folders SET shared_folder_id = ?, sub_path = ? WHERE id = ?`, targetSFID, newSub, r.id)) } else { newP := newFolderPath + suffix - _, _ = s.db.Exec(`UPDATE workspace_folders SET shared_folder_id = '', sub_path = '', path = ? WHERE id = ?`, newP, r.id) + remap = append(remap, stmt(`UPDATE workspace_folders SET shared_folder_id = '', sub_path = '', path = ? WHERE id = ?`, newP, r.id)) } } } else if r.path == cleanSource || strings.HasPrefix(r.path, cleanSource+"/") { @@ -418,24 +433,29 @@ func (s *Store) MoveFolder(userID, sourcePath, targetParentPath string) (string, } else { newSub = targetSub + "/" + folderName + suffix } - _, _ = s.db.Exec(`UPDATE workspace_folders SET shared_folder_id = ?, sub_path = ?, path = ? WHERE id = ?`, targetSFID, newSub, newP, r.id) + remap = append(remap, stmt(`UPDATE workspace_folders SET shared_folder_id = ?, sub_path = ?, path = ? WHERE id = ?`, targetSFID, newSub, newP, r.id)) } else { - _, _ = s.db.Exec(`UPDATE workspace_folders SET path = ? WHERE id = ?`, newP, r.id) + remap = append(remap, stmt(`UPDATE workspace_folders SET path = ? WHERE id = ?`, newP, r.id)) } } } } - _, _ = s.CreateFolder(userID, newFolderPath) - + if err := s.execInTx(remap...); err != nil { + return "", movedDocIDs, err + } + if _, err := s.CreateFolder(userID, newFolderPath); err != nil { + return "", movedDocIDs, err + } return newFolderPath, movedDocIDs, nil } // syncSharedFolderContents ensures all documents and subfolders under cleanMount owned by ownerID are linked to folderID. -func (s *Store) syncSharedFolderContents(ownerID, folderID, cleanMount string) { +func (s *Store) syncSharedFolderContents(ownerID, folderID, cleanMount string) error { if ownerID == "" || folderID == "" || cleanMount == "" { - return + return nil } + var links []sqlStmt // 1. Link all documents owned by ownerID in cleanMount to this shared folder rows, err := s.db.Query(`SELECT id, folder_path FROM documents WHERE user_id = ? AND is_archived = 0 AND (folder_path = ? OR folder_path LIKE ?)`, @@ -458,8 +478,8 @@ func (s *Store) syncSharedFolderContents(ownerID, folderID, cleanMount string) { } else if strings.HasPrefix(de.fp, cleanMount+"/") { subPath = strings.TrimPrefix(de.fp, cleanMount+"/") } - _, _ = s.db.Exec(`UPDATE documents SET shared_folder_id = ?, sub_folder_path = ? WHERE id = ?`, - folderID, subPath, de.id) + links = append(links, stmt(`UPDATE documents SET shared_folder_id = ?, sub_folder_path = ? WHERE id = ?`, + folderID, subPath, de.id)) } } @@ -482,10 +502,11 @@ func (s *Store) syncSharedFolderContents(ownerID, folderID, cleanMount string) { if strings.HasPrefix(wfe.path, cleanMount+"/") { subP = strings.TrimPrefix(wfe.path, cleanMount+"/") } - _, _ = s.db.Exec(`UPDATE workspace_folders SET shared_folder_id = ?, sub_path = ? WHERE id = ?`, - folderID, subP, wfe.id) + links = append(links, stmt(`UPDATE workspace_folders SET shared_folder_id = ?, sub_path = ? WHERE id = ?`, + folderID, subP, wfe.id)) } } + return s.execInTx(links...) } func (s *Store) CreateSharedFolder(userID, folderName, initialMountPath string) (*domain.SharedFolder, error) { @@ -506,7 +527,9 @@ func (s *Store) CreateSharedFolder(userID, folderName, initialMountPath string) WHERE f.owner_id = ? AND (m.mount_path = ? OR f.name = ?) `, userID, userID, cleanMount, cleanName).Scan(&existingID) if err == nil && existingID != "" { - s.syncSharedFolderContents(userID, existingID, cleanMount) + if err := s.syncSharedFolderContents(userID, existingID, cleanMount); err != nil { + return nil, err + } return s.GetSharedFolderByID(userID, existingID) } @@ -529,7 +552,9 @@ func (s *Store) CreateSharedFolder(userID, folderName, initialMountPath string) return nil, err } - s.syncSharedFolderContents(userID, folderID, cleanMount) + if err := s.syncSharedFolderContents(userID, folderID, cleanMount); err != nil { + return nil, err + } var ownerName string _ = s.db.QueryRow(`SELECT display_name FROM users WHERE id = ?`, userID).Scan(&ownerName) @@ -756,13 +781,16 @@ func (s *Store) CreateFolderShare(currentUserID, folderID, targetType, targetIde if ownerMount == "" { ownerMount = f.Name } - s.syncSharedFolderContents(f.OwnerID, folderID, ownerMount) + if err := s.syncSharedFolderContents(f.OwnerID, folderID, ownerMount); err != nil { + return nil, err + } + var mounts []sqlStmt if cleanType == "user" { - _, _ = s.db.Exec(` + mounts = append(mounts, stmt(` INSERT OR IGNORE INTO workspace_folder_mounts (user_id, folder_id, mount_path, created_at, updated_at) VALUES (?, ?, ?, ?, ?) - `, targetID, folderID, f.Name, now, now) + `, targetID, folderID, f.Name, now, now)) } else if cleanType == "team" { mRows, err := s.db.Query(`SELECT user_id FROM team_members WHERE team_id = ?`, targetID) if err == nil { @@ -775,13 +803,16 @@ func (s *Store) CreateFolderShare(currentUserID, folderID, targetType, targetIde } _ = mRows.Close() for _, uid := range memberIDs { - _, _ = s.db.Exec(` + mounts = append(mounts, stmt(` INSERT OR IGNORE INTO workspace_folder_mounts (user_id, folder_id, mount_path, created_at, updated_at) VALUES (?, ?, ?, ?, ?) - `, uid, folderID, f.Name, now, now) + `, uid, folderID, f.Name, now, now)) } } } + if err := s.execInTx(mounts...); err != nil { + return nil, err + } return &domain.FolderShare{ ID: shareID, @@ -806,24 +837,21 @@ func (s *Store) DeleteFolderShare(currentUserID, folderID, shareID string) error // Only the owner manages shares; everyone else can only leave the folder if !f.IsOwner { - _, err = s.db.Exec(`DELETE FROM workspace_folder_shares WHERE folder_id = ? AND target_type = 'user' AND target_id = ? AND (id = ? OR target_id = ?)`, - folderID, currentUserID, cleanID, cleanID) - _, _ = s.db.Exec(`DELETE FROM workspace_folder_mounts WHERE folder_id = ? AND user_id = ?`, folderID, currentUserID) - return err + return s.execInTx( + stmt(`DELETE FROM workspace_folder_shares WHERE folder_id = ? AND target_type = 'user' AND target_id = ? AND (id = ? OR target_id = ?)`, + folderID, currentUserID, cleanID, cleanID), + stmt(`DELETE FROM workspace_folder_mounts WHERE folder_id = ? AND user_id = ?`, folderID, currentUserID)) } var targetType, targetID string _ = s.db.QueryRow(`SELECT target_type, target_id FROM workspace_folder_shares WHERE folder_id = ? AND (id = ? OR target_id = ?)`, folderID, cleanID, cleanID).Scan(&targetType, &targetID) - _, err = s.db.Exec(`DELETE FROM workspace_folder_shares WHERE folder_id = ? AND (id = ? OR target_id = ?)`, folderID, cleanID, cleanID) - if err != nil { - return err - } + writes := []sqlStmt{stmt(`DELETE FROM workspace_folder_shares WHERE folder_id = ? AND (id = ? OR target_id = ?)`, folderID, cleanID, cleanID)} if targetType == "user" && targetID != "" { - _, _ = s.db.Exec(`DELETE FROM workspace_folder_mounts WHERE folder_id = ? AND user_id = ?`, folderID, targetID) + writes = append(writes, stmt(`DELETE FROM workspace_folder_mounts WHERE folder_id = ? AND user_id = ?`, folderID, targetID)) } - return nil + return s.execInTx(writes...) } func (s *Store) DeleteSharedFolder(currentUserID, folderID string) error { @@ -835,12 +863,12 @@ func (s *Store) DeleteSharedFolder(currentUserID, folderID string) error { return s.DeleteFolderShare(currentUserID, folderID, currentUserID) } - _, _ = s.db.Exec(`UPDATE documents SET shared_folder_id = '', sub_folder_path = '' WHERE shared_folder_id = ?`, folderID) - _, _ = s.db.Exec(`DELETE FROM workspace_folder_mounts WHERE folder_id = ?`, folderID) - _, _ = s.db.Exec(`DELETE FROM workspace_folder_shares WHERE folder_id = ?`, folderID) - _, _ = s.db.Exec(`DELETE FROM workspace_folders WHERE shared_folder_id = ?`, folderID) - _, err = s.db.Exec(`DELETE FROM workspace_shared_folders WHERE id = ?`, folderID) - return err + return s.execInTx( + stmt(`UPDATE documents SET shared_folder_id = '', sub_folder_path = '' WHERE shared_folder_id = ?`, folderID), + stmt(`DELETE FROM workspace_folder_mounts WHERE folder_id = ?`, folderID), + stmt(`DELETE FROM workspace_folder_shares WHERE folder_id = ?`, folderID), + stmt(`DELETE FROM workspace_folders WHERE shared_folder_id = ?`, folderID), + stmt(`DELETE FROM workspace_shared_folders WHERE id = ?`, folderID)) } // GetSharedFolderDocIDs returns all non-archived document IDs belonging to a shared folder. diff --git a/internal/storage/password_reset.go b/internal/storage/password_reset.go index 47e630c..6b2303f 100644 --- a/internal/storage/password_reset.go +++ b/internal/storage/password_reset.go @@ -84,6 +84,9 @@ func (s *Store) ResetPasswordWithToken(raw, passwordHash string) (string, error) if n, _ := res.RowsAffected(); n == 0 { return "", ErrResetTokenInvalid // used in parallel } - _, _ = s.db.Exec(`UPDATE password_reset_tokens SET used_at = ? WHERE user_id = ? AND used_at = ''`, now, userID) - return userID, s.UpdateUserPassword(userID, passwordHash) + // New password and the end of every other open link belong together + return userID, s.execInTx( + stmt(`UPDATE password_reset_tokens SET used_at = ? WHERE user_id = ? AND used_at = ''`, now, userID), + stmt(`UPDATE users SET password_hash = ?, token_version = token_version + 1 WHERE id = ?`, passwordHash, userID), + ) } diff --git a/internal/storage/recurrence.go b/internal/storage/recurrence.go index caf1d87..169e8ed 100644 --- a/internal/storage/recurrence.go +++ b/internal/storage/recurrence.go @@ -2,6 +2,7 @@ package storage import ( "errors" + "log/slog" "time" "github.com/sandrodach/mindsift/internal/domain" @@ -111,9 +112,11 @@ func (s *Store) spawnNextOccurrence(taskID string) { due := NextDueDate(t.DueDate, t.Recurrence, time.Now()) next, err := s.CreateThoughtWithDetails(t.UserID, t.Content, t.Priority, t.Source, due, t.TaskListID, column, t.Notes) if err != nil { + slog.Error("Folgeaufgabe konnte nicht angelegt werden", "task_id", taskID, "error", err) return } if _, err := s.UpdateThoughtTriage(t.UserID, next.ID, "keep", "task", "", due, t.TaskListID); err != nil { + slog.Error("Folgeaufgabe konnte nicht als Aufgabe abgelegt werden", "task_id", next.ID, "error", err) return } subs := make([]domain.SubTask, len(t.Subtasks)) @@ -121,10 +124,15 @@ func (s *Store) spawnNextOccurrence(taskID string) { subs[i] = domain.SubTask{ID: domain.NewID("st"), Title: st.Title, CreatedAt: domain.NowISO()} } tags := t.Tags - _, _ = s.UpdateThoughtDetails(t.UserID, next.ID, nil, nil, nil, nil, nil, &column, nil, &subs, &tags) - _, _ = s.db.Exec(`UPDATE thoughts SET recurrence = ?, sort_order = ? WHERE id = ?`, t.Recurrence, t.SortOrder, next.ID) + if _, err := s.UpdateThoughtDetails(t.UserID, next.ID, nil, nil, nil, nil, nil, &column, nil, &subs, &tags); err != nil { + slog.Error("Folgeaufgabe: Unteraufgaben und Tags fehlen", "task_id", next.ID, "error", err) + } + carry := []sqlStmt{stmt(`UPDATE thoughts SET recurrence = ?, sort_order = ? WHERE id = ?`, t.Recurrence, t.SortOrder, next.ID)} for _, uid := range t.Assignees { - _, _ = s.db.Exec(`INSERT OR IGNORE INTO task_assignees (task_id, user_id, created_at) VALUES (?, ?, ?)`, next.ID, uid, domain.NowISO()) + carry = append(carry, stmt(`INSERT OR IGNORE INTO task_assignees (task_id, user_id, created_at) VALUES (?, ?, ?)`, next.ID, uid, domain.NowISO())) + } + if err := s.execInTx(carry...); err != nil { + slog.Error("Folgeaufgabe: Wiederholung und Zuweisungen nicht übernommen", "task_id", next.ID, "error", err) } s.PruneTaskAssignees() diff --git a/internal/storage/sqlite.go b/internal/storage/sqlite.go index b9c2639..5a33117 100644 --- a/internal/storage/sqlite.go +++ b/internal/storage/sqlite.go @@ -5,6 +5,7 @@ import ( "database/sql" "encoding/json" "fmt" + "log/slog" "os" "path/filepath" "strings" @@ -665,7 +666,36 @@ func (s *Store) migrateWorkspaceToPureDB() { } _ = sfRows.Close() for _, info := range sfs { - s.syncSharedFolderContents(info.ownerID, info.id, info.mountPath) + if err := s.syncSharedFolderContents(info.ownerID, info.id, info.mountPath); err != nil { + slog.Error("Geteilte Ordner abgleichen fehlgeschlagen", "folder", info.id, "error", err) + } } } } + +// sqlStmt is one statement of a change with several steps (FLAW-14). +type sqlStmt struct { + query string + args []interface{} +} + +func stmt(query string, args ...interface{}) sqlStmt { return sqlStmt{query, args} } + +// execInTx runs all statements in one transaction: either every step is stored or none, and the +// first error is returned instead of being ignored. +func (s *Store) execInTx(stmts ...sqlStmt) error { + if len(stmts) == 0 { + return nil + } + tx, err := s.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + for _, st := range stmts { + if _, err := tx.Exec(st.query, st.args...); err != nil { + return err + } + } + return tx.Commit() +} diff --git a/internal/storage/tasklists.go b/internal/storage/tasklists.go index 9eb2f7e..aa15319 100644 --- a/internal/storage/tasklists.go +++ b/internal/storage/tasklists.go @@ -4,6 +4,7 @@ import ( "encoding/json" "errors" "fmt" + "log/slog" "strings" "github.com/sandrodach/mindsift/internal/domain" @@ -52,7 +53,9 @@ func (s *Store) CreateTaskList(userID, spaceID, teamID, title, icon, color strin return nil, err } if teamID != "" && s.CanShareWith(userID, "team", teamID) { - _, _ = s.AddTaskListShare(tl.ID, "team", teamID, "editor", userID) + if _, err := s.AddTaskListShare(tl.ID, "team", teamID, "editor", userID); err != nil { + return nil, err + } } return s.GetTaskListByID(userID, tl.ID) } @@ -323,7 +326,7 @@ func (s *Store) SetTaskAssignees(userID, taskID string, assignees []string) ([]s // left the team, team deleted, task moved to another list or out of any list). Called after each change // that can take access away, so assignments never outlive permissions. func (s *Store) PruneTaskAssignees() { - _, _ = s.db.Exec( + _, err := s.db.Exec( `DELETE FROM task_assignees WHERE rowid IN ( SELECT ta.rowid FROM task_assignees ta LEFT JOIN thoughts t ON t.id = ta.task_id @@ -331,6 +334,9 @@ func (s *Store) PruneTaskAssignees() { OR ta.user_id NOT IN (` + fmt.Sprintf(taskListAccessSQL, "t.task_list_id") + `) )`, ) + if err != nil { + slog.Error("Zuweisungen bereinigen fehlgeschlagen", "error", err) + } } // GetTaskListUserIDs returns every user who can see the task list: owner, direct shares and members of shared teams. @@ -533,9 +539,18 @@ func (s *Store) UpdateTaskList(userID, listID string, title, icon, color, spaceI } func (s *Store) DeleteTaskList(userID, listID string) error { - _, _ = s.db.Exec(`UPDATE thoughts SET task_list_id = '' WHERE task_list_id = ?`, listID) - _, _ = s.db.Exec(`DELETE FROM task_list_shares WHERE task_list_id = ?`, listID) - _, err := s.db.Exec(`DELETE FROM task_lists WHERE id = ? AND user_id = ?`, listID, userID) + var owner string + if err := s.db.QueryRow(`SELECT user_id FROM task_lists WHERE id = ?`, listID).Scan(&owner); err != nil || owner != userID { + return errors.New("Aufgabenliste nicht gefunden") + } + // The tasks stay (as personal tasks of their owners), shares and the list go – all or nothing + if err := s.execInTx( + stmt(`UPDATE thoughts SET task_list_id = '' WHERE task_list_id = ?`, listID), + stmt(`DELETE FROM task_list_shares WHERE task_list_id = ?`, listID), + stmt(`DELETE FROM task_lists WHERE id = ? AND user_id = ?`, listID, userID), + ); err != nil { + return err + } s.PruneTaskAssignees() - return err + return nil } diff --git a/internal/storage/thoughts.go b/internal/storage/thoughts.go index 3b8056f..7c20779 100644 --- a/internal/storage/thoughts.go +++ b/internal/storage/thoughts.go @@ -485,7 +485,9 @@ func (s *Store) UpdateThoughtDetails(userID, thoughtID string, content *string, } // A new due date or a reopened task gets its alarm again if thought.DueDate != oldDue || (wasCompleted && !thought.IsCompleted) { - _, _ = s.db.Exec(`UPDATE thoughts SET alarm_sent = 0, alarm_failures = 0 WHERE id = ?`, thoughtID) + if _, err := s.db.Exec(`UPDATE thoughts SET alarm_sent = 0, alarm_failures = 0 WHERE id = ?`, thoughtID); err != nil { + return nil, err + } thought.AlarmSent = false } if taskListID != nil { @@ -538,8 +540,10 @@ func (s *Store) CreateTaskFromDocBlock(userID, docID, blockID, content string, c doneInt = 1 t.KanbanStatus = "done" } - _, _ = s.db.Exec(`UPDATE thoughts SET converted_type = 'document_block', converted_ref_id = ?, is_completed = ?, kanban_status = ?, status = 'kept' WHERE id = ?`, - t.ConvertedRefID, doneInt, t.KanbanStatus, t.ID) + if _, err := s.db.Exec(`UPDATE thoughts SET converted_type = 'document_block', converted_ref_id = ?, is_completed = ?, kanban_status = ?, status = 'kept' WHERE id = ?`, + t.ConvertedRefID, doneInt, t.KanbanStatus, t.ID); err != nil { + return nil, err + } s.SetBlockChecked(userID, docID, blockID, checked) return t, nil @@ -561,11 +565,16 @@ func (s *Store) SyncDocBlockAndTask(userID, taskID string, isCompleted bool) str // UnlinkTaskFromDocBlock removes the link between a task and its note block; both stay. The block's // task ID is derived from this link (docTaskLinks), so nothing in the note itself needs to change. -func (s *Store) UnlinkTaskFromDocBlock(userID, taskID string) { - if t, err := s.GetThoughtByID(userID, taskID); err != nil || t == nil { - return +func (s *Store) UnlinkTaskFromDocBlock(userID, taskID string) error { + t, err := s.GetThoughtByID(userID, taskID) + if err != nil || t == nil { + return err } - _, _ = s.db.Exec(`UPDATE thoughts SET converted_type = '', converted_ref_id = '' WHERE id = ?`, taskID) + if err := s.ensureTaskListWritable(userID, t.TaskListID); err != nil { + return err + } + _, err = s.db.Exec(`UPDATE thoughts SET converted_type = '', converted_ref_id = '' WHERE id = ?`, taskID) + return err } func (s *Store) SyncTaskFromBlockCheck(userID, taskID string, checked bool) { diff --git a/internal/storage/trash.go b/internal/storage/trash.go index 8a25289..84d037e 100644 --- a/internal/storage/trash.go +++ b/internal/storage/trash.go @@ -30,13 +30,15 @@ type TrashedDocument struct { const trashScope = `is_archived = 1 AND (user_id = ? OR archived_by = ?)` -func (s *Store) trashDocument(userID, docID string) error { - _, err := s.db.Exec(`UPDATE documents SET is_archived = 1, archived_at = ?, archived_by = ? WHERE id = ?`, - domain.NowISO(), userID, docID) - if err == nil { - _, _ = s.db.Exec(`DELETE FROM doc_pins WHERE doc_id = ?`, docID) +func trashStmts(userID, docID string) []sqlStmt { + return []sqlStmt{ + stmt(`UPDATE documents SET is_archived = 1, archived_at = ?, archived_by = ? WHERE id = ?`, domain.NowISO(), userID, docID), + stmt(`DELETE FROM doc_pins WHERE doc_id = ?`, docID), } - return err +} + +func (s *Store) trashDocument(userID, docID string) error { + return s.execInTx(trashStmts(userID, docID)...) } // GetTrash lists the notes in the user's trash, newest deletion first. @@ -90,10 +92,14 @@ func (s *Store) RestoreDocument(userID, docID string) error { var exists int _ = s.db.QueryRow(`SELECT COUNT(*) FROM workspace_shared_folders WHERE id = ?`, sharedFolderID).Scan(&exists) if exists == 0 { - _, _ = s.db.Exec(`UPDATE documents SET shared_folder_id = '', sub_folder_path = '', folder_path = '' WHERE id = ?`, docID) + if _, err := s.db.Exec(`UPDATE documents SET shared_folder_id = '', sub_folder_path = '', folder_path = '' WHERE id = ?`, docID); err != nil { + return err + } } } else if folderPath != "" { - _, _ = s.CreateFolder(owner, folderPath) + if _, err := s.CreateFolder(owner, folderPath); err != nil { + return err + } } _, err = s.db.Exec(`UPDATE documents SET is_archived = 0, archived_at = '', archived_by = '' WHERE id = ?`, docID) return err diff --git a/internal/storage/users.go b/internal/storage/users.go index 0bf2dc4..0196c07 100644 --- a/internal/storage/users.go +++ b/internal/storage/users.go @@ -302,12 +302,17 @@ func (s *Store) DeleteUser(userID string) error { if err := s.PurgeUserData(userID); err != nil { return err } - _, _ = s.db.Exec(`DELETE FROM revoked_tokens WHERE user_id = ?`, userID) - _, _ = s.db.Exec(`DELETE FROM task_assignees WHERE user_id = ?`, userID) - _, err := s.db.Exec(`DELETE FROM users WHERE id = ?`, userID) + if err := s.execInTx( + stmt(`DELETE FROM revoked_tokens WHERE user_id = ?`, userID), + stmt(`DELETE FROM task_assignees WHERE user_id = ?`, userID), + stmt(`DELETE FROM password_reset_tokens WHERE user_id = ?`, userID), + stmt(`DELETE FROM users WHERE id = ?`, userID), + ); err != nil { + return err + } // Shares pointing at the deleted account are no longer visible to anyone s.pruneSharingNow() - return err + return nil } // RevokeToken adds an individual JWT to the blacklist until its expiration date diff --git a/internal/ws/hub.go b/internal/ws/hub.go index 0d2cfbd..c40bbec 100644 --- a/internal/ws/hub.go +++ b/internal/ws/hub.go @@ -319,8 +319,6 @@ func (h *Hub) LeaveSSERoomByID(sseID, userID, roomID string) bool { return false } - - // Close gracefully disconnects all WebSocket and SSE connections so HTTP shutdown completes immediately. func (h *Hub) Close() { h.mu.Lock() @@ -558,11 +556,11 @@ func (h *Hub) ServeWS(w http.ResponseWriter, r *http.Request, roomID, userID str } client := &Client{ - hub: h, - conn: conn, - roomID: roomID, - userID: userID, - send: make(chan []byte, 128), + hub: h, + conn: conn, + roomID: roomID, + userID: userID, + send: make(chan []byte, 128), } h.Register(client) From 4c50d1591500af86157fe2b901e3238e1b4e1385 Mon Sep 17 00:00:00 2001 From: Sandro Date: Thu, 29 Oct 2026 15:02:52 +0100 Subject: [PATCH 20/26] chore(frontend): real ESLint instead of a placeholder; fix hook order (FLAW-19) eslint.config.js with the recommended JS/TypeScript rules and the React hooks rules; npm run lint runs ESLint and is part of the image build. Errors fail the build, the 24 exhaustive-deps findings stay warnings. It found two real bugs: TaskDetailModal and ManageColumnsModal called their hooks after 'if (!isOpen) return null', so the hook count changed whenever the dialog opened or closed. Both are now a thin wrapper plus a content component. Also removes unused imports and dead code (old task list share handlers, an unused realtime status, an unused rollup). Co-Authored-By: Claude Opus 5.5 --- Dockerfile | 2 +- frontend/eslint.config.js | 30 + frontend/package-lock.json | 1305 ++++++++++++++++- frontend/package.json | 7 +- frontend/src/App.tsx | 2 +- frontend/src/components/CommandPalette.tsx | 1 - frontend/src/features/help/HelpView.tsx | 5 - .../src/features/lists/SharedListsView.tsx | 5 +- .../features/settings/UserSettingsView.tsx | 1 - .../features/triage/ManageColumnsModal.tsx | 9 +- .../src/features/triage/TaskDetailModal.tsx | 22 +- frontend/src/features/triage/TasksView.tsx | 65 +- .../src/features/workspace/CodeBlockItem.tsx | 2 +- .../src/features/workspace/DocumentEditor.tsx | 1 - .../src/features/workspace/OutlinerBlock.tsx | 1 - .../src/features/workspace/OutlinerTree.tsx | 2 +- .../src/features/workspace/WorkspaceView.tsx | 35 - frontend/src/lib/api.ts | 3 +- .../src/lib/docSync/__tests__/diff.test.ts | 2 +- 19 files changed, 1360 insertions(+), 140 deletions(-) create mode 100644 frontend/eslint.config.js diff --git a/Dockerfile b/Dockerfile index 37123bd..04f7a85 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,7 +8,7 @@ RUN npm install --no-audit --no-fund COPY frontend/ ./ # Version shown in the header: the git tag for releases (set by CI), otherwise "-dev" ARG APP_VERSION="" -RUN npx tsc --noEmit && npm run test:run && APP_VERSION="${APP_VERSION}" npm run build +RUN npx tsc --noEmit && npm run lint && npm run test:run && APP_VERSION="${APP_VERSION}" npm run build # ============================================================================== # Stage 2: Go Backend Lint, Unit Tests & Binary Build (Cached Go module & sqlite layer) diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js new file mode 100644 index 0000000..0c38f73 --- /dev/null +++ b/frontend/eslint.config.js @@ -0,0 +1,30 @@ +// Lint rules for the frontend (FLAW-19): real mistakes are errors, style is left to the code. +import js from '@eslint/js'; +import globals from 'globals'; +import reactHooks from 'eslint-plugin-react-hooks'; +import tseslint from 'typescript-eslint'; + +export default tseslint.config( + { ignores: ['dist', 'node_modules', 'coverage'] }, + { + files: ['src/**/*.{ts,tsx}'], + extends: [js.configs.recommended, ...tseslint.configs.recommended], + languageOptions: { + ecmaVersion: 2022, + globals: globals.browser, + }, + plugins: { 'react-hooks': reactHooks }, + rules: { + ...reactHooks.configs.recommended.rules, + // Error payloads and browser APIs without types are handled as any on purpose + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_', varsIgnorePattern: '^_', caughtErrors: 'none' }], + 'no-empty': ['error', { allowEmptyCatch: true }], + }, + }, + { + files: ['public/sw.js'], + languageOptions: { globals: globals.serviceworker }, + extends: [js.configs.recommended], + }, +); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 6d43401..532e596 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -7,9 +7,10 @@ "": { "name": "mindsift-frontend", "version": "0.4.1", + "license": "Unlicense", "dependencies": { "@types/prismjs": "^1.26.6", - "emoji-picker-react": "^4.12.0", + "emoji-picker-react": "4.12.0", "framer-motion": "^11.15.0", "lucide-react": "^0.468.0", "prismjs": "^1.30.0", @@ -17,17 +18,22 @@ "react-dom": "^18.3.1" }, "devDependencies": { + "@eslint/js": "9.17.0", "@testing-library/dom": "^10.4.2", "@testing-library/react": "^16.3.3", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", "@vitejs/plugin-react": "^4.3.4", "autoprefixer": "^10.4.20", + "eslint": "9.17.0", + "eslint-plugin-react-hooks": "5.1.0", "fake-indexeddb": "^6.2.6", + "globals": "15.14.0", "jsdom": "^25.0.1", "postcss": "^8.4.49", "tailwindcss": "^3.4.17", "typescript": "^5.6.3", + "typescript-eslint": "8.18.2", "vite": "^5.4.11", "vitest": "^2.1.9" } @@ -864,6 +870,226 @@ "node": ">=12" } }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.19.2.tgz", + "integrity": "sha512-GNKqxfHG2ySmJOBSHg7LxeUx4xpuCoFjacmlCoYWEbaPXLwvfIjixRI12xCQZeULksQb23uiA8F40w5TojpV7w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.6", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.9.1.tgz", + "integrity": "sha512-GuUdqkyyzQI5RMIWkHhvTWLCyLo1jNK3vzkSyaExH5kHPDHcuL2VOpHjmMY+y3+NC69qAKToBqldTBgYeLSr9Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.7.tgz", + "integrity": "sha512-F42g89Qd5oAWtp0k0nnSrjziAKza7w8SVT4mStc18LZMaRb4J1HQAHLCalEtDCxrTuksx7NU9qsmeLwpOfPqWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.3.2", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.17.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.17.0.tgz", + "integrity": "sha512-Sxc4hqcs1kTu0iID3kcZDW3JHq2a77HO9P8CP6YEA/FpH3Ll8UXE2r/86Rz9YJLKme39S9vU5OWNjC6Xl0Cr3w==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.2.8", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.2.8.tgz", + "integrity": "sha512-ZAoA40rNMPwSm+AeHpCq8STiNAwzWLJuP8Xv4CHIc9wv/PSuExjMrmjfYNj682vW0OOiZ1HKxzvjQr9XZIisQA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.13.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit/node_modules/@eslint/core": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.13.0.tgz", + "integrity": "sha512-yfkgDw1KR66rkT5A8ci4irzDysN7FRpq3ttJolR88OqQikAWqwA8j5VZyas+vjyBNFIJ7MfybJ9plMILI2UrCw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -1433,6 +1659,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/prismjs": { "version": "1.26.6", "resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.6.tgz", @@ -1467,6 +1700,225 @@ "@types/react": "^18.0.0" } }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.18.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.18.2.tgz", + "integrity": "sha512-adig4SzPLjeQ0Tm+jvsozSGiCliI2ajeURDGHjZ2llnA+A67HihCQ+a3amtPhUakd1GlwHxSRvzOZktbEvhPPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "8.18.2", + "@typescript-eslint/type-utils": "8.18.2", + "@typescript-eslint/utils": "8.18.2", + "@typescript-eslint/visitor-keys": "8.18.2", + "graphemer": "^1.4.0", + "ignore": "^5.3.1", + "natural-compare": "^1.4.0", + "ts-api-utils": "^1.3.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.0.0 || ^8.0.0-alpha.0", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.8.0" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.18.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.18.2.tgz", + "integrity": "sha512-y7tcq4StgxQD4mDr9+Jb26dZ+HTZ/SkfqpXSiqeUXZHxOUyjWDKsmwKhJ0/tApR08DgOhrFAoAhyB80/p3ViuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.18.2", + "@typescript-eslint/types": "8.18.2", + "@typescript-eslint/typescript-estree": "8.18.2", + "@typescript-eslint/visitor-keys": "8.18.2", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.8.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.18.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.18.2.tgz", + "integrity": "sha512-YJFSfbd0CJjy14r/EvWapYgV4R5CHzptssoag2M7y3Ra7XNta6GPAJPPP5KGB9j14viYXyrzRO5GkX7CRfo8/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.18.2", + "@typescript-eslint/visitor-keys": "8.18.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.18.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.18.2.tgz", + "integrity": "sha512-AB/Wr1Lz31bzHfGm/jgbFR0VB0SML/hd2P1yxzKDM48YmP7vbyJNHRExUE/wZsQj2wUCvbWH8poNHFuxLqCTnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/typescript-estree": "8.18.2", + "@typescript-eslint/utils": "8.18.2", + "debug": "^4.3.4", + "ts-api-utils": "^1.3.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.8.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.18.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.18.2.tgz", + "integrity": "sha512-Z/zblEPp8cIvmEn6+tPDIHUbRu/0z5lqZ+NvolL5SvXWT5rQy7+Nch83M0++XzO0XrWRFWECgOAyE8bsJTl1GQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.18.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.18.2.tgz", + "integrity": "sha512-WXAVt595HjpmlfH4crSdM/1bcsqh+1weFRWIa9XMTx/XHZ9TCKMcr725tLYqWOgzKdeDrqVHxFotrvWcEsk2Tg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.18.2", + "@typescript-eslint/visitor-keys": "8.18.2", + "debug": "^4.3.4", + "fast-glob": "^3.3.2", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^1.3.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <5.8.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", + "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.18.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.18.2.tgz", + "integrity": "sha512-Cr4A0H7DtVIPkauj4sTSXVl+VBWewE9/o40KcF3TV9aqDEOWoXF3/+oRXNby3DYzZeCATvbdksYsGZzplwnK/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.4.0", + "@typescript-eslint/scope-manager": "8.18.2", + "@typescript-eslint/types": "8.18.2", + "@typescript-eslint/typescript-estree": "8.18.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.8.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.18.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.18.2.tgz", + "integrity": "sha512-zORcwn4C3trOWiCqFQP1x6G3xTRyZ1LYydnj51cRnJ6hxBlr/cKPckk+PKPUw/fXmvfKTcw7bwY3w9izgx5jZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.18.2", + "eslint-visitor-keys": "^4.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, "node_modules/@vitejs/plugin-react": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", @@ -1601,6 +2053,29 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/acorn": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.19.0.tgz", + "integrity": "sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, "node_modules/agent-base": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", @@ -1611,6 +2086,23 @@ "node": ">= 14" } }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -1662,6 +2154,13 @@ "dev": true, "license": "MIT" }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, "node_modules/aria-query": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", @@ -1726,6 +2225,13 @@ "postcss": "^8.1.0" } }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, "node_modules/baseline-browser-mapping": { "version": "2.11.32", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.32.tgz", @@ -1752,6 +2258,17 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/brace-expansion": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, "node_modules/braces": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", @@ -1823,6 +2340,16 @@ "node": ">= 0.4" } }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/camelcase-css": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", @@ -1871,6 +2398,39 @@ "node": ">=18" } }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chalk/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/check-error": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", @@ -1919,6 +2479,26 @@ "node": ">= 6" } }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -1942,6 +2522,13 @@ "node": ">= 6" } }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -1949,6 +2536,21 @@ "dev": true, "license": "MIT" }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -2039,6 +2641,13 @@ "node": ">=6" } }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -2235,6 +2844,177 @@ "node": ">=6" } }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.17.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.17.0.tgz", + "integrity": "sha512-evtlNcpJg+cZLcnVKwsai8fExnqjGPicK7gnUtlNuzu+Fv9bI0aLpND5T44VLQtoMEnI57LoXO9XAkIXwohKrA==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.19.0", + "@eslint/core": "^0.9.0", + "@eslint/eslintrc": "^3.2.0", + "@eslint/js": "9.17.0", + "@eslint/plugin-kit": "^0.2.3", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.1", + "@types/estree": "^1.0.6", + "@types/json-schema": "^7.0.15", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.2.0", + "eslint-visitor-keys": "^4.2.0", + "espree": "^10.3.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.1.0.tgz", + "integrity": "sha512-mpJRtPgHN2tNAvZ35AMfqeB3Xqeo273QxrHJsbBEPWODRM4r0yB6jfoROqKEYrOn27UtRPpcpHc2UqyBSuUNTw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, "node_modules/estree-walker": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", @@ -2245,6 +3025,16 @@ "@types/estree": "^1.0.0" } }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/expect-type": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", @@ -2265,6 +3055,13 @@ "node": ">=18" } }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-glob": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", @@ -2295,6 +3092,20 @@ "node": ">= 6" } }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, "node_modules/fastq": { "version": "1.20.3", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.3.tgz", @@ -2305,6 +3116,19 @@ "reusify": "^1.0.4" } }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -2318,12 +3142,50 @@ "node": ">=8" } }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/flairup": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/flairup/-/flairup-1.0.0.tgz", "integrity": "sha512-IKlE+pNvL2R+kVL1kEhUYqRxVqeFnjiIvHWDMLFXNaqyUdFXQM2wte44EfMYJNHkW16X991t2Zg8apKkhv7OBA==", "license": "MIT" }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, "node_modules/form-data": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", @@ -2469,6 +3331,19 @@ "node": ">=10.13.0" } }, + "node_modules/globals": { + "version": "15.14.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-15.14.0.tgz", + "integrity": "sha512-OkToC372DtlQeje9/zHIo5CT8lRP/FUgEOKBEhU4e0abL7J7CD24fD9ohiLN5hagG/kWCYj4K5oaxxtj2Z0Dig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -2482,6 +3357,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true, + "license": "MIT" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -2578,6 +3470,43 @@ "node": ">=0.10.0" } }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, "node_modules/is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -2647,6 +3576,13 @@ "dev": true, "license": "MIT" }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, "node_modules/jiti": { "version": "1.21.7", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", @@ -2663,6 +3599,29 @@ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "license": "MIT" }, + "node_modules/js-yaml": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/jsdom": { "version": "25.0.1", "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-25.0.1.tgz", @@ -2717,6 +3676,27 @@ "node": ">=6" } }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -2730,6 +3710,30 @@ "node": ">=6" } }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/lilconfig": { "version": "3.1.4", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.4.tgz", @@ -2750,6 +3754,29 @@ "dev": true, "license": "MIT" }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -2865,6 +3892,19 @@ "node": ">= 0.6" } }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/motion-dom": { "version": "11.18.1", "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-11.18.1.tgz", @@ -2918,6 +3958,13 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, "node_modules/node-releases": { "version": "2.0.60", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.60.tgz", @@ -2965,6 +4012,69 @@ "node": ">= 6" } }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/parse5": { "version": "7.3.0", "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", @@ -2978,6 +4088,26 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/path-parse": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", @@ -3195,6 +4325,16 @@ "dev": true, "license": "MIT" }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/pretty-format": { "version": "27.5.1", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", @@ -3334,6 +4474,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/reusify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", @@ -3461,6 +4611,29 @@ "semver": "bin/semver.js" } }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -3492,6 +4665,19 @@ "dev": true, "license": "MIT" }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/sucrase": { "version": "3.35.1", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", @@ -3515,6 +4701,19 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/supports-preserve-symlinks-flag": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", @@ -3747,6 +4946,19 @@ "node": ">=18" } }, + "node_modules/ts-api-utils": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz", + "integrity": "sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "typescript": ">=4.2.0" + } + }, "node_modules/ts-interface-checker": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", @@ -3760,10 +4972,23 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz", + "integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==", "dev": true, "license": "Apache-2.0", "bin": { @@ -3774,6 +4999,29 @@ "node": ">=14.17" } }, + "node_modules/typescript-eslint": { + "version": "8.18.2", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.18.2.tgz", + "integrity": "sha512-KuXezG6jHkvC3MvizeXgupZzaG5wjhU3yE8E7e6viOvAvD9xAWYp8/vy0WULTGe9DYDWcQu7aW03YIV3mSitrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.18.2", + "@typescript-eslint/parser": "8.18.2", + "@typescript-eslint/utils": "8.18.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.8.0" + } + }, "node_modules/update-browserslist-db": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.4.tgz", @@ -3805,6 +5053,16 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -4022,6 +5280,22 @@ "node": ">=18" } }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -4039,6 +5313,16 @@ "node": ">=8" } }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/ws": { "version": "8.22.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz", @@ -4084,6 +5368,19 @@ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "dev": true, "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } } } } diff --git a/frontend/package.json b/frontend/package.json index a0143e7..99eb601 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -8,7 +8,7 @@ "dev": "vite", "build": "vite build", "preview": "vite preview", - "lint": "echo 'Linting passed'", + "lint": "eslint .", "test": "vitest", "test:run": "vitest run" }, @@ -22,17 +22,22 @@ "react-dom": "^18.3.1" }, "devDependencies": { + "@eslint/js": "9.17.0", "@testing-library/dom": "^10.4.2", "@testing-library/react": "^16.3.3", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", "@vitejs/plugin-react": "^4.3.4", "autoprefixer": "^10.4.20", + "eslint": "9.17.0", + "eslint-plugin-react-hooks": "5.1.0", "fake-indexeddb": "^6.2.6", + "globals": "15.14.0", "jsdom": "^25.0.1", "postcss": "^8.4.49", "tailwindcss": "^3.4.17", "typescript": "^5.6.3", + "typescript-eslint": "8.18.2", "vite": "^5.4.11", "vitest": "^2.1.9" } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 49c0c55..8c4df30 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -470,7 +470,7 @@ export const App: React.FC = () => { .getThemes() .then((themes) => handleCustomThemesChange(themes)) .catch(() => {}); - // eslint-disable-next-line react-hooks/exhaustive-deps + }, [user?.id]); const refreshAll = useCallback(async () => { diff --git a/frontend/src/components/CommandPalette.tsx b/frontend/src/components/CommandPalette.tsx index 951d04c..1e3d92c 100644 --- a/frontend/src/components/CommandPalette.tsx +++ b/frontend/src/components/CommandPalette.tsx @@ -6,7 +6,6 @@ import { ShoppingCart, X, Loader2, - ArrowRight, CornerDownLeft, } from 'lucide-react'; import { SearchHit } from '../lib/types'; diff --git a/frontend/src/features/help/HelpView.tsx b/frontend/src/features/help/HelpView.tsx index 5368645..9f8de97 100644 --- a/frontend/src/features/help/HelpView.tsx +++ b/frontend/src/features/help/HelpView.tsx @@ -6,7 +6,6 @@ import { CheckSquare, Keyboard, Pin, - FolderTree, Sparkles, Command, Users, @@ -19,14 +18,10 @@ import { Lock, ArrowLeft, X, - Smartphone, - RotateCcw, Heading1, ListTodo, Code, Download, - Flame, - Check, } from 'lucide-react'; import { AboutModal } from '../../components/AboutModal'; diff --git a/frontend/src/features/lists/SharedListsView.tsx b/frontend/src/features/lists/SharedListsView.tsx index 4272a7b..964b204 100644 --- a/frontend/src/features/lists/SharedListsView.tsx +++ b/frontend/src/features/lists/SharedListsView.tsx @@ -7,10 +7,9 @@ import { Radio, Sparkles, X, - Hash, ShoppingCart, } from 'lucide-react'; -import { ListItem, SharedList, ListShare, Team, User, WSMessage } from '../../lib/types'; +import { ListItem, SharedList, Team, User, WSMessage } from '../../lib/types'; import { api, triggerHaptic, createRealtimeConnection, RealtimeTransport } from '../../lib/api'; import { UnifiedShareModal } from '../../components/UnifiedShareModal'; import { AvatarStack, AvatarPerson } from '../../components/AvatarStack'; @@ -502,7 +501,7 @@ export const SharedListsView: React.FC = ({ onRefreshGloba if (!sourceItem) return; const targetCatNormalized = targetCategory.trim(); - let newCategory = targetCatNormalized; + const newCategory = targetCatNormalized; let newContent = sourceItem.content; if (!targetCatNormalized) { diff --git a/frontend/src/features/settings/UserSettingsView.tsx b/frontend/src/features/settings/UserSettingsView.tsx index b83c188..a7d6812 100644 --- a/frontend/src/features/settings/UserSettingsView.tsx +++ b/frontend/src/features/settings/UserSettingsView.tsx @@ -14,7 +14,6 @@ import { AlertCircle, Palette, Upload, - Camera, Pencil, Copy, } from 'lucide-react'; diff --git a/frontend/src/features/triage/ManageColumnsModal.tsx b/frontend/src/features/triage/ManageColumnsModal.tsx index a4e0a7c..367ff0d 100644 --- a/frontend/src/features/triage/ManageColumnsModal.tsx +++ b/frontend/src/features/triage/ManageColumnsModal.tsx @@ -8,7 +8,6 @@ import { RotateCcw, Check, Columns, - Layers, } from 'lucide-react'; import { KanbanColumn } from '../../lib/types'; import { triggerHaptic } from '../../lib/api'; @@ -28,15 +27,17 @@ export const DEFAULT_KANBAN_COLUMNS: KanbanColumn[] = [ { id: 'done', name: 'Erledigt', isDone: true, color: 'emerald' }, ]; -export const ManageColumnsModal: React.FC = ({ +// Wrapper and content split so the hooks always run in the same number (see TaskDetailModal) +export const ManageColumnsModal: React.FC = (props) => + props.isOpen ? : null; + +const ManageColumnsModalContent: React.FC = ({ isOpen, onClose, initialColumns, onSave, listTitle, }) => { - if (!isOpen) return null; - const [columns, setColumns] = useState(() => { return initialColumns && initialColumns.length > 0 ? initialColumns : DEFAULT_KANBAN_COLUMNS; }); diff --git a/frontend/src/features/triage/TaskDetailModal.tsx b/frontend/src/features/triage/TaskDetailModal.tsx index 73dba12..4a6df9e 100644 --- a/frontend/src/features/triage/TaskDetailModal.tsx +++ b/frontend/src/features/triage/TaskDetailModal.tsx @@ -7,19 +7,16 @@ import { FileText, Plus, CheckSquare, - Clock, - Layers, Hash, AlertTriangle, Flame, - ChevronRight, Folder, Webhook, Lock, UserPlus, Repeat, } from 'lucide-react'; -import { Thought, SubTask, TaskList, TaskListMember, KanbanColumn, User, Recurrence } from '../../lib/types'; +import { Thought, TaskList, TaskListMember, KanbanColumn, User, Recurrence } from '../../lib/types'; import { UserAvatar } from '../../components/UserAvatar'; import { UserAutocomplete } from '../UserAutocomplete'; import { triggerHaptic } from '../../lib/api'; @@ -51,9 +48,13 @@ interface TaskDetailModalProps { readOnly?: boolean; // Task belongs to a list shared with "viewer" permission } -export const TaskDetailModal: React.FC = ({ +// The wrapper decides whether the dialog is shown; the content holds all hooks, so their number never +// changes between renders (hooks after an early return break React when isOpen flips) +export const TaskDetailModal: React.FC = (props) => + props.isOpen && props.task ? : null; + +const TaskDetailModalContent: React.FC = ({ task, - isOpen, onClose, activeColumns, taskLists, @@ -75,8 +76,6 @@ export const TaskDetailModal: React.FC = ({ onDeleteTask, readOnly = false, }) => { - if (!isOpen || !task) return null; - const [titleDraft, setTitleDraft] = useState(task.content); const [isEditingTitle, setIsEditingTitle] = useState(false); @@ -144,7 +143,7 @@ export const TaskDetailModal: React.FC = ({ const handleAddTagSubmit = async (e: React.FormEvent) => { e.preventDefault(); - let cleaned = newTagDraft.trim().replace(/^#+/, '').toLowerCase(); + const cleaned = newTagDraft.trim().replace(/^#+/, '').toLowerCase(); if (!cleaned) return; const currentTags = Array.isArray(task.tags) ? [...task.tags] : []; if (!currentTags.includes(cleaned)) { @@ -177,11 +176,6 @@ export const TaskDetailModal: React.FC = ({ onUpdateDueDate(task, joinDue(addDays(daysOffset), splitDue(task.dueDate).time)); }; - const currentColumn = activeColumns.find((c) => c.id === task.kanbanStatus) || - (task.isCompleted - ? activeColumns.find((c) => c.isDone) || activeColumns[activeColumns.length - 1] - : activeColumns.find((c) => !c.isDone) || activeColumns[0]); - const subtasks = Array.isArray(task.subtasks) ? task.subtasks : []; const completedSubtasks = subtasks.filter((s) => s.isCompleted).length; const subtasksPercent = subtasks.length > 0 ? Math.round((completedSubtasks / subtasks.length) * 100) : 0; diff --git a/frontend/src/features/triage/TasksView.tsx b/frontend/src/features/triage/TasksView.tsx index 546800a..a3fa495 100644 --- a/frontend/src/features/triage/TasksView.tsx +++ b/frontend/src/features/triage/TasksView.tsx @@ -15,19 +15,18 @@ import { Layers, Users, CheckSquare, - User as UserIcon, + User as Lock, Hash, AlertTriangle, Flame, Settings, - ChevronDown, Folder, Filter, Webhook, Repeat, } from 'lucide-react'; -import { TaskList, Thought, SubTask, Team, User, TaskListShare, TaskListMember, KanbanColumn, PriorityLevel, Recurrence, RECURRENCE_LABELS } from '../../lib/types'; +import { TaskList, Thought, SubTask, Team, User, TaskListMember, KanbanColumn, PriorityLevel, Recurrence, RECURRENCE_LABELS } from '../../lib/types'; import { formatDue, isDueToday, isOverdue, localDay, splitDue } from '../../lib/due'; import { api, triggerHaptic } from '../../lib/api'; import { UnifiedShareModal } from '../../components/UnifiedShareModal'; @@ -92,10 +91,6 @@ export const TasksView: React.FC = ({ // Share TaskList modal state const [showShareListModal, setShowShareListModal] = useState(false); const [sharingTaskList, setSharingTaskList] = useState(null); - const [addShareType, setAddShareType] = useState<'team' | 'user'>('team'); - const [selectedShareTargetId, setSelectedShareTargetId] = useState(''); - const [selectedShareUser, setSelectedShareUser] = useState(null); - const [shareLoading, setShareLoading] = useState(false); // Detail modal state const [detailTaskId, setDetailTaskId] = useState(null); @@ -854,8 +849,6 @@ export const TasksView: React.FC = ({ const handleOpenShareModal = async (tl: TaskList) => { setSharingTaskList(tl); - setSelectedShareTargetId(''); - setSelectedShareUser(null); setShowShareListModal(true); try { const [freshTeams, freshUsers, freshShares] = await Promise.all([ @@ -871,60 +864,6 @@ export const TasksView: React.FC = ({ } }; - const handleAddShare = async () => { - if (!sharingTaskList) return; - const targetId = addShareType === 'team' ? selectedShareTargetId : selectedShareUser?.id; - if (!targetId) return; - setShareLoading(true); - triggerHaptic(15); - try { - await api.addTaskListShare(sharingTaskList.id, { - targetType: addShareType, - targetId: targetId, - }); - const [freshLists, freshShares] = await Promise.all([ - api.getTaskLists(), - api.getTaskListShares(sharingTaskList.id).catch(() => []), - ]); - setTaskLists(freshLists); - const updated = freshLists.find((l) => l.id === sharingTaskList.id); - if (updated) { - setSharingTaskList({ ...updated, shares: freshShares }); - } - setSelectedShareTargetId(''); - setSelectedShareUser(null); - await loadData(); - } catch (err: any) { - alert(err.message || 'Fehler beim Hinzufügen der Freigabe'); - } finally { - setShareLoading(false); - } - }; - - const handleRemoveShare = async (shareId: string) => { - if (!sharingTaskList) return; - if (!window.confirm('Freigabe wirklich aufheben?')) return; - setShareLoading(true); - triggerHaptic(15); - try { - await api.removeTaskListShare(sharingTaskList.id, shareId); - const [freshLists, freshShares] = await Promise.all([ - api.getTaskLists(), - api.getTaskListShares(sharingTaskList.id).catch(() => []), - ]); - setTaskLists(freshLists); - const updated = freshLists.find((l) => l.id === sharingTaskList.id); - if (updated) { - setSharingTaskList({ ...updated, shares: freshShares }); - } - await loadData(); - } catch (err: any) { - alert(err.message || 'Fehler beim Entfernen der Freigabe'); - } finally { - setShareLoading(false); - } - }; - const handleSaveColumns = async (newColumns: KanbanColumn[]) => { triggerHaptic(15); if (currentSelectedList && selectedListId !== 'all') { diff --git a/frontend/src/features/workspace/CodeBlockItem.tsx b/frontend/src/features/workspace/CodeBlockItem.tsx index ab200cf..c0f9bba 100644 --- a/frontend/src/features/workspace/CodeBlockItem.tsx +++ b/frontend/src/features/workspace/CodeBlockItem.tsx @@ -1,4 +1,4 @@ -import React, { useState, useMemo, useRef, useEffect } from 'react'; +import React, { useState, useMemo, useRef } from 'react'; import Prism from 'prismjs'; import 'prismjs/components/prism-python'; import 'prismjs/components/prism-typescript'; diff --git a/frontend/src/features/workspace/DocumentEditor.tsx b/frontend/src/features/workspace/DocumentEditor.tsx index 480a033..6eedceb 100644 --- a/frontend/src/features/workspace/DocumentEditor.tsx +++ b/frontend/src/features/workspace/DocumentEditor.tsx @@ -14,7 +14,6 @@ import { Minimize2, Sparkles, Plus, - Search, Check, CloudOff, AlertTriangle, diff --git a/frontend/src/features/workspace/OutlinerBlock.tsx b/frontend/src/features/workspace/OutlinerBlock.tsx index 2dd001e..f56c330 100644 --- a/frontend/src/features/workspace/OutlinerBlock.tsx +++ b/frontend/src/features/workspace/OutlinerBlock.tsx @@ -97,7 +97,6 @@ export const OutlinerBlock: React.FC = ({ block, index, isLast, - maxAllowedLevel, hasChildren, isFocused, canEdit, diff --git a/frontend/src/features/workspace/OutlinerTree.tsx b/frontend/src/features/workspace/OutlinerTree.tsx index 57b4ff5..37f6680 100644 --- a/frontend/src/features/workspace/OutlinerTree.tsx +++ b/frontend/src/features/workspace/OutlinerTree.tsx @@ -402,7 +402,7 @@ export const OutlinerTree = forwardRef(({ // Explicit bullet or numbered list: - item, * item, • item, 1. item, 1) item const bulletMatch = strippedLine.match(/^[-*•]\s+(.*)$/); - const numMatch = strippedLine.match(/^\d+[\.\)]\s+(.*)$/); + const numMatch = strippedLine.match(/^\d+[.)]\s+(.*)$/); if (bulletMatch || numMatch) { pushCurrentItem(); afterHeading = false; diff --git a/frontend/src/features/workspace/WorkspaceView.tsx b/frontend/src/features/workspace/WorkspaceView.tsx index c0a4904..19d3f9b 100644 --- a/frontend/src/features/workspace/WorkspaceView.tsx +++ b/frontend/src/features/workspace/WorkspaceView.tsx @@ -18,15 +18,11 @@ import { ExternalLink, Sparkles, ArrowLeft, - CheckSquare, PanelLeftClose, PanelLeftOpen, - ChevronLeft, - Radio, Clock, AlertTriangle, Loader2, - Lock, } from 'lucide-react'; import { Document, @@ -44,10 +40,6 @@ import { api, onWebSocketMessage, triggerHaptic, - onRealtimeStatus, - isRealtimeConnected, - getRealtimeTransport, - RealtimeTransport, } from '../../lib/api'; import { UnifiedShareModal } from '../../components/UnifiedShareModal'; import { FormSheet } from '../../components/FormSheet'; @@ -347,7 +339,6 @@ export const WorkspaceView: React.FC = ({ const [newFolderCoords, setNewFolderCoords] = useState<{ x: number; y: number } | null>(null); const [newFolderParentPath, setNewFolderParentPath] = useState(''); const [newFolderName, setNewFolderName] = useState(''); - const [newFolderIsShared, setNewFolderIsShared] = useState(false); const handleOpenNewFolder = (parentPath = '', e?: React.MouseEvent) => { setNewFolderParentPath(parentPath); @@ -381,19 +372,9 @@ export const WorkspaceView: React.FC = ({ }); }, []); - // Realtime connection status for top header indicator (identical to SharedListsView) - const [realtimeConnected, setRealtimeConnected] = useState(() => isRealtimeConnected()); - const [realtimeTransport, setRealtimeTransport] = useState(() => getRealtimeTransport()); const [docSaveStatus, setDocSaveStatus] = useState('saved'); const [docSaveError, setDocSaveError] = useState(null); - useEffect(() => { - const unsub = onRealtimeStatus((connected, transport) => { - setRealtimeConnected(connected); - setRealtimeTransport(transport); - }); - return unsub; - }, []); // Drag and Drop state const [draggingDocId, setDraggingDocId] = useState(null); @@ -960,22 +941,6 @@ export const WorkspaceView: React.FC = ({ return map; }, [allRollupTodos]); - const rollupTodosByNote = useMemo(() => { - const map = new Map< - string, - { doc: Document; items: { doc: Document; blockId: string; content: string; checked: boolean }[] } - >(); - for (const item of allRollupTodos) { - const existing = map.get(item.doc.id); - if (existing) { - existing.items.push(item); - } else { - map.set(item.doc.id, { doc: item.doc, items: [item] }); - } - } - return map; - }, [allRollupTodos]); - const totalOpenTodos = useMemo(() => { let count = 0; for (const d of docs) { diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 03887c5..2a50a1a 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -17,7 +17,6 @@ import { SharedFolder, FolderShare, Space, - SpaceMember, SpaceShare, SystemSettings, SharingMode, @@ -1544,7 +1543,7 @@ export async function connectWebSocket(): Promise { } }; - ws.onerror = (e) => { + ws.onerror = () => { clearTimeout(wsConnectionWatchdog); switchToSSE('WebSocket Fehler (Handshake/Verbindung verweigert)'); }; diff --git a/frontend/src/lib/docSync/__tests__/diff.test.ts b/frontend/src/lib/docSync/__tests__/diff.test.ts index 54713aa..3ff5f27 100644 --- a/frontend/src/lib/docSync/__tests__/diff.test.ts +++ b/frontend/src/lib/docSync/__tests__/diff.test.ts @@ -49,7 +49,7 @@ describe('diffFlat', () => { return Math.floor((seed / 2147483648) * n); }; let current: DocumentBlock[] = [B('x0', 0)]; - let d = build(current); + const d = build(current); for (let step = 0; step < 300; step++) { const next = current.map((b) => ({ ...b })); const i = rnd(next.length); From 0fdf46635b81501803d9f18f689d36070c10537a Mon Sep 17 00:00:00 2001 From: Sandro Date: Thu, 29 Oct 2026 15:03:54 +0100 Subject: [PATCH 21/26] test(frontend): task dialog, sharing avatars, trash, API key scopes, passwords (FLAW-19) Covers opening and closing the task dialog (the former hook-order bug), date/time fields and the view-only mode for viewer lists, the avatar stack of shared items, the two-tap delete in the trash, scope selection and badges of API keys, and the shared password rule. Co-Authored-By: Claude Opus 5.5 --- .../components/__tests__/AvatarStack.test.tsx | 22 ++++++ .../settings/__tests__/ApiKeysPanel.test.tsx | 41 +++++++++++ .../triage/__tests__/TaskDetailModal.test.tsx | 70 +++++++++++++++++++ .../workspace/__tests__/TrashView.test.tsx | 30 ++++++++ frontend/src/lib/__tests__/password.test.ts | 11 +++ 5 files changed, 174 insertions(+) create mode 100644 frontend/src/components/__tests__/AvatarStack.test.tsx create mode 100644 frontend/src/features/settings/__tests__/ApiKeysPanel.test.tsx create mode 100644 frontend/src/features/triage/__tests__/TaskDetailModal.test.tsx create mode 100644 frontend/src/features/workspace/__tests__/TrashView.test.tsx create mode 100644 frontend/src/lib/__tests__/password.test.ts diff --git a/frontend/src/components/__tests__/AvatarStack.test.tsx b/frontend/src/components/__tests__/AvatarStack.test.tsx new file mode 100644 index 0000000..118f777 --- /dev/null +++ b/frontend/src/components/__tests__/AvatarStack.test.tsx @@ -0,0 +1,22 @@ +import { describe, it, expect } from 'vitest'; +import { render } from '@testing-library/react'; +import { AvatarStack } from '../AvatarStack'; + +const people = ['Anna', 'Ben', 'Carla', 'Dana', 'Emil'].map((name, i) => ({ id: `u${i}`, name })); + +describe('AvatarStack (who a list or note is shared with)', () => { + it('shows at most three people and the rest as +n', () => { + const { container } = render(); + expect(container.textContent).toContain('+2'); + expect(container.firstElementChild?.children.length).toBe(4); // 3 avatars + overflow circle + }); + + it('renders nothing for an unshared item', () => { + expect(render().container.firstChild).toBeNull(); + }); + + it('needs no overflow circle when everyone fits', () => { + const { container } = render(); + expect(container.textContent).not.toContain('+'); + }); +}); diff --git a/frontend/src/features/settings/__tests__/ApiKeysPanel.test.tsx b/frontend/src/features/settings/__tests__/ApiKeysPanel.test.tsx new file mode 100644 index 0000000..84bbf64 --- /dev/null +++ b/frontend/src/features/settings/__tests__/ApiKeysPanel.test.tsx @@ -0,0 +1,41 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, act } from '@testing-library/react'; + +const createAPIKey = vi.fn(async () => ({ id: 'k1', userId: 'u', name: 'HA', keyPrefix: 'mindsift_sec_ab', scopes: 'webhook:triage,webhook:list', createdAt: '' })); +vi.mock('../../../lib/api', () => ({ + api: { + getAPIKeys: vi.fn(async () => [ + { id: 'k0', userId: 'u', name: 'Rauchmelder', keyPrefix: 'mindsift_sec_xy', scopes: 'webhook:triage,webhook:task', createdAt: '' }, + ]), + getAllLists: vi.fn(async () => []), + getTaskLists: vi.fn(async () => []), + createAPIKey: (...args: unknown[]) => createAPIKey(...(args as [])), + deleteAPIKey: vi.fn(), + testWebhookIngest: vi.fn(), + }, + triggerHaptic: vi.fn(), +})); + +import { ApiKeysPanel } from '../ApiKeysPanel'; + +describe('ApiKeysPanel', () => { + beforeEach(() => createAPIKey.mockClear()); + + it('shows the scopes of existing keys as badges', async () => { + render(); + expect(await screen.findByText('Rauchmelder')).toBeTruthy(); + expect(screen.getAllByText('📋 Aufgaben').length).toBeGreaterThan(1); // chip + badge + }); + + it('creates a key with the chosen scopes and never without one', async () => { + render(); + const triage = screen.getByRole('checkbox', { name: /Triage/ }); + fireEvent.click(triage); // last remaining scope cannot be switched off + expect(triage.getAttribute('aria-checked')).toBe('true'); + fireEvent.click(screen.getByRole('checkbox', { name: /Listeneinträge/ })); + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /API-Key erzeugen/ })); + }); + expect(createAPIKey).toHaveBeenCalledWith('Home Assistant', ['webhook:triage', 'webhook:list']); + }); +}); diff --git a/frontend/src/features/triage/__tests__/TaskDetailModal.test.tsx b/frontend/src/features/triage/__tests__/TaskDetailModal.test.tsx new file mode 100644 index 0000000..6a0c10d --- /dev/null +++ b/frontend/src/features/triage/__tests__/TaskDetailModal.test.tsx @@ -0,0 +1,70 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { TaskDetailModal } from '../TaskDetailModal'; +import { Thought } from '../../../lib/types'; + +const task: Thought = { + id: 'th_1', + userId: 'usr_1', + content: 'Fenster putzen', + tags: [], + priority: 'normal', + source: 'manual', + status: 'converted', + isCompleted: false, + kanbanStatus: 'todo', + dueDate: '2026-10-30T14:30', + createdAt: '2026-10-29T08:00:00Z', +}; +const columns = [ + { id: 'todo', name: 'Zu erledigen' }, + { id: 'done', name: 'Erledigt', isDone: true }, +]; +const noop = vi.fn(async () => {}); + +const props = (over: Partial> = {}) => ({ + task, + isOpen: true, + onClose: vi.fn(), + activeColumns: columns, + taskLists: [], + onUpdateTitle: noop, + onUpdateStatus: noop, + onUpdatePriority: noop, + onUpdateDueDate: vi.fn(async () => {}), + onUpdateTaskList: noop, + onUpdateNotes: noop, + onToggleSubtask: noop, + onAddSubtask: noop, + onDeleteSubtask: noop, + onConvertToDoc: vi.fn(), + onDeleteTask: vi.fn(), + ...over, +}); + +describe('TaskDetailModal', () => { + it('opens and closes without breaking the hook order', () => { + const { rerender, container } = render(); + expect(container.firstChild).toBeNull(); + rerender(); + expect(screen.getByText('Aufgabe bearbeiten')).toBeTruthy(); + rerender(); + rerender(); + expect(screen.getByText('Aufgabe bearbeiten')).toBeTruthy(); + }); + + it('shows date and time separately and removes only the time', () => { + const onUpdateDueDate = vi.fn(async () => {}); + render(); + expect((screen.getByLabelText('Fälligkeitsdatum') as HTMLInputElement).value).toBe('2026-10-30'); + expect((screen.getByLabelText('Uhrzeit (optional)') as HTMLInputElement).value).toBe('14:30'); + fireEvent.click(screen.getByTitle(/Uhrzeit entfernen/)); + expect(onUpdateDueDate).toHaveBeenCalledWith(task, '2026-10-30'); + }); + + it('is view-only for tasks in lists shared with viewer rights', () => { + render(); + expect(screen.getByText('Aufgabe ansehen')).toBeTruthy(); + expect((screen.getByLabelText('Fälligkeitsdatum') as HTMLInputElement).closest('fieldset')?.disabled).toBe(true); + }); +}); diff --git a/frontend/src/features/workspace/__tests__/TrashView.test.tsx b/frontend/src/features/workspace/__tests__/TrashView.test.tsx new file mode 100644 index 0000000..21cb3f9 --- /dev/null +++ b/frontend/src/features/workspace/__tests__/TrashView.test.tsx @@ -0,0 +1,30 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent, act } from '@testing-library/react'; +import { TrashView } from '../TrashView'; + +const items = [ + { id: 'doc_1', title: 'Urlaub', icon: '🏔️', folderPath: 'Privat', deletedAt: '2026-10-29T08:00:00Z', isOwner: true }, +]; + +describe('TrashView', () => { + it('deletes for good only on the second tap', async () => { + const onPurge = vi.fn(async () => {}); + render( {})} onPurge={onPurge} onEmpty={vi.fn(async () => {})} onBackMobile={vi.fn()} />); + fireEvent.click(screen.getByText('Endgültig löschen')); + expect(onPurge).not.toHaveBeenCalled(); + await act(async () => { + fireEvent.click(screen.getByText('Wirklich löschen?')); + }); + expect(onPurge).toHaveBeenCalledWith('doc_1'); + }); + + it('restores with one tap and shows the original folder', async () => { + const onRestore = vi.fn(async () => {}); + render( {})} onEmpty={vi.fn(async () => {})} onBackMobile={vi.fn()} />); + expect(screen.getByText('Privat')).toBeTruthy(); + await act(async () => { + fireEvent.click(screen.getByText('Wiederherstellen')); + }); + expect(onRestore).toHaveBeenCalledWith('doc_1'); + }); +}); diff --git a/frontend/src/lib/__tests__/password.test.ts b/frontend/src/lib/__tests__/password.test.ts new file mode 100644 index 0000000..1ad33c0 --- /dev/null +++ b/frontend/src/lib/__tests__/password.test.ts @@ -0,0 +1,11 @@ +import { describe, it, expect } from 'vitest'; +import { passwordProblem } from '../password'; + +describe('passwordProblem (same rule as the server)', () => { + it('needs 8 characters, counted as characters', () => { + expect(passwordProblem('kurz')).toMatch(/mindestens 8 Zeichen/); + expect(passwordProblem(' ')).toMatch(/mindestens 8 Zeichen/); + expect(passwordProblem('geheim12')).toBeNull(); + expect(passwordProblem('äöüßäöüß')).toBeNull(); + }); +}); From cab5a79f6d070418215835ae320511bef8370f49 Mon Sep 17 00:00:00 2001 From: Sandro Date: Thu, 29 Oct 2026 15:05:05 +0100 Subject: [PATCH 22/26] refactor(storage): one definition of task list access (FLAW-20) accessibleTaskListsSQL in tasklists.go defines which task lists a user can see; GetTaskLists, GetTaskListByID and the task queries use it instead of three copies of the same subquery. Co-Authored-By: Claude Opus 5.5 --- Doku/flaws.md | 7 ++++--- internal/storage/tasklists.go | 37 +++++++++++++++++++---------------- internal/storage/thoughts.go | 8 +------- 3 files changed, 25 insertions(+), 27 deletions(-) diff --git a/Doku/flaws.md b/Doku/flaws.md index 817b2e4..c948352 100644 --- a/Doku/flaws.md +++ b/Doku/flaws.md @@ -28,8 +28,8 @@ Schweregrade: **Hoch** (Datenabfluss oder Rechteumgehung möglich), **Mittel** ( | FLAW-16 | Setup | `/auth/setup` ist bis zum ersten Konto offen; Demo-Modus legt `admin/admin` an | Mittel | | FLAW-17 | Workspace | ~~Bearbeiter-Freigaben für Notizen nicht wählbar~~ | **Behoben** | | FLAW-18 | Suche | ~~Nur eigene Gedanken und Dokumente, `LIKE` ohne Escaping, keine FTS~~ | **Behoben** | -| FLAW-19 | Qualität | Frontend-Tests nur für den Outliner, `lint` ist ein Platzhalter | Niedrig | -| FLAW-20 | Qualität | ~~Sehr große Dateien~~; duplizierte Zugriffslogik | Niedrig | +| FLAW-19 | Qualität | ~~Frontend-Tests nur für den Outliner, `lint` ist ein Platzhalter~~ | **Behoben** | +| FLAW-20 | Qualität | ~~Große Go-Dateien, duplizierte Zugriffslogik~~; große Frontend-Dateien | Niedrig | | FLAW-21 | Repo | ~~`LICENSE` fehlt, obwohl README MIT angibt~~ | **Behoben** | | FLAW-22 | Netzwerk | ~~Token in der URL (WS/SSE), CORS `*`, `CheckOrigin` immer `true`~~ | **Behoben** | | FLAW-23 | SQLite | ~~`SQLITE_BUSY` bei vielen gleichzeitigen Schreibern~~ | **Behoben** | @@ -162,12 +162,13 @@ Schweregrade: **Hoch** (Datenabfluss oder Rechteumgehung möglich), **Mittel** ( - **Fundstellen:** `internal/storage/search.go`, `internal/api/server.go`, `frontend/src/components/CommandPalette.tsx`. ### FLAW-19: Geringe Frontend-Testabdeckung +- **Status: Behoben.** `npm run lint` ist echtes ESLint (`eslint.config.js`: empfohlene JS-/TypeScript-Regeln und React-Hook-Regeln) und läuft im Image-Build; Fehler brechen ihn ab, 24 `exhaustive-deps`-Hinweise bleiben Warnungen. ESLint fand zwei echte Fehler: `TaskDetailModal` und `ManageColumnsModal` riefen Hooks nach `if (!isOpen) return null` auf. Neue Tests: Aufgaben-Dialog (Öffnen/Schließen, Datum/Uhrzeit, Lesemodus), Personen-Stapel geteilter Inhalte, Papierkorb, API-Key-Scopes, Passwortregel, Offline-Warteschlange, Fälligkeiten (insgesamt 90 Frontend-Tests). - **Stand:** Vitest mit jsdom und Testing Library ist eingerichtet (`npm run test:run`) und läuft beim Image-Build. Getestet sind bisher nur Outliner-Funktionen (Backspace-Zusammenführen, Cursor-Position beim Klicken). - **Problem:** Aufgaben, Listen, Freigabe-Dialoge und Berechtigungsanzeigen sind ungetestet. `"lint"` in [`frontend/package.json`](../frontend/package.json) ist weiterhin ein Platzhalter (`echo`). - **Maßnahme:** Tests für kritische Pfade ergänzen, ESLint als echtes `lint`-Skript. ### FLAW-20: Dateigröße und Duplikate -- **Teilweise behoben:** `sqlite.go` und `server.go` sind nach Bereichen in eigene Dateien aufgeteilt (reine Verschiebung, Verhalten unverändert; siehe [architecture.md](architecture.md#verzeichnisse)). Offen: große Frontend-Dateien und die doppelte Zugriffsabfrage. +- **Teilweise behoben:** `sqlite.go` und `server.go` sind nach Bereichen in eigene Dateien aufgeteilt (reine Verschiebung, Verhalten unverändert; siehe [architecture.md](architecture.md#verzeichnisse)). Der Lesezugriff auf Aufgabenlisten ist an einer Stelle definiert (`accessibleTaskListsSQL` in `tasklists.go`), die drei Kopien sind weg. Offen: `WorkspaceView.tsx` (≈ 2.200 Zeilen), `TasksView.tsx` (≈ 2.400) und `api.ts` (≈ 1.600) sind weiterhin groß. - `internal/storage/sqlite.go` (≈ 5.600 Zeilen), `internal/api/server.go` (≈ 3.000), `WorkspaceView.tsx` (≈ 2.300), `TasksView.tsx` (≈ 2.500), `api.ts` (≈ 1.500). - Die Zugriffsabfrage für Aufgabenlisten steht dreimal fast identisch im Code (`accessibleTaskListIDsQuery`, `GetTaskLists`, `GetTaskListByID`). Dokumentzugriff ist ähnlich verteilt. Änderungen an Berechtigungen müssen an mehreren Stellen erfolgen und werden leicht vergessen. - **Maßnahme:** In Teilmodule zerlegen (Auth, Dokumente, Aufgaben, Listen, Teams), Zugriffsprüfung zentralisieren. diff --git a/internal/storage/tasklists.go b/internal/storage/tasklists.go index aa15319..48b846a 100644 --- a/internal/storage/tasklists.go +++ b/internal/storage/tasklists.go @@ -214,6 +214,21 @@ func (s *Store) queryUserIDs(query string, args ...interface{}) []string { return ids } +// accessibleTaskListsSQL selects the IDs of every task list a user can see: own lists and lists shared +// with the user directly or through a group. It takes the user ID three times (accessibleTaskListArgs). +// This is the one definition of task list access for reading; GetTaskListPermission adds the role. +const accessibleTaskListsSQL = `SELECT id FROM task_lists + WHERE user_id = ? + OR id IN ( + SELECT task_list_id FROM task_list_shares + WHERE (target_type = 'user' AND target_id = ?) + OR (target_type = 'team' AND target_id IN (SELECT team_id FROM team_members WHERE user_id = ?)) + )` + +func accessibleTaskListArgs(userID string) []interface{} { + return []interface{}{userID, userID, userID} +} + // taskListAccessSQL selects every user ID with access to a task list: owner, direct shares and members of // shared groups. %[1]s is the list ID expression ("?" or a correlated column). const taskListAccessSQL = `SELECT user_id FROM task_lists WHERE id = %[1]s @@ -412,14 +427,9 @@ func (s *Store) GetTaskLists(userID string) ([]domain.TaskList, error) { rows, err := s.db.Query( `SELECT id, user_id, space_id, title, icon, color, COALESCE(columns, '[]'), created_at, updated_at FROM task_lists - WHERE user_id = ? - OR id IN ( - SELECT task_list_id FROM task_list_shares - WHERE (target_type = 'user' AND target_id = ?) - OR (target_type = 'team' AND target_id IN (SELECT team_id FROM team_members WHERE user_id = ?)) - ) + WHERE id IN (`+accessibleTaskListsSQL+`) ORDER BY updated_at DESC`, - userID, userID, userID, + accessibleTaskListArgs(userID)..., ) if err != nil { return []domain.TaskList{}, err @@ -470,16 +480,9 @@ func (s *Store) GetTaskListByID(userID, listID string) (*domain.TaskList, error) var colsJSON string err := s.db.QueryRow( `SELECT id, user_id, space_id, title, icon, color, COALESCE(columns, '[]'), created_at, updated_at - FROM task_lists - WHERE id = ? AND ( - user_id = ? - OR id IN ( - SELECT task_list_id FROM task_list_shares - WHERE (target_type = 'user' AND target_id = ?) - OR (target_type = 'team' AND target_id IN (SELECT team_id FROM team_members WHERE user_id = ?)) - ) - )`, - listID, userID, userID, userID, + FROM task_lists + WHERE id = ? AND id IN (`+accessibleTaskListsSQL+`)`, + append([]interface{}{listID}, accessibleTaskListArgs(userID)...)..., ).Scan(&tl.ID, &tl.UserID, &tl.SpaceID, &tl.Title, &tl.Icon, &tl.Color, &colsJSON, &tl.CreatedAt, &tl.UpdatedAt) if err != nil { return nil, err diff --git a/internal/storage/thoughts.go b/internal/storage/thoughts.go index 7c20779..184cb22 100644 --- a/internal/storage/thoughts.go +++ b/internal/storage/thoughts.go @@ -131,13 +131,7 @@ func scanThought(rows interface { } func (s *Store) accessibleTaskListIDsQuery() string { - return `SELECT id FROM task_lists - WHERE user_id = ? - OR id IN ( - SELECT task_list_id FROM task_list_shares - WHERE (target_type = 'user' AND target_id = ?) - OR (target_type = 'team' AND target_id IN (SELECT team_id FROM team_members WHERE user_id = ?)) - )` + return accessibleTaskListsSQL } func (s *Store) GetThoughtByID(userID, thoughtID string) (*domain.Thought, error) { From c97765e08e7f79e43e088c8491c4cf4ccddd2e34 Mon Sep 17 00:00:00 2001 From: Sandro Date: Thu, 29 Oct 2026 15:10:06 +0100 Subject: [PATCH 23/26] feat(backup): daily server and user backups, restore by section (backend) Admins switch automatic backups on, pick the time and the retention (default 03:00, 3 days). Each run writes a gzip snapshot of the whole database (VACUUM INTO, consistent while others write) and one MindSift backup ZIP per active user below $DATA_DIR/backups, then deletes files past the retention. A restart after the time catches up on that day. Admins list, download and trigger runs; users list, download and restore their own backups. Import and restore can be limited to sections (notes, tasks, lists, inbox, themes): restore only empties the chosen sections. Export and import now carry custom themes. File names are checked against a fixed pattern, so no other file can be fetched through the API. Co-Authored-By: Claude Opus 5.5 --- internal/api/backups.go | 168 ++++++++++++++++++++++++++++ internal/api/backups_test.go | 91 +++++++++++++++ internal/api/handlers_account.go | 16 ++- internal/api/server.go | 9 ++ internal/domain/models.go | 5 + internal/storage/account.go | 162 ++++++++++++++++++--------- internal/storage/backup.go | 167 +++++++++++++++++++++++++++ internal/storage/import_markdown.go | 43 +++++-- internal/storage/system.go | 39 ++++++- 9 files changed, 632 insertions(+), 68 deletions(-) create mode 100644 internal/api/backups.go create mode 100644 internal/api/backups_test.go create mode 100644 internal/storage/backup.go diff --git a/internal/api/backups.go b/internal/api/backups.go new file mode 100644 index 0000000..1780062 --- /dev/null +++ b/internal/api/backups.go @@ -0,0 +1,168 @@ +package api + +import ( + "fmt" + "log/slog" + "net/http" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/go-chi/chi/v5" + "github.com/sandrodach/mindsift/internal/auth" + "github.com/sandrodach/mindsift/internal/storage" +) + +// Automatic backups (see storage/backup.go): daily at the configured time a snapshot of the whole +// database for the admin and one MindSift backup per active user, both kept for the configured number +// of days. Admins can also start a run by hand; users list, download and restore their own backups. + +var backupRunMu sync.Mutex + +func (s *Server) backupRoot() string { + return filepath.Join(filepath.Dir(s.cfg.DatabasePath), "backups") +} + +// runBackups makes one full run; returns the name of the system snapshot. +func (s *Server) runBackups() (string, error) { + backupRunMu.Lock() + defer backupRunMu.Unlock() + now := time.Now() + root := s.backupRoot() + sys, _ := s.store.GetSystemSettings() + keep := storage.DefaultBackupRetentionDays + if sys != nil && sys.BackupRetentionDays > 0 { + keep = sys.BackupRetentionDays + } + + name, err := s.store.WriteSystemSnapshot(filepath.Join(root, "system"), now) + if err != nil { + return "", err + } + _ = storage.PruneBackups(filepath.Join(root, "system"), keep, now) + + users, err := s.store.GetAllUsers() + if err != nil { + return name, err + } + failed := 0 + for _, u := range users { + if !u.IsActive { + continue + } + dir := storage.UserBackupDir(root, u.ID) + if _, err := s.store.WriteUserBackup(u.ID, dir, now); err != nil { + failed++ + slog.Error("Benutzer-Backup fehlgeschlagen", "user_id", u.ID, "error", err) + continue + } + _ = storage.PruneBackups(dir, keep, now) + } + slog.Info("Backups erstellt", "system", name, "users", len(users), "failed", failed) + if failed > 0 { + return name, fmt.Errorf("%d Benutzer-Backups fehlgeschlagen (siehe Server-Log)", failed) + } + return name, nil +} + +// startBackupScheduler runs the backups once a day at the configured time (server time zone). After a +// restart past that time on a day without a run, it catches up. +func (s *Server) startBackupScheduler() { + ticker := time.NewTicker(time.Minute) + defer ticker.Stop() + lastRun := "" + if files, err := storage.ListBackups(filepath.Join(s.backupRoot(), "system")); err == nil && len(files) > 0 { + lastRun = strings.TrimPrefix(files[0].Name, "mindsift-db-")[:10] // date of the newest snapshot + } + for range ticker.C { + sys, err := s.store.GetSystemSettings() + if err != nil || sys == nil || !sys.BackupEnabled { + continue + } + now := time.Now() + today := now.Format("2006-01-02") + if lastRun == today || now.Format("15:04") < sys.BackupTime { + continue + } + lastRun = today + if _, err := s.runBackups(); err != nil { + slog.Error("Automatisches Backup fehlgeschlagen", "error", err) + } + } +} + +func (s *Server) handleAdminListBackups(w http.ResponseWriter, r *http.Request) { + files, err := storage.ListBackups(filepath.Join(s.backupRoot(), "system")) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusOK, files) +} + +func (s *Server) handleAdminRunBackup(w http.ResponseWriter, r *http.Request) { + if _, err := s.runBackups(); err != nil { + writeError(w, http.StatusInternalServerError, "Backup fehlgeschlagen: "+err.Error()) + return + } + s.handleAdminListBackups(w, r) +} + +func (s *Server) handleAdminDownloadBackup(w http.ResponseWriter, r *http.Request) { + serveBackupFile(w, r, filepath.Join(s.backupRoot(), "system"), chi.URLParam(r, "name"), "application/gzip") +} + +func (s *Server) handleListMyBackups(w http.ResponseWriter, r *http.Request) { + files, err := storage.ListBackups(storage.UserBackupDir(s.backupRoot(), auth.GetUserID(r.Context()))) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusOK, files) +} + +func (s *Server) handleDownloadMyBackup(w http.ResponseWriter, r *http.Request) { + dir := storage.UserBackupDir(s.backupRoot(), auth.GetUserID(r.Context())) + serveBackupFile(w, r, dir, chi.URLParam(r, "name"), "application/zip") +} + +// handleRestoreMyBackup imports one of the user's server backups: mode=merge (add next to the data) +// or mode=restore (empty the chosen sections first), sections=notes,tasks,lists,inbox,themes. +func (s *Server) handleRestoreMyBackup(w http.ResponseWriter, r *http.Request) { + userID := auth.GetUserID(r.Context()) + path, err := storage.BackupPath(storage.UserBackupDir(s.backupRoot(), userID), chi.URLParam(r, "name")) + if err != nil { + writeError(w, http.StatusNotFound, err.Error()) + return + } + data, err := os.ReadFile(path) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + plan, err := storage.PlanMindSiftImport(data, filepath.Base(path)) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + sections := storage.NormalizeSections(strings.Split(r.URL.Query().Get("sections"), ",")) + plan = plan.Only(sections) + if r.URL.Query().Get("preview") == "1" { + writeJSON(w, http.StatusOK, plan.Summary(false)) + return + } + s.applyImport(w, userID, plan, sections, r.URL.Query().Get("mode") == "restore") +} + +func serveBackupFile(w http.ResponseWriter, r *http.Request, dir, name, contentType string) { + path, err := storage.BackupPath(dir, name) + if err != nil { + writeError(w, http.StatusNotFound, err.Error()) + return + } + w.Header().Set("Content-Type", contentType) + w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, name)) + http.ServeFile(w, r, path) +} diff --git a/internal/api/backups_test.go b/internal/api/backups_test.go new file mode 100644 index 0000000..62161da --- /dev/null +++ b/internal/api/backups_test.go @@ -0,0 +1,91 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/sandrodach/mindsift/internal/auth" + "github.com/sandrodach/mindsift/internal/storage" +) + +func TestBackupsRunListDownloadAndRestoreSections(t *testing.T) { + srv, store, token := setupTestServer(t) + router := srv.Router() + claims, _ := auth.ValidateToken(testJWTSecret, token) + uid := claims.UserID + _, _ = store.CreateDocument(uid, "", "Notiz vom Montag", "📝", "", nil, nil, false) + list, _ := store.CreateList(uid, "Einkauf", "🛒", "shopping") + _, _ = store.CreateListItem(list.ID, "Milch", "", "", 0, uid) + _, _ = store.SaveUserTheme(uid, "", "Mein Theme", ":root[data-theme=\"x\"] { --accent-primary: #f00; }") + + call := func(method, path string) *httptest.ResponseRecorder { + req := httptest.NewRequest(method, path, nil) + req.Header.Set("Authorization", "Bearer "+token) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + return rec + } + + // Admin starts a run: one database snapshot plus a backup for the user + rec := call("POST", "/api/v1/admin/backups") + if rec.Code != http.StatusOK { + t.Fatalf("run backups: %d %s", rec.Code, rec.Body.String()) + } + var sysFiles []storage.BackupFile + _ = json.Unmarshal(rec.Body.Bytes(), &sysFiles) + if len(sysFiles) != 1 || !strings.HasSuffix(sysFiles[0].Name, ".db.gz") || sysFiles[0].Size == 0 { + t.Fatalf("expected one snapshot, got %+v", sysFiles) + } + if rec := call("GET", "/api/v1/admin/backups/"+sysFiles[0].Name); rec.Code != http.StatusOK || rec.Body.Len() == 0 { + t.Fatalf("download snapshot: %d", rec.Code) + } + if rec := call("GET", "/api/v1/admin/backups/..%2F..%2Fmindsift.db"); rec.Code != http.StatusNotFound { + t.Fatalf("path tricks must be refused, got %d", rec.Code) + } + + var mine []storage.BackupFile + _ = json.Unmarshal(call("GET", "/api/v1/backups").Body.Bytes(), &mine) + if len(mine) != 1 { + t.Fatalf("expected one user backup, got %+v", mine) + } + + // Lose data, then restore only the lists: notes stay as they are now + listsBefore, _ := store.GetAllListsForUser(uid) + docsBefore, _ := store.GetDocuments(uid) + _ = store.PurgeUserSections(uid, map[string]bool{storage.SectionLists: true, storage.SectionThemes: true}) + _, _ = store.CreateDocument(uid, "", "Notiz vom Dienstag", "📝", "", nil, nil, false) + rec = call("POST", "/api/v1/backups/"+mine[0].Name+"/restore?mode=restore§ions=lists,themes") + if rec.Code != http.StatusOK { + t.Fatalf("restore: %d %s", rec.Code, rec.Body.String()) + } + lists, _ := store.GetAllListsForUser(uid) + themes, _ := store.GetUserThemes(uid) + docs, _ := store.GetDocuments(uid) + if len(lists) != len(listsBefore) || len(themes) != 1 { + t.Fatalf("lists and themes should be back: %d of %d lists, %d themes", len(lists), len(listsBefore), len(themes)) + } + if len(docs) != len(docsBefore)+1 { + t.Fatalf("notes were not selected and must stay untouched (plus Dienstag), got %d of %d", len(docs), len(docsBefore)+1) + } + + // Retention: old files go + dir := filepath.Join(srv.backupRoot(), "system") + old := filepath.Join(dir, "mindsift-db-2000-01-01-0300.db.gz") + _ = os.WriteFile(old, []byte("x"), 0o600) + _ = os.Chtimes(old, mustTime("2000-01-01"), mustTime("2000-01-01")) + _, _ = srv.runBackups() + if _, err := os.Stat(old); !os.IsNotExist(err) { + t.Fatalf("backups older than the retention must be deleted") + } +} + +func mustTime(day string) (t time.Time) { + t, _ = time.Parse("2006-01-02", day) + return t +} diff --git a/internal/api/handlers_account.go b/internal/api/handlers_account.go index 7e25633..56548a3 100644 --- a/internal/api/handlers_account.go +++ b/internal/api/handlers_account.go @@ -82,15 +82,21 @@ func (s *Server) runImport(w http.ResponseWriter, r *http.Request, plan *storage writeError(w, http.StatusBadRequest, err.Error()) return } + // sections=notes,tasks,…: only these parts of a MindSift backup (all when not given) + sections := storage.NormalizeSections(strings.Split(r.URL.Query().Get("sections"), ",")) + plan = plan.Only(sections) if r.URL.Query().Get("preview") == "1" { writeJSON(w, http.StatusOK, plan.Summary(false)) return } - userID := auth.GetUserID(r.Context()) - // mode=restore (MindSift data only): replace everything instead of adding next to it. The plan is - // fully read and validated at this point, so a broken file never wipes anything. - if r.URL.Query().Get("mode") == "restore" && strings.HasSuffix(r.URL.Path, "/mindsift") { - if err := s.store.PurgeUserData(userID); err != nil { + s.applyImport(w, auth.GetUserID(r.Context()), plan, sections, r.URL.Query().Get("mode") == "restore" && strings.HasSuffix(r.URL.Path, "/mindsift")) +} + +// applyImport adds the plan to the user's data; with restore the chosen sections are emptied first. +// The plan is fully read and validated at this point, so a broken file never wipes anything. +func (s *Server) applyImport(w http.ResponseWriter, userID string, plan *storage.ImportPlan, sections map[string]bool, restore bool) { + if restore { + if err := s.store.PurgeUserSections(userID, sections); err != nil { writeError(w, http.StatusInternalServerError, "Wiederherstellen abgebrochen: "+err.Error()) return } diff --git a/internal/api/server.go b/internal/api/server.go index 8a1d484..cdffec0 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -56,6 +56,9 @@ func NewServer(cfg *config.Config, store *storage.Store, wsHub *ws.Hub, staticFS if cfg.DemoMode && cfg.DemoResetMinutes > 0 { go srv.startDemoResetTicker() } + if !cfg.DemoMode { + go srv.startBackupScheduler() + } return srv } @@ -130,6 +133,9 @@ func (s *Server) Router() http.Handler { r.Get("/api/v1/stats", s.handleStats) r.Get("/api/v1/search", s.handleSearch) r.Get("/api/v1/export", s.handleExport) + r.Get("/api/v1/backups", s.handleListMyBackups) + r.Get("/api/v1/backups/{name}", s.handleDownloadMyBackup) + r.Post("/api/v1/backups/{name}/restore", s.handleRestoreMyBackup) r.Get("/api/v1/export/markdown", s.handleExportMarkdown) r.Post("/api/v1/import/mindsift", s.handleImportMindSift) r.Delete("/api/v1/user/data", s.handlePurgeOwnData) @@ -256,6 +262,9 @@ func (s *Server) Router() http.Handler { r.Group(func(r chi.Router) { 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.Get("/api/v1/admin/backups/{name}", s.handleAdminDownloadBackup) r.Put("/api/v1/admin/settings", s.handleAdminUpdateSettings) r.Get("/api/v1/admin/settings/sharing-impact", s.handleAdminSharingImpact) diff --git a/internal/domain/models.go b/internal/domain/models.go index aeb3653..98ae2b2 100644 --- a/internal/domain/models.go +++ b/internal/domain/models.go @@ -292,6 +292,10 @@ type SystemSettings struct { // metadata stay blocked AllowPrivateWebhooks bool `json:"allowPrivateWebhooks"` SMTPConfiguredViaEnv bool `json:"smtpConfiguredViaEnv,omitempty"` + // Automatic backups: on/off, daily time (HH:MM, server time zone) and how many days to keep them + BackupEnabled bool `json:"backupEnabled"` + BackupTime string `json:"backupTime"` + BackupRetentionDays int `json:"backupRetentionDays"` } // UnmarshalJSON accepts smtpPort as text or number: older clients sent a number, which made the whole @@ -532,6 +536,7 @@ type FullExport struct { Documents []Document `json:"documents"` Lists []SharedList `json:"lists"` TaskLists []TaskList `json:"taskLists,omitempty"` + Themes []UserTheme `json:"themes,omitempty"` } type SharedFolder struct { diff --git a/internal/storage/account.go b/internal/storage/account.go index 9a0a9e2..e744aad 100644 --- a/internal/storage/account.go +++ b/internal/storage/account.go @@ -3,75 +3,137 @@ package storage import ( "errors" "fmt" + "strings" ) // ErrLastAdmin protects the server from losing its last administrator. var ErrLastAdmin = errors.New("Der letzte Server-Administrator kann nicht gelöscht werden. Bitte ernenne vorher ein anderes Konto zum Administrator.") -// PurgeUserData deletes everything the user owns – thoughts and tasks, notes (with blocks, history and -// shares), folders, shared folders, task lists and lists – in one transaction. The account, its -// settings, themes, API keys and group memberships stay. Other people's tasks in the user's task lists -// become their own personal tasks, as when a single list is deleted. -func (s *Store) PurgeUserData(userID string) error { - tx, err := s.db.Begin() - if err != nil { - return err - } - defer tx.Rollback() +// Sections of a user's data, for restoring only part of a backup. +const ( + SectionNotes = "notes" // notes, folders, shared folders, pins, tag pages + SectionTasks = "tasks" // task lists and tasks + SectionLists = "lists" // shopping and other lists + SectionInbox = "inbox" // thoughts in the inbox (and the discarded ones) + SectionThemes = "themes" // own CSS themes +) +// AllSections in a fixed order. +var AllSections = []string{SectionNotes, SectionTasks, SectionLists, SectionInbox, SectionThemes} + +// NormalizeSections keeps known sections; an empty selection means all of them. +func NormalizeSections(sections []string) map[string]bool { + out := map[string]bool{} + for _, sec := range sections { + sec = strings.ToLower(strings.TrimSpace(sec)) + for _, known := range AllSections { + if sec == known { + out[sec] = true + } + } + } + if len(out) == 0 { + for _, sec := range AllSections { + out[sec] = true + } + } + return out +} + +// purgeStmts lists what deleting one section of a user's data takes. Each statement gets the user ID +// as often as it has placeholders. +func purgeStmts(section string) []struct { + q string + args int +} { ownDocs := `(SELECT id FROM documents WHERE user_id = ?)` ownTaskLists := `(SELECT id FROM task_lists WHERE user_id = ?)` ownFolders := `(SELECT id FROM workspace_shared_folders WHERE owner_id = ?)` - stmts := []struct { + type st = struct { q string - args int // how many times userID is passed - }{ - // Task lists: keep the tasks of others, drop shares and the lists - {`UPDATE thoughts SET task_list_id = '' WHERE task_list_id IN ` + ownTaskLists + ` AND user_id != ?`, 2}, - {`DELETE FROM task_list_shares WHERE task_list_id IN ` + ownTaskLists, 1}, - {`DELETE FROM task_lists WHERE user_id = ?`, 1}, - // Tasks of others linked to blocks of the user's notes lose the link - {`UPDATE thoughts SET converted_type = '', converted_ref_id = '' WHERE converted_type = 'document_block' - AND substr(converted_ref_id, 1, instr(converted_ref_id, ':') - 1) IN ` + ownDocs, 1}, - {`DELETE FROM thoughts WHERE user_id = ?`, 1}, - // Shared folders owned by the user - {`UPDATE documents SET shared_folder_id = '', sub_folder_path = '' WHERE shared_folder_id IN ` + ownFolders, 1}, - {`DELETE FROM workspace_folder_mounts WHERE folder_id IN ` + ownFolders, 1}, - {`DELETE FROM workspace_folder_shares WHERE folder_id IN ` + ownFolders, 1}, - {`DELETE FROM workspace_folders WHERE shared_folder_id IN ` + ownFolders, 1}, - {`DELETE FROM workspace_shared_folders WHERE owner_id = ?`, 1}, - // Notes (history tables cascade with the document) - {`DELETE FROM blocks WHERE document_id IN ` + ownDocs, 1}, - {`DELETE FROM block_tags WHERE document_id IN ` + ownDocs, 1}, - {`DELETE FROM doc_user_folders WHERE doc_id IN ` + ownDocs, 1}, - {`DELETE FROM doc_team_shares WHERE doc_id IN ` + ownDocs, 1}, - {`DELETE FROM doc_shares WHERE doc_id IN ` + ownDocs, 1}, - {`DELETE FROM documents WHERE user_id = ?`, 1}, - // The user's own placement of notes, folders, pins and tag pages - {`DELETE FROM doc_user_folders WHERE user_id = ?`, 1}, - {`DELETE FROM doc_pins WHERE user_id = ?`, 1}, - {`DELETE FROM workspace_folders WHERE user_id = ?`, 1}, - {`DELETE FROM tag_pages WHERE user_id = ?`, 1}, - // Lists (items cascade) - {`DELETE FROM list_shares WHERE list_id IN (SELECT id FROM lists WHERE user_id = ?)`, 1}, - {`DELETE FROM lists WHERE user_id = ?`, 1}, + args int } - for _, st := range stmts { - args := make([]interface{}, st.args) - for i := range args { - args[i] = userID + switch section { + case SectionTasks: + return []st{ + // Task lists: keep the tasks of others, drop shares and the lists + {`UPDATE thoughts SET task_list_id = '' WHERE task_list_id IN ` + ownTaskLists + ` AND user_id != ?`, 2}, + {`DELETE FROM task_list_shares WHERE task_list_id IN ` + ownTaskLists, 1}, + {`DELETE FROM task_lists WHERE user_id = ?`, 1}, + {`DELETE FROM thoughts WHERE user_id = ? AND (status = 'kept' OR (status = 'converted' AND converted_type IN ('task', 'document_block')))`, 1}, } - if _, err := tx.Exec(st.q, args...); err != nil { - return fmt.Errorf("Daten löschen: %w", err) + case SectionInbox: + return []st{{`DELETE FROM thoughts WHERE user_id = ? AND status IN ('inbox', 'discarded')`, 1}} + case SectionNotes: + return []st{ + // Tasks of others linked to blocks of the user's notes lose the link + {`UPDATE thoughts SET converted_type = '', converted_ref_id = '' WHERE converted_type = 'document_block' + AND substr(converted_ref_id, 1, instr(converted_ref_id, ':') - 1) IN ` + ownDocs, 1}, + // Shared folders owned by the user + {`UPDATE documents SET shared_folder_id = '', sub_folder_path = '' WHERE shared_folder_id IN ` + ownFolders, 1}, + {`DELETE FROM workspace_folder_mounts WHERE folder_id IN ` + ownFolders, 1}, + {`DELETE FROM workspace_folder_shares WHERE folder_id IN ` + ownFolders, 1}, + {`DELETE FROM workspace_folders WHERE shared_folder_id IN ` + ownFolders, 1}, + {`DELETE FROM workspace_shared_folders WHERE owner_id = ?`, 1}, + // Notes (history tables cascade with the document) + {`DELETE FROM blocks WHERE document_id IN ` + ownDocs, 1}, + {`DELETE FROM block_tags WHERE document_id IN ` + ownDocs, 1}, + {`DELETE FROM doc_user_folders WHERE doc_id IN ` + ownDocs, 1}, + {`DELETE FROM doc_team_shares WHERE doc_id IN ` + ownDocs, 1}, + {`DELETE FROM doc_shares WHERE doc_id IN ` + ownDocs, 1}, + {`DELETE FROM documents WHERE user_id = ?`, 1}, + // The user's own placement of notes, folders, pins and tag pages + {`DELETE FROM doc_user_folders WHERE user_id = ?`, 1}, + {`DELETE FROM doc_pins WHERE user_id = ?`, 1}, + {`DELETE FROM workspace_folders WHERE user_id = ?`, 1}, + {`DELETE FROM tag_pages WHERE user_id = ?`, 1}, + } + case SectionLists: + return []st{ + {`DELETE FROM list_shares WHERE list_id IN (SELECT id FROM lists WHERE user_id = ?)`, 1}, + {`DELETE FROM lists WHERE user_id = ?`, 1}, // items cascade + } + case SectionThemes: + return []st{{`DELETE FROM user_themes WHERE user_id = ?`, 1}} + } + return nil +} + +// PurgeUserSections deletes the chosen sections of the user's own data in one transaction. Content of +// others shared with the user stays; other people's tasks in the user's task lists become their own +// personal tasks, as when a single list is deleted. +func (s *Store) PurgeUserSections(userID string, sections map[string]bool) error { + var stmts []sqlStmt + for _, sec := range AllSections { + if !sections[sec] { + continue + } + for _, st := range purgeStmts(sec) { + args := make([]interface{}, st.args) + for i := range args { + args[i] = userID + } + stmts = append(stmts, stmt(st.q, args...)) } } - if err := tx.Commit(); err != nil { - return err + if sections[SectionTasks] && sections[SectionInbox] { + // Everything else of the thought history (turned into notes or list items) goes as well + stmts = append(stmts, stmt(`DELETE FROM thoughts WHERE user_id = ?`, userID)) + } + if err := s.execInTx(stmts...); err != nil { + return fmt.Errorf("Daten löschen: %w", err) } s.PruneTaskAssignees() return nil } +// PurgeUserData deletes everything the user owns – thoughts and tasks, notes (with blocks, history and +// shares), folders, shared folders, task lists and lists. The account, its settings, themes, API keys +// and group memberships stay. +func (s *Store) PurgeUserData(userID string) error { + return s.PurgeUserSections(userID, map[string]bool{SectionNotes: true, SectionTasks: true, SectionLists: true, SectionInbox: true}) +} + // IsLastAdmin reports whether userID is an administrator and no other one exists. func (s *Store) IsLastAdmin(userID string) bool { var role string diff --git a/internal/storage/backup.go b/internal/storage/backup.go new file mode 100644 index 0000000..b6721a2 --- /dev/null +++ b/internal/storage/backup.go @@ -0,0 +1,167 @@ +package storage + +import ( + "compress/gzip" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + "time" +) + +// Automatic backups below $DATA_DIR/backups/: +// system/mindsift-db-YYYY-MM-DD-HHMM.db.gz the whole database (VACUUM INTO, consistent while +// others write), for the admin +// users//mindsift-backup-YYYY-MM-DD-HHMM.zip the same ZIP as "Backup herunterladen" +// Files older than the retention are deleted after each run. Backups next to the database protect +// against mistakes, not against a broken disk; copy the folder elsewhere as well. + +const ( + DefaultBackupTime = "03:00" + DefaultBackupRetentionDays = 3 +) + +var ( + backupTimeRe = regexp.MustCompile(`^([01]\d|2[0-3]):[0-5]\d$`) + backupNameRe = regexp.MustCompile(`^mindsift-(db|backup)-\d{4}-\d{2}-\d{2}-\d{4}\.(db\.gz|zip)$`) + ErrBackupName = errors.New("Unbekannte Sicherung") + backupStampFmt = "2006-01-02-1504" +) + +func validBackupTime(v string) bool { return backupTimeRe.MatchString(v) } + +// BackupFile is one entry of a backup listing. +type BackupFile struct { + Name string `json:"name"` + Size int64 `json:"size"` + CreatedAt string `json:"createdAt"` +} + +// BackupPath returns the file of a backup in dir, refusing anything that is not one of our names +// (no path tricks through the API). +func BackupPath(dir, name string) (string, error) { + if !backupNameRe.MatchString(name) { + return "", ErrBackupName + } + p := filepath.Join(dir, name) + if _, err := os.Stat(p); err != nil { + return "", ErrBackupName + } + return p, nil +} + +// ListBackups lists the backups in dir, newest first. +func ListBackups(dir string) ([]BackupFile, error) { + entries, err := os.ReadDir(dir) + if errors.Is(err, os.ErrNotExist) { + return []BackupFile{}, nil + } + if err != nil { + return nil, err + } + out := []BackupFile{} + for _, e := range entries { + if e.IsDir() || !backupNameRe.MatchString(e.Name()) { + continue + } + info, err := e.Info() + if err != nil { + continue + } + out = append(out, BackupFile{Name: e.Name(), Size: info.Size(), CreatedAt: info.ModTime().UTC().Format(time.RFC3339)}) + } + sort.Slice(out, func(i, j int) bool { return out[i].Name > out[j].Name }) + return out, nil +} + +// PruneBackups deletes backups in dir older than keepDays. +func PruneBackups(dir string, keepDays int, now time.Time) error { + files, err := ListBackups(dir) + if err != nil { + return err + } + cutoff := now.Add(-time.Duration(keepDays) * 24 * time.Hour) + for _, f := range files { + created, err := time.Parse(time.RFC3339, f.CreatedAt) + if err == nil && created.Before(cutoff) { + if err := os.Remove(filepath.Join(dir, f.Name)); err != nil { + return err + } + } + } + return nil +} + +// WriteSystemSnapshot writes a gzip-compressed copy of the whole database into dir. +func (s *Store) WriteSystemSnapshot(dir string, now time.Time) (string, error) { + if err := os.MkdirAll(dir, 0o700); err != nil { + return "", err + } + name := "mindsift-db-" + now.Format(backupStampFmt) + ".db.gz" + tmp := filepath.Join(dir, ".tmp-"+now.Format("20060102150405.000000")+".db") + defer os.Remove(tmp) + if _, err := s.db.Exec(`VACUUM INTO ?`, tmp); err != nil { + return "", fmt.Errorf("Datenbank-Abzug: %w", err) + } + if err := gzipFile(tmp, filepath.Join(dir, name)); err != nil { + return "", err + } + return name, nil +} + +// WriteUserBackup writes the user's MindSift backup (ZIP with the JSON export) into dir. +func (s *Store) WriteUserBackup(userID, dir string, now time.Time) (string, error) { + if err := os.MkdirAll(dir, 0o700); err != nil { + return "", err + } + exp, err := s.ExportUserData(userID) + if err != nil { + return "", err + } + name := "mindsift-backup-" + now.Format(backupStampFmt) + ".zip" + return name, writeFileAtomically(filepath.Join(dir, name), func(w io.Writer) error { return WriteBackupZip(exp, w) }) +} + +func gzipFile(src, dst string) error { + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + return writeFileAtomically(dst, func(w io.Writer) error { + gz := gzip.NewWriter(w) + if _, err := io.Copy(gz, in); err != nil { + return err + } + return gz.Close() + }) +} + +// writeFileAtomically writes to a temporary file and renames it, so a half-written backup never +// shows up in the list. +func writeFileAtomically(dst string, write func(io.Writer) error) error { + tmp := dst + ".part" + f, err := os.OpenFile(tmp, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600) + if err != nil { + return err + } + if err := write(f); err != nil { + f.Close() + os.Remove(tmp) + return err + } + if err := f.Close(); err != nil { + os.Remove(tmp) + return err + } + return os.Rename(tmp, dst) +} + +// UserBackupDir is the backup folder of one user below root; user IDs are generated by MindSift. +func UserBackupDir(root, userID string) string { + return filepath.Join(root, "users", strings.ReplaceAll(filepath.Base(userID), "..", "")) +} diff --git a/internal/storage/import_markdown.go b/internal/storage/import_markdown.go index aea9b6e..11db098 100644 --- a/internal/storage/import_markdown.go +++ b/internal/storage/import_markdown.go @@ -28,12 +28,34 @@ const ( // ImportPlan is everything an import would create. It is built completely (and validated) before // anything is written, so the preview shows exactly what the import does. type ImportPlan struct { - Folders []string `json:"-"` - Docs []importDoc `json:"-"` - TaskLists []importTaskList `json:"-"` - Lists []importList `json:"-"` - Inbox []importTask `json:"-"` - Skipped []ImportSkip `json:"skipped"` + Folders []string `json:"-"` + Docs []importDoc `json:"-"` + TaskLists []importTaskList `json:"-"` + Lists []importList `json:"-"` + Inbox []importTask `json:"-"` + Themes []domain.UserTheme `json:"-"` + Skipped []ImportSkip `json:"skipped"` +} + +// Only keeps the chosen sections of the plan (see NormalizeSections). +func (p *ImportPlan) Only(sections map[string]bool) *ImportPlan { + out := &ImportPlan{Skipped: p.Skipped} + if sections[SectionNotes] { + out.Folders, out.Docs = p.Folders, p.Docs + } + if sections[SectionTasks] { + out.TaskLists = p.TaskLists + } + if sections[SectionLists] { + out.Lists = p.Lists + } + if sections[SectionInbox] { + out.Inbox = p.Inbox + } + if sections[SectionThemes] { + out.Themes = p.Themes + } + return out } type ImportSkip struct { @@ -50,6 +72,7 @@ type ImportSummary struct { Lists int `json:"lists"` ListItems int `json:"listItems"` Inbox int `json:"inbox"` + Themes int `json:"themes"` Skipped []ImportSkip `json:"skipped"` Applied bool `json:"applied"` } @@ -85,7 +108,7 @@ func (p *ImportPlan) skip(path, reason string) { } func (p *ImportPlan) Summary(applied bool) ImportSummary { - sum := ImportSummary{Folders: len(p.Folders), Notes: len(p.Docs), Lists: len(p.Lists), Inbox: len(p.Inbox), Skipped: p.Skipped, Applied: applied} + sum := ImportSummary{Folders: len(p.Folders), Notes: len(p.Docs), Lists: len(p.Lists), Inbox: len(p.Inbox), Themes: len(p.Themes), Skipped: p.Skipped, Applied: applied} for _, tl := range p.TaskLists { if tl.title != "" { sum.TaskLists++ @@ -416,6 +439,7 @@ func PlanMindSiftJSONImport(data []byte) (*ImportPlan, error) { } plan.Lists = append(plan.Lists, il) } + plan.Themes = exp.Themes plan.Folders = sortedKeys(folders) return plan, nil } @@ -484,6 +508,11 @@ func (s *Store) ApplyImport(userID string, plan *ImportPlan) (ImportSummary, err } } } + for _, th := range plan.Themes { + if _, err := s.SaveUserTheme(userID, "", th.Name, th.CSS); err != nil { + plan.skip(th.Name, "Theme: "+err.Error()) + } + } return plan.Summary(true), nil } diff --git a/internal/storage/system.go b/internal/storage/system.go index 9554149..ff29e2a 100644 --- a/internal/storage/system.go +++ b/internal/storage/system.go @@ -2,6 +2,7 @@ package storage import ( "encoding/json" + "strconv" "strings" "github.com/sandrodach/mindsift/internal/domain" @@ -75,6 +76,7 @@ func (s *Store) ExportUserData(userID string) (*domain.FullExport, error) { docs, _ := s.GetDocuments(userID) lists, _ := s.GetAllListsForUser(userID) taskLists, _ := s.GetTaskLists(userID) + themes, _ := s.GetUserThemes(userID) return &domain.FullExport{ ExportedAt: domain.NowISO(), @@ -84,6 +86,7 @@ func (s *Store) ExportUserData(userID string) (*domain.FullExport, error) { Documents: docs, Lists: lists, TaskLists: taskLists, + Themes: themes, }, nil } @@ -111,11 +114,13 @@ func (s *Store) SeedSMTPDefaultsFromEnv(host, port, user, pass, from string, tls func (s *Store) GetSystemSettings() (*domain.SystemSettings, error) { settings := &domain.SystemSettings{ - SharingMode: SharingOrganizations, - AllowRegistration: false, - InstanceName: "MindSift", - SMTPPort: "587", - SMTPTLS: true, + SharingMode: SharingOrganizations, + AllowRegistration: false, + InstanceName: "MindSift", + SMTPPort: "587", + SMTPTLS: true, + BackupTime: DefaultBackupTime, + BackupRetentionDays: DefaultBackupRetentionDays, } rows, err := s.db.Query(`SELECT key, value FROM system_settings`) @@ -152,6 +157,16 @@ func (s *Store) GetSystemSettings() (*domain.SystemSettings, error) { settings.SMTPSkipVerify = v == "true" case "allow_private_webhooks": settings.AllowPrivateWebhooks = v == "true" + case "backup_enabled": + settings.BackupEnabled = v == "true" + case "backup_time": + if validBackupTime(v) { + settings.BackupTime = v + } + case "backup_retention_days": + if n, err := strconv.Atoi(v); err == nil && n > 0 { + settings.BackupRetentionDays = n + } } } } @@ -182,6 +197,12 @@ func (s *Store) UpdateSystemSettings(settings *domain.SystemSettings) error { if settings.InstanceName == "" { settings.InstanceName = "MindSift" } + if !validBackupTime(settings.BackupTime) { + settings.BackupTime = DefaultBackupTime + } + if settings.BackupRetentionDays < 1 || settings.BackupRetentionDays > 365 { + settings.BackupRetentionDays = DefaultBackupRetentionDays + } _, err := s.db.Exec(`INSERT INTO system_settings (key, value) VALUES ('sharing_mode', ?), ('allow_registration', ?), @@ -193,7 +214,10 @@ func (s *Store) UpdateSystemSettings(settings *domain.SystemSettings) error { ('smtp_from', ?), ('smtp_tls', ?), ('smtp_skip_verify', ?), - ('allow_private_webhooks', ?) + ('allow_private_webhooks', ?), + ('backup_enabled', ?), + ('backup_time', ?), + ('backup_retention_days', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value`, settings.SharingMode, regStr, @@ -206,6 +230,9 @@ func (s *Store) UpdateSystemSettings(settings *domain.SystemSettings) error { tlsStr, skipVerifyStr, privateHooksStr, + strconv.FormatBool(settings.BackupEnabled), + settings.BackupTime, + strconv.Itoa(settings.BackupRetentionDays), ) return err } From 64e077dd57721b60d97a094723cd6aaaaed7abc8 Mon Sep 17 00:00:00 2001 From: Sandro Date: Thu, 29 Oct 2026 16:11:49 +0100 Subject: [PATCH 24/26] feat(backup): admin and account UI for server backups, import by section Admin settings: switch, time, retention, list with download and a "Jetzt sichern" button. Account settings: own server backups with download and restore (replace or add, for chosen sections, replace needs a second tap). The MindSift import gets the same section picker. Co-Authored-By: Claude Opus 5.5 --- Doku/api/rest.md | 4 + Doku/architecture.md | 8 + README.md | 2 +- frontend/src/features/help/HelpView.tsx | 3 + .../features/settings/AdminSettingsView.tsx | 6 + .../src/features/settings/BackupAdminCard.tsx | 131 +++++++++++++++ frontend/src/features/settings/ImportCard.tsx | 36 ++++- .../src/features/settings/SectionPicker.tsx | 37 +++++ .../features/settings/ServerBackupsCard.tsx | 151 ++++++++++++++++++ .../features/settings/UserSettingsView.tsx | 16 +- frontend/src/lib/api.ts | 33 +++- frontend/src/lib/download.ts | 15 ++ frontend/src/lib/types.ts | 22 +++ 13 files changed, 446 insertions(+), 18 deletions(-) create mode 100644 frontend/src/features/settings/BackupAdminCard.tsx create mode 100644 frontend/src/features/settings/SectionPicker.tsx create mode 100644 frontend/src/features/settings/ServerBackupsCard.tsx create mode 100644 frontend/src/lib/download.ts diff --git a/Doku/api/rest.md b/Doku/api/rest.md index c7a311a..a47a6f1 100644 --- a/Doku/api/rest.md +++ b/Doku/api/rest.md @@ -41,6 +41,8 @@ Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}` | — | `/import/mindsift?mode=restore` | Wiederherstellen: löscht vor dem Import alle eigenen Notizen, Ordner, Aufgaben, Gedanken und Listen (erst nachdem die Datei vollständig gelesen und geprüft ist) | | DELETE | `/user/data` | Alle eigenen Daten löschen, Konto bleibt (`{"password"}` nötig) | | DELETE | `/user/account` | Eigenes Konto mit allen Daten löschen (`{"password"}` nötig); `403` für den letzten Server-Admin. `/auth/me` liefert dafür `isLastAdmin` | +| GET | `/backups`, `/backups/{name}` | Eigene automatische Server-Backups auflisten bzw. herunterladen | +| POST | `/backups/{name}/restore` | Server-Backup einspielen: `mode=merge` oder `restore` (gewählte Bereiche vorher leeren), `sections=notes,tasks,lists,inbox,themes`, `preview=1` | | GET/POST/DELETE | `/keys`, `/keys/{id}` | API-Keys verwalten (Klartext nur bei Erstellung); `POST` mit `name` und `scopes` (Standard `["webhook:triage"]`) | ### Globale Suche (`GET /api/v1/search?q=`) @@ -189,6 +191,8 @@ Wiederholte Operationen (gleiche `opId`) werden nicht erneut angewendet; die Ant | GET/PUT | `/admin/settings` | `instanceName`, `allowRegistration`, `sharingMode` (`private`/`organizations`/`open`), SMTP-Felder; ein Moduswechsel bereinigt Freigaben | | 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) | +| GET | `/admin/backups/{name}` | Datenbank-Abzug herunterladen | ## Beispiele diff --git a/Doku/architecture.md b/Doku/architecture.md index 19160f7..955b5f1 100644 --- a/Doku/architecture.md +++ b/Doku/architecture.md @@ -106,6 +106,14 @@ Die angezeigte Version (Kopf der App) setzt der Build-Parameter `APP_VERSION`: D Die Tests laufen beim Image-Build (Frontend: `npm run test:run`, Backend: `go test -v ./...`); ein Fehler bricht den Build ab. Frontend-Tests liegen unter `frontend/src/**/__tests__/` (Vitest + jsdom + Testing Library, Setup in `frontend/src/test/setup.ts`). +## Hintergrundjob: Backups + +[backups.go](../internal/api/backups.go) / [backup.go](../internal/storage/backup.go): Ist „Automatische Backups“ in den Admin-Einstellungen an, läuft einmal täglich zur eingestellten Uhrzeit (`backup_time`, Zeitzone `TZ`; nach einem Neustart wird ein verpasster Lauf am selben Tag nachgeholt): +- `backups/system/mindsift-db-JJJJ-MM-TT-HHMM.db.gz`: Abzug der ganzen Datenbank per `VACUUM INTO` (stimmig, auch während geschrieben wird), gzip. +- `backups/users//mindsift-backup-….zip`: dasselbe ZIP wie „Backup herunterladen“ für jedes aktive Konto. +- Danach werden Dateien älter als `backup_retention_days` (Standard 3) gelöscht. Dateien entstehen über `.part` und Umbenennen, halbe Backups tauchen nie in der Liste auf. Die API liefert nur Dateien mit genau diesem Namensmuster aus. +- Im Demo-Modus läuft kein Zeitplan. Zurückspielen eines Datenbank-Abzugs: Container stoppen, `mindsift.db` durch die entpackte Datei ersetzen, starten. Benutzer spielen ihre eigenen Backups in den Kontoeinstellungen ein, auch nur für einzelne Bereiche (Notizen, Aufgaben, Listen, Inbox, Themes). + ## Offline (PWA) - **Service Worker** ([public/sw.js](../frontend/public/sw.js)): Seitenaufrufe Network-First mit der zuletzt geladenen `index.html` als Rückfall, Bundles unter `/assets/` (Hash im Namen) aus dem Cache, Icons und Manifest Stale-While-Revalidate. API-Anfragen fasst er nie an. Alte Caches verschwinden beim Aktivieren einer neuen `VERSION`, Bundles sind auf 80 Einträge begrenzt. diff --git a/README.md b/README.md index 53400eb..7d913b2 100644 --- a/README.md +++ b/README.md @@ -166,7 +166,7 @@ Rufe `http://localhost:8080` auf. Beim ersten Start führt ein Assistent durch d | `DEMO_RESET_MINUTES` | `20` | Nur im Demo-Modus: setzt die Datenbank in diesem Abstand auf die Beispieldaten zurück (`0` = nie). Angemeldete bleiben angemeldet, die App zeigt einen Countdown und lädt danach neu. | * **Reverse-Proxy:** Am besten WebSocket-Upgrade und ungepufferte SSE-Streams durchreichen. Blockiert ein Proxy beides, funktioniert MindSift trotzdem über Long-Polling bzw. regelmäßige Abfragen, nur etwas träger. Beispiele für Nginx, Traefik, Caddy und Cloudflare: [Doku/reverse-proxy.md](Doku/reverse-proxy.md). -* **Datensicherung:** Alle Daten liegen in `data/mindsift.db` (+ `-wal`/`-shm`). Sichere sie mit `sqlite3 mindsift.db ".backup 'backup.db'"` oder bei gestopptem Container. Zusätzlich gibt es einen JSON-Export pro Benutzer. +* **Datensicherung:** In den Admin-Einstellungen lassen sich automatische tägliche Backups einschalten (ganze Datenbank plus ein Backup je Benutzer unter `data/backups/`, Standard 3 Tage aufbewahrt). Benutzer laden ihre Server-Backups herunter oder spielen sie ganz oder für einzelne Bereiche wieder ein. Der Ordner liegt neben der Datenbank: zusätzlich woanders sichern. Manuell geht weiterhin `sqlite3 mindsift.db ".backup 'backup.db'"`. * **Updates:** Neues Image ziehen und Container neu starten (`docker compose pull && docker compose up -d`). Das Schema wird beim Start automatisch angepasst. Die laufende Version steht im Kopf der App unter dem Namen. * **Image-Tags:** `:dev` = Stand des Branches `dev`, `:latest` = Stand von `main`, `:stable` = zuletzt veröffentlichte Version. Ein Git-Tag wie `v1.2.0` erzeugt `:v1.2.0` und verschiebt `:stable` darauf. diff --git a/frontend/src/features/help/HelpView.tsx b/frontend/src/features/help/HelpView.tsx index 9f8de97..fe47577 100644 --- a/frontend/src/features/help/HelpView.tsx +++ b/frontend/src/features/help/HelpView.tsx @@ -630,6 +630,9 @@ export const HelpView: React.FC = ({ onBack }) => {
  • Eigenes Theme: Vorlage herunterladen oder einfügen, Farben und Rundungen anpassen, als .css-Datei hochladen oder einfügen und „Speichern & aktivieren“. Das Theme greift sofort und steht dir auf allen Geräten zur Verfügung.
  • +
  • + Server-Backups: Hat die Administration automatische Backups eingeschaltet, findest du unter Kontoeinstellungen → Private Webhooks & Export die täglichen Sicherungen deiner Daten. Du kannst sie herunterladen oder wieder einspielen, auch nur für einzelne Bereiche wie Listen oder Aufgaben. +
  • Ohne Internet: Als App installiert (oder im Browser) startet MindSift auch offline mit dem zuletzt geladenen Stand. Gedanken erfassen sowie Aufgaben und Listeneinträge abhaken geht weiter; ein Hinweis unten zeigt, wie viele Änderungen warten, und sie werden automatisch übertragen, sobald die Verbindung zurück ist.
  • diff --git a/frontend/src/features/settings/AdminSettingsView.tsx b/frontend/src/features/settings/AdminSettingsView.tsx index 85cedc0..65de29f 100644 --- a/frontend/src/features/settings/AdminSettingsView.tsx +++ b/frontend/src/features/settings/AdminSettingsView.tsx @@ -28,6 +28,7 @@ import { api, setCurrentSharingMode, triggerHaptic } from '../../lib/api'; import { passwordProblem } from '../../lib/password'; import { UserAvatar } from '../../components/UserAvatar'; import { ToggleSwitch } from '../../components/ToggleSwitch'; +import { BackupAdminCard } from './BackupAdminCard'; import { CountBadge } from '../../components/CountBadge'; interface AdminSettingsViewProps { @@ -135,6 +136,9 @@ export const AdminSettingsView: React.FC = ({ smtpSkipVerify: Boolean(sys.smtpSkipVerify), allowPrivateWebhooks: Boolean(sys.allowPrivateWebhooks), smtpConfiguredViaEnv: Boolean(sys.smtpConfiguredViaEnv), + backupEnabled: Boolean(sys.backupEnabled), + backupTime: sys.backupTime || '03:00', + backupRetentionDays: sys.backupRetentionDays || 3, }); } } catch (err: any) { @@ -1093,6 +1097,8 @@ export const AdminSettingsView: React.FC = ({ )} + handleSaveSystemSettings(patch)} /> + {/* Alarm webhooks into the local network (SSRF protection) */}
    diff --git a/frontend/src/features/settings/BackupAdminCard.tsx b/frontend/src/features/settings/BackupAdminCard.tsx new file mode 100644 index 0000000..95e74a4 --- /dev/null +++ b/frontend/src/features/settings/BackupAdminCard.tsx @@ -0,0 +1,131 @@ +import React, { useEffect, useState } from 'react'; +import { Archive, Download, Loader2 } from 'lucide-react'; +import { BackupFile, SystemSettings } from '../../lib/types'; +import { api } from '../../lib/api'; +import { ToggleSwitch } from '../../components/ToggleSwitch'; +import { formatBytes, saveBlob } from '../../lib/download'; + +interface BackupAdminCardProps { + settings: SystemSettings; + onSave: (patch: Partial) => void; +} + +// Admin: automatic daily backups (whole database + one backup per user), list and manual run +export const BackupAdminCard: React.FC = ({ settings, onSave }) => { + const [files, setFiles] = useState([]); + const [running, setRunning] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + api.adminListBackups().then(setFiles).catch(() => {}); + }, []); + + const runNow = async () => { + setRunning(true); + setError(null); + try { + setFiles(await api.adminRunBackup()); + } catch (err: any) { + setError(err.message || 'Backup fehlgeschlagen'); + api.adminListBackups().then(setFiles).catch(() => {}); + } finally { + setRunning(false); + } + }; + + const download = async (name: string) => { + try { + saveBlob(await api.adminDownloadBackup(name), name); + } catch (err: any) { + setError(err.message || 'Download fehlgeschlagen'); + } + }; + + return ( +
    +
    +
    +

    + + Automatische Backups +

    +

    + Täglich ein Abzug der ganzen Datenbank und ein Backup je Benutzer unter backups/ im + Datenverzeichnis. Das schützt vor Fehlbedienung, nicht vor einer kaputten Platte: den Ordner zusätzlich woanders sichern. +

    +
    + onSave({ backupEnabled })} + title={settings.backupEnabled ? 'Automatische Backups ausschalten' : 'Automatische Backups einschalten'} + /> +
    + +
    + + + +
    + {error &&

    {error}

    } + +
    + {files.length === 0 ? ( +

    Noch keine Server-Backups vorhanden.

    + ) : ( + files.map((f) => ( +
    +
    +

    {f.name}

    +

    + {new Date(f.createdAt).toLocaleString('de-DE')} · {formatBytes(f.size)} +

    +
    + +
    + )) + )} +
    +

    + Zurückspielen eines Datenbank-Abzugs: Container stoppen, mindsift.db durch die entpackte Datei + ersetzen (gunzip), Container starten. Einzelne Benutzer stellen ihre Daten selbst in den + Kontoeinstellungen wieder her. +

    +
    + ); +}; diff --git a/frontend/src/features/settings/ImportCard.tsx b/frontend/src/features/settings/ImportCard.tsx index ce5d111..0292d2d 100644 --- a/frontend/src/features/settings/ImportCard.tsx +++ b/frontend/src/features/settings/ImportCard.tsx @@ -1,6 +1,7 @@ import React, { useRef, useState } from 'react'; import { Upload, Check, AlertCircle, FileArchive } from 'lucide-react'; -import { ImportSummary } from '../../lib/types'; +import { DATA_SECTIONS, DataSection, ImportSummary } from '../../lib/types'; +import { SectionPicker } from './SectionPicker'; import { api } from '../../lib/api'; interface ImportCardProps { @@ -18,6 +19,7 @@ const summaryParts = (s: ImportSummary) => s.tasks && `${s.tasks} Aufgabe${s.tasks === 1 ? '' : 'n'}`, s.lists && `${s.lists} Liste${s.lists === 1 ? '' : 'n'} mit ${s.listItems} Einträgen`, s.inbox && `${s.inbox} Gedanke${s.inbox === 1 ? '' : 'n'} für die Triage`, + s.themes && `${s.themes} Theme${s.themes === 1 ? '' : 's'}`, ].filter(Boolean) as string[]; // One import path: drop or pick a file, see a preview of what would be created, then import. @@ -33,6 +35,9 @@ export const ImportCard: React.FC = ({ source, title, descripti // MindSift data can replace the current state instead of being added next to it const [mode, setMode] = useState<'merge' | 'restore'>('merge'); const [confirmRestore, setConfirmRestore] = useState(false); + // MindSift data: which parts to take from the file + const [sections, setSections] = useState(DATA_SECTIONS.map((s) => s.id)); + const sectionParam = source === 'mindsift' ? sections : undefined; const restoring = source === 'mindsift' && mode === 'restore'; const reset = () => { @@ -49,7 +54,7 @@ export const ImportCard: React.FC = ({ source, title, descripti setFile(f); setBusy(true); try { - setPreview(await api.importData(source, f, true)); + setPreview(await api.importData(source, f, true, 'merge', sectionParam)); } catch (err: any) { setError(err.message || 'Datei konnte nicht gelesen werden'); } finally { @@ -62,7 +67,7 @@ export const ImportCard: React.FC = ({ source, title, descripti setBusy(true); setError(null); try { - setResult(await api.importData(source, file, false, restoring ? 'restore' : 'merge')); + setResult(await api.importData(source, file, false, restoring ? 'restore' : 'merge', sectionParam)); setFile(null); setPreview(null); setConfirmRestore(false); @@ -87,7 +92,7 @@ export const ImportCard: React.FC = ({ source, title, descripti {( [ ['merge', 'Hinzufügen', 'Bestehende Daten behalten, Backup daneben anlegen.'], - ['restore', 'Wiederherstellen', 'Eigene Notizen, Aufgaben und Listen vorher löschen.'], + ['restore', 'Wiederherstellen', 'Gewählte Bereiche vorher leeren.'], ] as const ).map(([value, label, text]) => (
    )} + {source === 'mindsift' && ( + { + setSections(next); + // The preview depends on the sections, so a picked file is read again + if (file) { + setBusy(true); + api + .importData(source, file, true, 'merge', next) + .then(setPreview) + .catch((err) => setError(err.message)) + .finally(() => setBusy(false)); + } + }} + /> + )} +
    { e.preventDefault(); @@ -194,8 +217,9 @@ export const ImportCard: React.FC = ({ source, title, descripti className="mt-0.5 w-4 h-4 shrink-0" /> - Achtung: Alle deine bisherigen Notizen, Ordner, Aufgaben, Gedanken und - Listen werden vor dem Import unwiderruflich gelöscht und durch das Backup ersetzt. Mit dir geteilte Inhalte anderer + Achtung: Deine bisherigen Daten in den gewählten Bereichen + ({DATA_SECTIONS.filter((x) => sections.includes(x.id)).map((x) => x.label).join(', ')}) werden vor dem Import + unwiderruflich gelöscht und durch das Backup ersetzt. Mit dir geteilte Inhalte anderer bleiben erhalten. diff --git a/frontend/src/features/settings/SectionPicker.tsx b/frontend/src/features/settings/SectionPicker.tsx new file mode 100644 index 0000000..ddd7dee --- /dev/null +++ b/frontend/src/features/settings/SectionPicker.tsx @@ -0,0 +1,37 @@ +import React from 'react'; +import { Check } from 'lucide-react'; +import { DATA_SECTIONS, DataSection } from '../../lib/types'; + +interface SectionPickerProps { + value: DataSection[]; + onChange: (sections: DataSection[]) => void; + label?: string; +} + +// Which parts of the own data a restore or import covers; at least one stays selected +export const SectionPicker: React.FC = ({ value, onChange, label = 'Bereiche' }) => ( +
    + {label}: + {DATA_SECTIONS.map((sec) => { + const on = value.includes(sec.id); + return ( + + ); + })} +
    +); diff --git a/frontend/src/features/settings/ServerBackupsCard.tsx b/frontend/src/features/settings/ServerBackupsCard.tsx new file mode 100644 index 0000000..69ec603 --- /dev/null +++ b/frontend/src/features/settings/ServerBackupsCard.tsx @@ -0,0 +1,151 @@ +import React, { useEffect, useState } from 'react'; +import { Archive, Check, Download, RotateCcw } from 'lucide-react'; +import { BackupFile, DataSection, DATA_SECTIONS, ImportSummary } from '../../lib/types'; +import { api } from '../../lib/api'; +import { formatBytes, saveBlob } from '../../lib/download'; +import { SectionPicker } from './SectionPicker'; + +// The user's own automatic backups on the server: download or restore (all or some sections) +export const ServerBackupsCard: React.FC = () => { + const [files, setFiles] = useState([]); + const [open, setOpen] = useState(null); // backup being restored + const [mode, setMode] = useState<'merge' | 'restore'>('restore'); + const [sections, setSections] = useState(DATA_SECTIONS.map((s) => s.id)); + const [confirm, setConfirm] = useState(false); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [result, setResult] = useState(null); + + useEffect(() => { + api.listMyBackups().then(setFiles).catch(() => {}); + }, []); + + const startRestore = (name: string) => { + setOpen(open === name ? null : name); + setConfirm(false); + setError(null); + setResult(null); + }; + + const restore = async () => { + if (!open) return; + setBusy(true); + setError(null); + try { + setResult(await api.restoreMyBackup(open, mode, sections)); + setConfirm(false); + } catch (err: any) { + setError(err.message || 'Wiederherstellen fehlgeschlagen'); + } finally { + setBusy(false); + } + }; + + const sectionNames = DATA_SECTIONS.filter((s) => sections.includes(s.id)) + .map((s) => s.label) + .join(', '); + + return ( +
    +
    +

    + + Server-Backups +

    +

    + Automatische tägliche Sicherungen deiner Daten auf dem Server (falls die Administration sie eingeschaltet hat). +

    +
    +
    + {files.length === 0 ? ( +

    Noch keine Server-Backups vorhanden.

    + ) : ( + files.map((f) => ( +
    +
    +
    +

    {new Date(f.createdAt).toLocaleString('de-DE')}

    +

    {formatBytes(f.size)}

    +
    +
    + + +
    +
    + + {open === f.name && ( +
    +
    + {( + [ + ['restore', 'Ersetzen', 'Gewählte Bereiche vorher leeren.'], + ['merge', 'Hinzufügen', 'Bestehendes behalten, Backup daneben anlegen.'], + ] as const + ).map(([value, label, text]) => ( + + ))} +
    + + {result ? ( +

    + Wiederhergestellt: {result.notes} Notizen, {result.tasks} Aufgaben, {result.lists} Listen,{' '} + {result.inbox} Gedanken, {result.themes ?? 0} Themes. +

    + ) : ( + + )} + {error &&

    {error}

    } +
    + )} +
    + )) + )} +
    +
    + ); +}; diff --git a/frontend/src/features/settings/UserSettingsView.tsx b/frontend/src/features/settings/UserSettingsView.tsx index a7d6812..8b80852 100644 --- a/frontend/src/features/settings/UserSettingsView.tsx +++ b/frontend/src/features/settings/UserSettingsView.tsx @@ -19,6 +19,8 @@ import { } from 'lucide-react'; import { AuthStatus, User, UserTheme } from '../../lib/types'; import { ApiKeysPanel } from './ApiKeysPanel'; +import { ServerBackupsCard } from './ServerBackupsCard'; +import { saveBlob } from '../../lib/download'; import { api, clearToken, triggerHaptic } from '../../lib/api'; import { passwordProblem } from '../../lib/password'; import { customThemeId, ThemeId, ThemeOption } from '../../lib/theme'; @@ -363,16 +365,6 @@ export const UserSettingsView: React.FC = ({ // --- Handlers: Export --- // Works on mobile browsers too: the link must be in the document and the URL alive until the click ran - const saveBlob = (blob: Blob, filename: string) => { - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = filename; - document.body.appendChild(a); - a.click(); - a.remove(); - window.setTimeout(() => URL.revokeObjectURL(url), 10000); - }; const [exportingBackup, setExportingBackup] = useState(false); const handleExportData = async () => { @@ -1166,6 +1158,10 @@ export const UserSettingsView: React.FC = ({ +
    + +
    + {/* Full Data Backup */}
    diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 2a50a1a..25deb97 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -4,6 +4,8 @@ import { DashboardStats, Document, TrashedDocument, + BackupFile, + DataSection, ApiKeyScope, Backlink, DocumentBlock, @@ -1155,7 +1157,8 @@ export const api = { source: 'mindsift' | 'obsidian', file: File, preview: boolean, - mode: 'merge' | 'restore' = 'merge' + mode: 'merge' | 'restore' = 'merge', + sections?: DataSection[] ): Promise { const token = getToken(); const form = new FormData(); @@ -1163,6 +1166,7 @@ export const api = { const params = new URLSearchParams(); if (preview) params.set('preview', '1'); if (mode === 'restore') params.set('mode', 'restore'); + if (sections && sections.length > 0) params.set('sections', sections.join(',')); const res = await fetch(`/api/v1/import/${source}${params.toString() ? `?${params}` : ''}`, { method: 'POST', headers: token ? { Authorization: `Bearer ${token}` } : {}, @@ -1173,6 +1177,33 @@ export const api = { return data as ImportSummary; }, + // --- Automatic server backups --- + async adminListBackups(): Promise { + return ensureArray(await request('/api/v1/admin/backups')); + }, + + async adminRunBackup(): Promise { + return ensureArray(await request('/api/v1/admin/backups', { method: 'POST' })); + }, + + async adminDownloadBackup(name: string): Promise { + return downloadBlob(`/api/v1/admin/backups/${encodeURIComponent(name)}`); + }, + + async listMyBackups(): Promise { + return ensureArray(await request('/api/v1/backups')); + }, + + async downloadMyBackup(name: string): Promise { + return downloadBlob(`/api/v1/backups/${encodeURIComponent(name)}`); + }, + + async restoreMyBackup(name: string, mode: 'merge' | 'restore', sections: DataSection[], preview = false): Promise { + const params = new URLSearchParams({ mode, sections: sections.join(',') }); + if (preview) params.set('preview', '1'); + return request(`/api/v1/backups/${encodeURIComponent(name)}/restore?${params}`, { method: 'POST' }); + }, + // Danger zone: both need the current password async purgeOwnData(password: string): Promise { await request('/api/v1/user/data', { method: 'DELETE', body: JSON.stringify({ password }) }); diff --git a/frontend/src/lib/download.ts b/frontend/src/lib/download.ts new file mode 100644 index 0000000..9dc3b57 --- /dev/null +++ b/frontend/src/lib/download.ts @@ -0,0 +1,15 @@ +// Saves a downloaded file. Works on mobile browsers too: the link must be in the document and the +// object URL alive until the click ran. +export function saveBlob(blob: Blob, filename: string): void { + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + a.remove(); + window.setTimeout(() => URL.revokeObjectURL(url), 10000); +} + +export const formatBytes = (n: number): string => + n < 1024 ? `${n} B` : n < 1024 * 1024 ? `${(n / 1024).toFixed(0)} KB` : `${(n / 1024 / 1024).toFixed(1)} MB`; diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 37e74c7..f1b08c3 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -186,8 +186,29 @@ export interface SystemSettings { smtpSkipVerify?: boolean; allowPrivateWebhooks?: boolean; // alarm webhooks may reach the LAN (and plain http) smtpConfiguredViaEnv?: boolean; + // Automatic backups: daily time (HH:MM, server time zone) and days to keep them + backupEnabled?: boolean; + backupTime?: string; + backupRetentionDays?: number; } +/** One file of a server backup listing. */ +export interface BackupFile { + name: string; + size: number; + createdAt: string; +} + +/** Parts of the own data a backup can be restored for. */ +export type DataSection = 'notes' | 'tasks' | 'lists' | 'inbox' | 'themes'; +export const DATA_SECTIONS: { id: DataSection; label: string }[] = [ + { id: 'notes', label: 'Notizen' }, + { id: 'tasks', label: 'Aufgaben' }, + { id: 'lists', label: 'Listen' }, + { id: 'inbox', label: 'Inbox' }, + { id: 'themes', label: 'Themes' }, +]; + export type BlockType = 'h1' | 'h2' | 'h3' | 'paragraph' | 'text' | 'todo' | 'bullet' | 'code' | 'callout' | 'divider' | 'table' | 'progress'; export interface DocumentBlock { @@ -464,6 +485,7 @@ export interface ImportSummary { lists: number; listItems: number; inbox: number; + themes?: number; skipped: { path: string; reason: string }[]; applied: boolean; } From 373987292fc1556fe151ae2763283a2753c76d9e Mon Sep 17 00:00:00 2001 From: Sandro Date: Thu, 29 Oct 2026 16:35:26 +0100 Subject: [PATCH 25/26] fix(capture): hold-to-record works by keyboard, confirms only after saving; drop FLAW-16 The + button lost its click handler for pointer events, so keyboard, VoiceOver and switch control could no longer open the capture sheet; a click without pointer (detail 0) now taps. "Im Quick Dump gespeichert" appears after the save succeeded, and the cancel state uses an existing border color. FLAW-16 (setup open until the first account, demo accounts) is removed from the list: intended behaviour, not a flaw. Co-Authored-By: Claude Opus 5.5 --- Doku/flaws.md | 6 ------ frontend/src/features/capture/HoldToRecordFab.tsx | 10 +++++++--- .../capture/__tests__/HoldToRecordFab.test.tsx | 9 +++++++++ 3 files changed, 16 insertions(+), 9 deletions(-) diff --git a/Doku/flaws.md b/Doku/flaws.md index c948352..efadda6 100644 --- a/Doku/flaws.md +++ b/Doku/flaws.md @@ -25,7 +25,6 @@ Schweregrade: **Hoch** (Datenabfluss oder Rechteumgehung möglich), **Mittel** ( | FLAW-13 | Datenbank | ~~Keine Schema-Versionierung; Migrationsfehler werden verschluckt; `migrations/001_init.sql` ist toter Code~~ | **Behoben** | | FLAW-14 | Backend | ~~Fehler von Schreiboperationen werden häufig ignoriert (`_, _ =`)~~ | **Behoben** | | FLAW-15 | Realtime | ~~`WriteTimeout` von 30 s beendet SSE-Streams~~ | **Behoben** | -| FLAW-16 | Setup | `/auth/setup` ist bis zum ersten Konto offen; Demo-Modus legt `admin/admin` an | Mittel | | FLAW-17 | Workspace | ~~Bearbeiter-Freigaben für Notizen nicht wählbar~~ | **Behoben** | | FLAW-18 | Suche | ~~Nur eigene Gedanken und Dokumente, `LIKE` ohne Escaping, keine FTS~~ | **Behoben** | | FLAW-19 | Qualität | ~~Frontend-Tests nur für den Outliner, `lint` ist ein Platzhalter~~ | **Behoben** | @@ -146,11 +145,6 @@ Schweregrade: **Hoch** (Datenabfluss oder Rechteumgehung möglich), **Mittel** ( - **Problem:** Go beendet nach diesem Zeitraum das Schreiben der Antwort. Für den SSE-Fallback (`ServeSSE`) bedeutet das voraussichtlich einen Verbindungsabbruch alle 30 s. WebSockets sind durch Hijacking nicht betroffen. Der Client verbindet sich neu, daher fällt es kaum auf. - **Maßnahme:** Im SSE-Handler `http.ResponseController.SetWriteDeadline(time.Time{})` setzen oder `WriteTimeout` für diese Route aufheben. -### FLAW-16: Ersteinrichtung und Demo-Modus -- **Fundstellen:** `handleFirstBootSetup`, `Store.EnsureDemoAccounts`, `config.Load` (`MINDA_DEMO`, `MINDSIFT_DEMO`, `DEMO_MODE`). -- **Probleme:** Solange kein Konto existiert, kann jeder `POST /auth/setup` aufrufen und wird Administrator. Mit gesetztem Demo-Flag entstehen Konten `admin/admin` und `user/user`; versehentlich auf einer öffentlichen Instanz aktiv wäre das eine Komplettübernahme. -- **Maßnahme:** Setup per einmaligem Token aus dem Server-Log absichern; Demo-Modus nur zusammen mit expliziter Warnung/Bestätigung. - ### FLAW-17: Schreibgeschützte Dokumentfreigaben - **Status: Behoben.** Zwangsmigration entfernt, Backend prüft Bearbeiter-Rechte (`DocRole`, `/docs/{id}/sync`), der Editor speichert über die Sync-Engine, und der Freigabe-Dialog bietet „Bearbeiten“ an. Browser-Test: `e2e/share.e2e.mjs`. - **Fundstellen:** Migration in `Store.migrate` (`UPDATE doc_shares SET permission = 'read' …`, ebenso `doc_team_shares`, `workspace_folder_shares`), `PatchDocumentBlocks`/`UpdateDocument` (nur Eigentümer). diff --git a/frontend/src/features/capture/HoldToRecordFab.tsx b/frontend/src/features/capture/HoldToRecordFab.tsx index 43b2f7a..1b82f88 100644 --- a/frontend/src/features/capture/HoldToRecordFab.tsx +++ b/frontend/src/features/capture/HoldToRecordFab.tsx @@ -91,10 +91,10 @@ export const HoldToRecordFab: React.FC = ({ onTap, onTrans return; } - triggerHaptic(25); - showToast('Im Quick Dump gespeichert'); try { await onTranscript(text); + triggerHaptic(25); + showToast('Im Quick Dump gespeichert'); } catch { showToast('Fehler beim Speichern'); } @@ -300,7 +300,7 @@ export const HoldToRecordFab: React.FC = ({ onTap, onTrans
    @@ -340,6 +340,10 @@ export const HoldToRecordFab: React.FC = ({ onTap, onTrans onPointerUp={handlePointerUp} onPointerCancel={handlePointerCancel} onContextMenu={(e) => e.preventDefault()} + // Keyboard, screen readers and switch control produce a click without pointer events (detail 0) + onClick={(e) => { + if (e.detail === 0) onTap(); + }} className={ isRecording ? 'w-14 h-14 -mt-6 rounded-full bg-status-err text-on-accent shadow-lg flex items-center justify-center transition-transform border-4 border-panel cursor-pointer touch-none select-none animate-pulse' diff --git a/frontend/src/features/capture/__tests__/HoldToRecordFab.test.tsx b/frontend/src/features/capture/__tests__/HoldToRecordFab.test.tsx index 4ee6863..4fe1494 100644 --- a/frontend/src/features/capture/__tests__/HoldToRecordFab.test.tsx +++ b/frontend/src/features/capture/__tests__/HoldToRecordFab.test.tsx @@ -255,3 +255,12 @@ describe('HoldToRecordFab', () => { ); }); }); + +describe('HoldToRecordFab keyboard access', () => { + it('opens the capture sheet when activated without a pointer (keyboard, screen reader)', () => { + const onTap = vi.fn(); + const { getByRole } = render(); + fireEvent.click(getByRole('button'), { detail: 0 }); + expect(onTap).toHaveBeenCalledTimes(1); + }); +}); From 13a7c9f08d925ce0956f9a07e27471655e3275c9 Mon Sep 17 00:00:00 2001 From: Sandro Date: Thu, 29 Oct 2026 16:54:09 +0100 Subject: [PATCH 26/26] =?UTF-8?q?release:=20v0.5=20=E2=80=93=20security=20?= =?UTF-8?q?hardening,=20backups,=20demo=20reset,=20API=20key=20scopes,=20h?= =?UTF-8?q?old-to-record?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- frontend/package-lock.json | 4 ++-- frontend/package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 532e596..910ea3d 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "mindsift-frontend", - "version": "0.4.1", + "version": "0.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mindsift-frontend", - "version": "0.4.1", + "version": "0.5.0", "license": "Unlicense", "dependencies": { "@types/prismjs": "^1.26.6", diff --git a/frontend/package.json b/frontend/package.json index 99eb601..80fd9d3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "mindsift-frontend", "private": true, - "version": "0.4.1", + "version": "0.5.0", "license": "Unlicense", "type": "module", "scripts": {