fix(realtime): scope events to authorized users, authorize rooms, keep SSE alive
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m6s

Phase 0 of the collaboration plan:
- Remove BroadcastToAll; task, task list and workspace folder events now
  only reach users who can see the resource. Task list events carry only
  the list ID, clients reload with their own permissions.
- Reject unauthenticated WebSocket/SSE connections (401). Every room join
  (URL room, JOIN_ROOM, SSE rooms) is checked via a room authorizer;
  /events/rooms requires login and only accepts the caller's own sseId.
- Reorder rejects tasks the caller cannot access or edit (IDOR).
- Lift the server WriteTimeout for SSE streams so they are not cut after 30s.
- Fix folder share removal sending DOC_DELETED to the share ID.
- Tests for all of the above; docs and flaw list updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-10-28 11:19:14 +01:00
1 parent 74797e3f44
commit bcc06da3ee
10 files changed
+526 -86

No files matched your search

+3 -3
View File
@@ -13,15 +13,15 @@ Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}`
| POST | `/auth/login` | Anmeldung (`login`/`email`/`username`, `password`) → `{token, user}` | | POST | `/auth/login` | Anmeldung (`login`/`email`/`username`, `password`) → `{token, user}` |
| POST | `/auth/quickstart` | Immer `403` (Gastzugang deaktiviert) | | 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` | 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) | | GET | `/ws`, `/ws/spaces/{id}`, `/ws/lists/{id}` | WebSocket, siehe [websocket.md](websocket.md); Token als `?token=` nötig |
| GET | `/events` | SSE-Fallback | | GET | `/events` | SSE-Fallback; Token als `?token=` nötig |
| POST | `/events/rooms` | SSE-Räume beitreten/verlassen ([FLAW-02](../flaws.md)) |
## Konto und Allgemeines (angemeldet) ## Konto und Allgemeines (angemeldet)
| Methode | Pfad | Zweck | | Methode | Pfad | Zweck |
|---|---|---| |---|---|---|
| GET | `/auth/me` | Benutzer und Kennzahlen | | GET | `/auth/me` | Benutzer und Kennzahlen |
| POST | `/events/rooms` | SSE-Raum beitreten/verlassen (`sseId`, `room`, `action`); nur eigene `sseId`, nur Räume mit Lesezugriff |
| POST | `/auth/logout` | Token sperren | | POST | `/auth/logout` | Token sperren |
| POST | `/auth/revoke-all` | Alle Sitzungen beenden (`token_version` +1) | | POST | `/auth/revoke-all` | Alle Sitzungen beenden (`token_version` +1) |
| PATCH | `/auth/settings` | `displayName`, `email`, `password`, `avatarUrl`, `alarmChannels`, `alarmEmail`, `discordWebhookUrl`, `customWebhookUrl`, `notifyBeforeMinutes` | | PATCH | `/auth/settings` | `displayName`, `email`, `password`, `avatarUrl`, `alarmChannels`, `alarmEmail`, `discordWebhookUrl`, `customWebhookUrl`, `notifyBeforeMinutes` |
+10 -8
View File
@@ -13,7 +13,9 @@ Der Server verteilt Änderungen als JSON-Ereignisse an verbundene Clients. Die E
Der Client versucht zuerst WebSocket. Gelingt der Aufbau nicht innerhalb von 2,5 s oder bricht die Verbindung unsauber ab, wechselt er auf SSE und bleibt dabei (Firmen-Proxys). Dort liefert der Server `SSE_CONNECTED` mit einer `sseId`; Räume werden über `POST /api/v1/events/rooms` (`sseId`, `room`, `action`: `join`/`leave`) abonniert. Der Client versucht zuerst WebSocket. Gelingt der Aufbau nicht innerhalb von 2,5 s oder bricht die Verbindung unsauber ab, wechselt er auf SSE und bleibt dabei (Firmen-Proxys). Dort liefert der Server `SSE_CONNECTED` mit einer `sseId`; Räume werden über `POST /api/v1/events/rooms` (`sseId`, `room`, `action`: `join`/`leave`) abonniert.
**Authentifizierung:** Token als `?token=<JWT|API-Key>` oder `Authorization`-Header. Ohne gültiges Token wird die Verbindung als `anonymous` akzeptiert ([FLAW-01](../flaws.md), [FLAW-02](../flaws.md)). Das Token wird nur beim Verbinden geprüft. **Authentifizierung:** Token als `?token=<JWT|API-Key>` 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)).
**Räume:** Jeder Beitritt (Raum in der URL, `JOIN_ROOM`, SSE-Startparameter `rooms`/`docId`, `POST /events/rooms`) wird geprüft: `doc:<id>` 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.
## Nachrichtenformat ## Nachrichtenformat
@@ -29,20 +31,20 @@ Client → Server versteht der Server nur: `{"event":"JOIN_ROOM","room":"<id>"}`
|---|---| |---|---|
| Benutzer-Kanal (`BroadcastToUser`) | alle Verbindungen eines Benutzers | | Benutzer-Kanal (`BroadcastToUser`) | alle Verbindungen eines Benutzers |
| Raum (`BroadcastRoom`, `Broadcast`) | Verbindungen im Raum | | Raum (`BroadcastRoom`, `Broadcast`) | Verbindungen im Raum |
| Alle (`BroadcastToAll`) | **jede** Verbindung, auch unangemeldete | | Mehrere Benutzer (`BroadcastToUsers`) | alle Verbindungen einer berechneten Benutzergruppe, z. B. Eigentümer und Mitglieder einer Aufgabenliste |
## Ereignisse ## Ereignisse
| Ereignis | Auslöser | Kanal | | Ereignis | Auslöser | Kanal |
|---|---|---| |---|---|---|
| `TASK_CREATED` | `POST /inbox` | Benutzer | | `TASK_CREATED` | `POST /inbox`, Webhook | Eigentümer und Benutzer der Liste |
| `TASK_UPDATED` | `PATCH /thoughts/{id}` (Triage: Benutzer) | alle | | `TASK_UPDATED` | `PATCH /thoughts/{id}`, Triage | Eigentümer und Benutzer der alten und neuen Liste |
| `TASK_DELETED` | `DELETE /thoughts/{id}` | alle | | `TASK_DELETED` | `DELETE /thoughts/{id}` | Eigentümer und Benutzer der Liste |
| `TASK_REORDERED` | Aufgaben umsortieren | alle | | `TASK_REORDERED` | Aufgaben umsortieren | Benutzer aller betroffenen Listen |
| `TASK_LIST_UPDATED` / `TASK_LIST_DELETED` | Liste ändern, (un)teilen, löschen | alle | | `TASK_LIST_UPDATED` / `TASK_LIST_DELETED` | Liste ändern, (un)teilen, löschen; Payload nur `{id}`, Clients laden ihre Listen neu | Benutzer vor und nach der Änderung |
| `DOC_CREATED`, `DOC_UPDATED`, `DOC_DELETED` | Notiz anlegen/ändern/löschen, Freigaben | berechtigte Benutzer | | `DOC_CREATED`, `DOC_UPDATED`, `DOC_DELETED` | Notiz anlegen/ändern/löschen, Freigaben | berechtigte Benutzer |
| `DOC_BLOCKS_PATCHED` | `PATCH /documents/{id}/blocks` (`docId`, `updatedBy`, `upserted`, `deletedIds`, `blockOrder`) | Raum `doc:<id>` und berechtigte Benutzer | | `DOC_BLOCKS_PATCHED` | `PATCH /documents/{id}/blocks` (`docId`, `updatedBy`, `upserted`, `deletedIds`, `blockOrder`) | Raum `doc:<id>` und berechtigte Benutzer |
| `WORKSPACE_FOLDERS_UPDATED` | Ordner/Mounts ändern | Benutzer | | `WORKSPACE_FOLDERS_UPDATED` | Ordner/Mounts ändern | Benutzer selbst; bei geteilten Ordnern Eigentümer und alle Beteiligten |
| `LIST_CREATED`, `LIST_UPDATED`, `LIST_DELETED`, `LIST_SHARED`, `LIST_UNSHARED`, `LIST_CLEARED` | Listenverwaltung | Benutzer bzw. Raum der Liste | | `LIST_CREATED`, `LIST_UPDATED`, `LIST_DELETED`, `LIST_SHARED`, `LIST_UNSHARED`, `LIST_CLEARED` | Listenverwaltung | Benutzer bzw. Raum der Liste |
| `ITEM_CREATED`, `ITEM_UPDATED`, `ITEM_TOGGLED`, `ITEM_DELETED`, `ITEM_REORDERED` | Listeneinträge | Raum der Liste | | `ITEM_CREATED`, `ITEM_UPDATED`, `ITEM_TOGGLED`, `ITEM_DELETED`, `ITEM_REORDERED` | Listeneinträge | Raum der Liste |
| `USER_PRESENCE` | Verbindung auf-/abgebaut (`onlineCount`) | Raum | | `USER_PRESENCE` | Verbindung auf-/abgebaut (`onlineCount`) | Raum |
+9 -5
View File
@@ -10,9 +10,9 @@ Schweregrade: **Hoch** (Datenabfluss oder Rechteumgehung möglich), **Mittel** (
| ID | Bereich | Kurzbeschreibung | Schwere | | ID | Bereich | Kurzbeschreibung | Schwere |
|---|---|---|---| |---|---|---|---|
| FLAW-01 | Realtime | `BroadcastToAll` sendet Aufgaben- und Listendaten an alle Verbindungen, auch unauthentifizierte | **Hoch** | | FLAW-01 | Realtime | ~~`BroadcastToAll` sendet Aufgaben- und Listendaten an alle Verbindungen~~ | **Behoben** |
| FLAW-02 | Realtime | Räume (WS `JOIN_ROOM`, SSE `/events/rooms`, `?listId=`) ohne Berechtigungsprüfung | **Hoch** | | FLAW-02 | Realtime | ~~Räume ohne Berechtigungsprüfung~~ | **Behoben** |
| FLAW-03 | Aufgaben | `ReorderTasks` ändert beliebige Aufgaben per ID, ohne Zugriffsprüfung (IDOR) | **Hoch** | | FLAW-03 | Aufgaben | ~~`ReorderTasks` ändert beliebige Aufgaben per ID (IDOR)~~ | **Behoben** |
| FLAW-04 | Auth | Fest eincodiertes Fallback-`JWT_SECRET` | **Hoch** | | FLAW-04 | Auth | Fest eincodiertes Fallback-`JWT_SECRET` | **Hoch** |
| FLAW-05 | Benachrichtigungen | Discord-/Webhook-URLs ungeprüft (SSRF) | Mittel | | FLAW-05 | Benachrichtigungen | Discord-/Webhook-URLs ungeprüft (SSRF) | Mittel |
| FLAW-06 | Aufgaben | Jeder Editor einer Aufgabenliste kann sie per `teamId` an weitere Gruppen freigeben | Mittel | | FLAW-06 | Aufgaben | Jeder Editor einer Aufgabenliste kann sie per `teamId` an weitere Gruppen freigeben | Mittel |
@@ -24,7 +24,7 @@ Schweregrade: **Hoch** (Datenabfluss oder Rechteumgehung möglich), **Mittel** (
| FLAW-12 | Benachrichtigungen | `notify_before_minutes` wirkungslos; Alarm feuert nach Umplanung nie wieder | Mittel | | FLAW-12 | Benachrichtigungen | `notify_before_minutes` wirkungslos; Alarm feuert nach Umplanung nie wieder | Mittel |
| 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 | Mittel |
| FLAW-14 | Backend | Fehler von Schreiboperationen werden häufig ignoriert (`_, _ =`) | Mittel | | FLAW-14 | Backend | Fehler von Schreiboperationen werden häufig ignoriert (`_, _ =`) | Mittel |
| FLAW-15 | Realtime | `WriteTimeout` von 30 s trifft vermutlich auch SSE-Streams *(nicht live verifiziert)* | 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 | | FLAW-16 | Setup | `/auth/setup` ist bis zum ersten Konto offen; Demo-Modus legt `admin/admin` an | Mittel |
| FLAW-17 | Workspace | Dokument- und Ordnerfreigaben sind per Migration erzwungen schreibgeschützt; tote Schreibpfade | Niedrig | | FLAW-17 | Workspace | Dokument- und Ordnerfreigaben sind per Migration erzwungen schreibgeschützt; tote Schreibpfade | Niedrig |
| FLAW-18 | Suche | Nur eigene Gedanken und Dokumente, `LIKE` ohne Escaping, keine FTS | Niedrig | | FLAW-18 | Suche | Nur eigene Gedanken und Dokumente, `LIKE` ohne Escaping, keine FTS | Niedrig |
@@ -40,16 +40,19 @@ Schweregrade: **Hoch** (Datenabfluss oder Rechteumgehung möglich), **Mittel** (
## Details ## Details
### FLAW-01: Broadcast an alle Verbindungen (Datenabfluss) ### FLAW-01: Broadcast an alle Verbindungen (Datenabfluss)
- **Status: Behoben.** `BroadcastToAll` existiert nicht mehr. Aufgabenereignisse gehen an Eigentümer und Benutzer der Liste (`GetTaskListUserIDs`), Listenereignisse tragen nur noch die ID, Ordnerereignisse gehen an Benutzer bzw. Ordnerbeteiligte (`GetSharedFolderUserIDs`). Test: `TestTaskEventsOnlyReachAuthorizedUsers`.
- **Fundstellen:** [`internal/ws/hub.go`](../internal/ws/hub.go) `BroadcastToAll`; Aufrufer in [`internal/api/server.go`](../internal/api/server.go): `handleUpdateThought` (`TASK_UPDATED`), `handleDeleteThought` (`TASK_DELETED`), `handleReorderTasks` (`TASK_REORDERED`), `handleUpdateTaskList`, `handleCreateTaskListShare`, `handleDeleteTaskListShare` (`TASK_LIST_UPDATED`), `handleDeleteTaskList`. - **Fundstellen:** [`internal/ws/hub.go`](../internal/ws/hub.go) `BroadcastToAll`; Aufrufer in [`internal/api/server.go`](../internal/api/server.go): `handleUpdateThought` (`TASK_UPDATED`), `handleDeleteThought` (`TASK_DELETED`), `handleReorderTasks` (`TASK_REORDERED`), `handleUpdateTaskList`, `handleCreateTaskListShare`, `handleDeleteTaskListShare` (`TASK_LIST_UPDATED`), `handleDeleteTaskList`.
- **Problem:** `BroadcastToAll` schreibt an `allClients` und `sseClients`. Dazu zählen auch Verbindungen mit `userID = "anonymous"`, denn `handleWebSocket` und `handleSSE` liegen außerhalb der Auth-Gruppe und lassen jede Verbindung zu. Der vollständige Aufgabeninhalt (Titel, Notizen, Unteraufgaben) und die Listen samt Mitgliedern gehen so an jeden, der `/api/v1/events` öffnet. - **Problem:** `BroadcastToAll` schreibt an `allClients` und `sseClients`. Dazu zählen auch Verbindungen mit `userID = "anonymous"`, denn `handleWebSocket` und `handleSSE` liegen außerhalb der Auth-Gruppe und lassen jede Verbindung zu. Der vollständige Aufgabeninhalt (Titel, Notizen, Unteraufgaben) und die Listen samt Mitgliedern gehen so an jeden, der `/api/v1/events` öffnet.
- **Maßnahme:** Ereignisse an die berechtigten Nutzer adressieren (wie bei `DOC_*` über `GetAuthorizedDocUsers`), unauthentifizierte Verbindungen ablehnen. - **Maßnahme:** Ereignisse an die berechtigten Nutzer adressieren (wie bei `DOC_*` über `GetAuthorizedDocUsers`), unauthentifizierte Verbindungen ablehnen.
### FLAW-02: Räume ohne Berechtigungsprüfung ### FLAW-02: Räume ohne Berechtigungsprüfung
- **Status: Behoben.** `/ws` und `/events` verlangen ein gültiges Token (sonst `401`). Jeder Raumbeitritt läuft über `Hub.SetRoomAuthorizer` → `Server.canJoinRoom` (Notiz bzw. Liste muss lesbar sein). `/events/rooms` liegt hinter der Anmeldung und akzeptiert nur die eigene `sseId`. Tests: `TestRealtimeEndpointsRequireAuth`, `TestSSERoomJoinRequiresAccess`, `TestRoomJoinsRequireAuthorization`.
- **Fundstellen:** `Hub.readPump` (`JOIN_ROOM`), `handleSSERooms` (Route `/api/v1/events/rooms` liegt **außerhalb** der Auth-Gruppe), `Hub.Register` (Raum aus `?listId=`/`?spaceId=`). - **Fundstellen:** `Hub.readPump` (`JOIN_ROOM`), `handleSSERooms` (Route `/api/v1/events/rooms` liegt **außerhalb** der Auth-Gruppe), `Hub.Register` (Raum aus `?listId=`/`?spaceId=`).
- **Problem:** Wer eine Raum-ID kennt (`doc:<id>`, Listen-ID), tritt bei und erhält Inhalte wie `DOC_BLOCKS_PATCHED` oder `ITEM_*`. Raum-IDs tauchen in Ereignissen auf und sind nicht geheim. Für den SSE-Beitritt reicht die `sseId`, die der Server selbst im Stream mitsendet. - **Problem:** Wer eine Raum-ID kennt (`doc:<id>`, Listen-ID), tritt bei und erhält Inhalte wie `DOC_BLOCKS_PATCHED` oder `ITEM_*`. Raum-IDs tauchen in Ereignissen auf und sind nicht geheim. Für den SSE-Beitritt reicht die `sseId`, die der Server selbst im Stream mitsendet.
- **Maßnahme:** Beitritt nur nach Zugriffsprüfung (`GetDocumentByID`, `GetListByID`); `/events/rooms` hinter `authMiddleware`. - **Maßnahme:** Beitritt nur nach Zugriffsprüfung (`GetDocumentByID`, `GetListByID`); `/events/rooms` hinter `authMiddleware`.
### FLAW-03: `ReorderTasks` ohne Zugriffsprüfung (IDOR) ### FLAW-03: `ReorderTasks` ohne Zugriffsprüfung (IDOR)
- **Status: Behoben.** `handleReorderTasks` lehnt die ganze Anfrage ab, sobald eine Aufgabe nicht zugreifbar (`404`) oder schreibgeschützt (`403`) ist. Test: `TestReorderRejectsForeignTasks`.
- **Fundstelle:** `Store.ReorderTasks` (`UPDATE thoughts SET sort_order … WHERE id = ?`) und `handleReorderTasks`. - **Fundstelle:** `Store.ReorderTasks` (`UPDATE thoughts SET sort_order … WHERE id = ?`) und `handleReorderTasks`.
- **Problem:** Die SQL-Anweisung ist weder auf `user_id` noch auf die Liste eingeschränkt. Jeder angemeldete Nutzer kann `sortOrder`, `kanbanStatus` und damit `isCompleted` fremder Aufgaben ändern, sofern er die ID kennt. Die Leser-Prüfung für Aufgabenlisten überspringt Aufgaben, auf die der Nutzer gar keinen Zugriff hat (`GetThoughtByID` liefert dann einen Fehler), und schützt davor nicht. - **Problem:** Die SQL-Anweisung ist weder auf `user_id` noch auf die Liste eingeschränkt. Jeder angemeldete Nutzer kann `sortOrder`, `kanbanStatus` und damit `isCompleted` fremder Aufgaben ändern, sofern er die ID kennt. Die Leser-Prüfung für Aufgabenlisten überspringt Aufgaben, auf die der Nutzer gar keinen Zugriff hat (`GetThoughtByID` liefert dann einen Fehler), und schützt davor nicht.
- **Maßnahme:** Jede ID gegen `accessibleTaskListIDsQuery` bzw. `user_id` prüfen und bei Verstoß ablehnen. - **Maßnahme:** Jede ID gegen `accessibleTaskListIDsQuery` bzw. `user_id` prüfen und bei Verstoß ablehnen.
@@ -119,7 +122,8 @@ Schweregrade: **Hoch** (Datenabfluss oder Rechteumgehung möglich), **Mittel** (
- **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. - **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.
- **Maßnahme:** Mehrstufige Änderungen in Transaktionen bündeln und Fehler zurückgeben. - **Maßnahme:** Mehrstufige Änderungen in Transaktionen bündeln und Fehler zurückgeben.
### FLAW-15: WriteTimeout und SSE *(nicht live verifiziert)* ### FLAW-15: WriteTimeout und SSE
- **Status: Behoben.** `ServeSSE` hebt die Schreibfrist für die eigene Antwort auf (`http.ResponseController.SetWriteDeadline`). Test mit 1-s-Timeout: `TestSSEStreamOutlivesWriteTimeout`. Der Test hat den Abbruch im alten Code bestätigt.
- **Fundstelle:** [`cmd/server/main.go`](../cmd/server/main.go) `WriteTimeout: 30 * time.Second`. - **Fundstelle:** [`cmd/server/main.go`](../cmd/server/main.go) `WriteTimeout: 30 * time.Second`.
- **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. - **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. - **Maßnahme:** Im SSE-Handler `http.ResponseController.SetWriteDeadline(time.Time{})` setzen oder `WriteTimeout` für diese Route aufheben.
+2 -2
View File
@@ -277,8 +277,8 @@ Damit private Notizen keinen unnötigen Aufwand erzeugen, liefert der Server im
| Phase | Inhalt | Ergebnis | | Phase | Inhalt | Ergebnis |
|---|---|---| |---|---|---|
| 0 | Sicherheitsfixes vorziehen: Raumberechtigung (FLAW-01/02), Stream-Timeout (FLAW-15), zentrale `docRole` | Grundlage dicht | | 0 | Sicherheitsfixes vorziehen: Raumberechtigung (FLAW-01/02), Umsortieren (FLAW-03), Stream-Timeout (FLAW-15). **Erledigt.** Die zentrale `docRole` wandert in Phase 1, weil sie auf dem neuen Freigabemodell (`workspace_access`) aufbaut. | Grundlage dicht |
| 1 | Neues Schema, Operations-Log, `/snapshot`, `/sync`, Konfliktregeln, Simulationstests | Server fertig und abgesichert | | 1 | Neues Schema inkl. `workspace_access` und zentraler `docRole`, Operations-Log, `/snapshot`, `/sync`, Konfliktregeln, Simulationstests | Server fertig und abgesichert |
| 2 | `SyncEngine` mit IndexedDB-Warteschlange, nur Intervall-Abfrage als Transport, Editor umgestellt (Baum statt `level`) | Bearbeiten funktioniert überall, auch offline | | 2 | `SyncEngine` mit IndexedDB-Warteschlange, nur Intervall-Abfrage als Transport, Editor umgestellt (Baum statt `level`) | Bearbeiten funktioniert überall, auch offline |
| 3 | Leases und Präsenz in UI, Konfliktblöcke mit Übernehmen/Verwerfen, Rückgängig/Wiederholen | Gemeinsames Bearbeiten ohne Kollisionen, Strg+Z | | 3 | Leases und Präsenz in UI, Konfliktblöcke mit Übernehmen/Verwerfen, Rückgängig/Wiederholen | Gemeinsames Bearbeiten ohne Kollisionen, Strg+Z |
| 4 | Push-Stufen WebSocket → SSE → Long-Polling mit automatischer Erkennung | Live-Gefühl, wo das Netz es zulässt | | 4 | Push-Stufen WebSocket → SSE → Long-Polling mit automatischer Erkennung | Live-Gefühl, wo das Netz es zulässt |
@@ -220,6 +220,9 @@ export const TasksView: React.FC<TasksViewProps> = ({
} }
} else if (msg.event === 'TASK_REORDERED') { } else if (msg.event === 'TASK_REORDERED') {
loadData(); loadData();
} else if (msg.event === 'TASK_LIST_UPDATED' || msg.event === 'TASK_LIST_DELETED') {
// Payload only carries the list ID; reload so permissions reflect this user's view
api.getTaskLists().then((lists) => setTaskLists(Array.isArray(lists) ? lists : [])).catch(() => {});
} }
}; };
+268
View File
@@ -0,0 +1,268 @@
package api
import (
"bufio"
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/sandrodach/mindsift/internal/auth"
"github.com/sandrodach/mindsift/internal/domain"
"github.com/sandrodach/mindsift/internal/storage"
)
const testJWTSecret = "test-jwt-secret-key"
type sseMsg struct {
Event string `json:"event"`
Payload json.RawMessage `json:"payload"`
}
type sseStream struct {
id string
events chan sseMsg
}
// startRealtimeServer serves the router over real HTTP with a short WriteTimeout, like production but faster.
func startRealtimeServer(t *testing.T) (*Server, *storage.Store, *httptest.Server, *domain.User, string) {
t.Helper()
srv, store, token := setupTestServer(t)
claims, err := auth.ValidateToken(testJWTSecret, token)
if err != nil {
t.Fatalf("token: %v", err)
}
owner, _ := store.GetUserByID(claims.UserID)
ts := httptest.NewUnstartedServer(srv.Router())
ts.Config.WriteTimeout = time.Second
ts.Start()
t.Cleanup(ts.Close)
return srv, store, ts, owner, token
}
func newTestUser(t *testing.T, store *storage.Store, email string) (*domain.User, string) {
t.Helper()
u, err := store.CreateUser(email, "hash", strings.Split(email, "@")[0], "member")
if err != nil {
t.Fatalf("create user: %v", err)
}
tok, err := auth.GenerateToken(testJWTSecret, u.ID, u.Email, u.DisplayName, u.TokenVersion)
if err != nil {
t.Fatalf("token: %v", err)
}
return u, tok
}
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)
resp, err := http.DefaultClient.Do(req)
if err != nil {
cancel()
t.Fatalf("open sse: %v", err)
}
if resp.StatusCode != http.StatusOK {
cancel()
t.Fatalf("open sse: status %d", resp.StatusCode)
}
st := &sseStream{events: make(chan sseMsg, 64)}
go func() {
defer close(st.events)
sc := bufio.NewScanner(resp.Body)
for sc.Scan() {
if line, ok := strings.CutPrefix(sc.Text(), "data: "); ok {
var m sseMsg
if json.Unmarshal([]byte(line), &m) == nil {
st.events <- m
}
}
}
}()
t.Cleanup(func() {
cancel()
_ = resp.Body.Close()
})
connected := st.waitFor(t, "SSE_CONNECTED", 2*time.Second)
if connected == nil {
t.Fatal("no SSE_CONNECTED event")
}
var p struct {
SSEID string `json:"sseId"`
}
_ = json.Unmarshal(connected.Payload, &p)
st.id = p.SSEID
return st
}
func (st *sseStream) waitFor(t *testing.T, event string, timeout time.Duration) *sseMsg {
t.Helper()
deadline := time.After(timeout)
for {
select {
case m, ok := <-st.events:
if !ok {
return nil
}
if m.Event == event {
return &m
}
case <-deadline:
return nil
}
}
}
func doJSON(t *testing.T, ts *httptest.Server, method, path, token string, body interface{}) *http.Response {
t.Helper()
var buf bytes.Buffer
if body != nil {
_ = json.NewEncoder(&buf).Encode(body)
}
req, _ := http.NewRequest(method, ts.URL+path, &buf)
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("%s %s: %v", method, path, err)
}
t.Cleanup(func() { _ = resp.Body.Close() })
return resp
}
func TestRealtimeEndpointsRequireAuth(t *testing.T) {
_, _, ts, _, _ := startRealtimeServer(t)
for _, path := range []string{"/api/v1/events", "/api/v1/ws", "/api/v1/events/rooms"} {
method := http.MethodGet
if strings.HasSuffix(path, "/rooms") {
method = http.MethodPost
}
req, _ := http.NewRequest(method, ts.URL+path, strings.NewReader(`{}`))
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("%s: %v", path, err)
}
_ = resp.Body.Close()
if resp.StatusCode != http.StatusUnauthorized {
t.Errorf("%s without token: want 401, got %d", path, resp.StatusCode)
}
}
}
func TestSSEStreamOutlivesWriteTimeout(t *testing.T) {
srv, _, ts, owner, token := startRealtimeServer(t)
st := openSSE(t, ts, token)
time.Sleep(1500 * time.Millisecond) // longer than the server's WriteTimeout of 1s
srv.wsHub.BroadcastToUser(owner.ID, "LATE_EVENT", map[string]string{"ok": "1"})
if st.waitFor(t, "LATE_EVENT", 2*time.Second) == nil {
t.Fatal("SSE stream was cut by the WriteTimeout")
}
}
func TestTaskEventsOnlyReachAuthorizedUsers(t *testing.T) {
_, store, ts, owner, ownerToken := startRealtimeServer(t)
member, memberToken := newTestUser(t, store, "member@example.com")
_, outsiderToken := newTestUser(t, store, "outsider@example.com")
list, err := store.CreateTaskList(owner.ID, "", "", "Haushalt", "🏠", "#000000")
if err != nil {
t.Fatalf("create list: %v", err)
}
if _, err := store.AddTaskListShare(list.ID, "user", member.ID, "viewer", owner.ID); err != nil {
t.Fatalf("share: %v", err)
}
task, err := store.CreateThoughtWithDetails(owner.ID, "Fenster putzen", "normal", "manual", "", list.ID, "todo", "")
if err != nil {
t.Fatalf("create task: %v", err)
}
memberStream := openSSE(t, ts, memberToken)
outsiderStream := openSSE(t, ts, outsiderToken)
resp := doJSON(t, ts, http.MethodPatch, "/api/v1/thoughts/"+task.ID, ownerToken, map[string]string{"content": "Fenster putzen (geheim)"})
if resp.StatusCode != http.StatusOK {
t.Fatalf("update task: status %d", resp.StatusCode)
}
if memberStream.waitFor(t, "TASK_UPDATED", 2*time.Second) == nil {
t.Error("list member did not receive TASK_UPDATED")
}
if outsiderStream.waitFor(t, "TASK_UPDATED", 500*time.Millisecond) != nil {
t.Error("outsider received TASK_UPDATED of a foreign list")
}
resp = doJSON(t, ts, http.MethodPut, "/api/v1/task-lists/"+list.ID, ownerToken, map[string]string{"title": "Haushalt 2"})
if resp.StatusCode != http.StatusOK {
t.Fatalf("update list: status %d", resp.StatusCode)
}
msg := memberStream.waitFor(t, "TASK_LIST_UPDATED", 2*time.Second)
if msg == nil {
t.Fatal("list member did not receive TASK_LIST_UPDATED")
}
if strings.Contains(string(msg.Payload), "permission") || !strings.Contains(string(msg.Payload), list.ID) {
t.Errorf("TASK_LIST_UPDATED must only carry the list ID, got %s", msg.Payload)
}
if outsiderStream.waitFor(t, "TASK_LIST_UPDATED", 500*time.Millisecond) != nil {
t.Error("outsider received TASK_LIST_UPDATED of a foreign list")
}
}
func TestSSERoomJoinRequiresAccess(t *testing.T) {
_, store, ts, owner, _ := startRealtimeServer(t)
friend, friendToken := newTestUser(t, store, "friend@example.com")
_, strangerToken := newTestUser(t, store, "stranger@example.com")
list, err := store.CreateList(owner.ID, "Einkauf", "🛒", "shopping")
if err != nil {
t.Fatalf("create list: %v", err)
}
friendStream := openSSE(t, ts, friendToken)
strangerStream := openSSE(t, ts, strangerToken)
join := func(token, sseID string) int {
resp := doJSON(t, ts, http.MethodPost, "/api/v1/events/rooms", token, map[string]string{"sseId": sseID, "room": list.ID})
return resp.StatusCode
}
if code := join(strangerToken, strangerStream.id); code != http.StatusForbidden {
t.Errorf("stranger joining a foreign list room: want 403, got %d", code)
}
if code := join(friendToken, friendStream.id); code != http.StatusForbidden {
t.Errorf("friend joining before being shared: want 403, got %d", code)
}
if _, err := store.AddListShare(owner.ID, list.ID, "user", friend.ID, "viewer"); err != nil {
t.Fatalf("share list: %v", err)
}
if code := join(strangerToken, friendStream.id); code != http.StatusForbidden {
t.Errorf("joining with someone else's sseId: want 403, got %d", code)
}
if code := join(friendToken, friendStream.id); code != http.StatusOK {
t.Errorf("friend joining shared list room: want 200, got %d", code)
}
}
func TestReorderRejectsForeignTasks(t *testing.T) {
_, store, ts, owner, _ := startRealtimeServer(t)
_, attackerToken := newTestUser(t, store, "attacker@example.com")
task, err := store.CreateThoughtWithDetails(owner.ID, "Privat", "normal", "manual", "", "", "todo", "")
if err != nil {
t.Fatalf("create task: %v", err)
}
done := "done"
resp := doJSON(t, ts, http.MethodPost, "/api/v1/tasks/reorder", attackerToken, map[string]interface{}{
"items": []domain.ThoughtReorderItem{{ID: task.ID, SortOrder: 99, KanbanStatus: &done}},
})
if resp.StatusCode == http.StatusOK {
t.Fatal("reordering a foreign task must be rejected")
}
after, _ := store.GetThoughtByID(owner.ID, task.ID)
if after.SortOrder == 99 || after.IsCompleted {
t.Fatalf("foreign task was modified: sortOrder=%d completed=%v", after.SortOrder, after.IsCompleted)
}
}
+87 -29
View File
@@ -38,10 +38,29 @@ func NewServer(cfg *config.Config, store *storage.Store, wsHub *ws.Hub, staticFS
wsHub: wsHub, wsHub: wsHub,
staticFS: staticFS, staticFS: staticFS,
} }
wsHub.SetRoomAuthorizer(srv.canJoinRoom)
go srv.startAlarmTicker() go srv.startAlarmTicker()
return srv return srv
} }
// canJoinRoom allows realtime rooms only to users with read access: "doc:<id>" for notes, otherwise a shared list ID.
func (s *Server) canJoinRoom(userID, room string) bool {
if docID, ok := strings.CutPrefix(room, "doc:"); ok {
_, err := s.store.GetDocumentByID(userID, docID)
return err == nil
}
_, err := s.store.GetListByID(userID, room)
return err == nil
}
// taskAudience returns everyone who can see a task: its owner plus all users of its task list.
func (s *Server) taskAudience(th *domain.Thought) []string {
if th == nil {
return nil
}
return append(s.store.GetTaskListUserIDs(th.TaskListID), th.UserID)
}
func (s *Server) Router() http.Handler { func (s *Server) Router() http.Handler {
r := chi.NewRouter() r := chi.NewRouter()
@@ -69,13 +88,13 @@ func (s *Server) Router() http.Handler {
r.Get("/api/v1/ws/spaces/{spaceId}", s.handleWebSocket) r.Get("/api/v1/ws/spaces/{spaceId}", s.handleWebSocket)
r.Get("/api/v1/ws/lists/{listId}", s.handleWebSocket) r.Get("/api/v1/ws/lists/{listId}", s.handleWebSocket)
r.Get("/api/v1/events", s.handleSSE) r.Get("/api/v1/events", s.handleSSE)
r.Post("/api/v1/events/rooms", s.handleSSERooms)
// Authenticated API Routes // Authenticated API Routes
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
r.Use(s.authMiddleware) r.Use(s.authMiddleware)
r.Get("/api/v1/auth/me", s.handleMe) r.Get("/api/v1/auth/me", s.handleMe)
r.Post("/api/v1/events/rooms", s.handleSSERooms)
r.Post("/api/v1/auth/logout", s.handleLogout) r.Post("/api/v1/auth/logout", s.handleLogout)
r.Post("/api/v1/auth/revoke-all", s.handleRevokeAllSessions) r.Post("/api/v1/auth/revoke-all", s.handleRevokeAllSessions)
r.Patch("/api/v1/auth/settings", s.handleUpdateUserSettings) r.Patch("/api/v1/auth/settings", s.handleUpdateUserSettings)
@@ -699,7 +718,7 @@ func (s *Server) handleCaptureInbox(w http.ResponseWriter, r *http.Request) {
} }
} }
s.wsHub.BroadcastTaskEvent(thought.UserID, "TASK_CREATED", thought) s.wsHub.BroadcastToUsers(s.taskAudience(thought), "TASK_CREATED", thought)
writeJSON(w, http.StatusCreated, thought) writeJSON(w, http.StatusCreated, thought)
} }
@@ -785,7 +804,7 @@ func (s *Server) handleTriageAction(w http.ResponseWriter, r *http.Request) {
} }
} }
s.wsHub.BroadcastTaskEvent(updated.UserID, "TASK_UPDATED", updated) s.wsHub.BroadcastToUsers(s.taskAudience(updated), "TASK_UPDATED", updated)
writeJSON(w, http.StatusOK, updated) writeJSON(w, http.StatusOK, updated)
} }
@@ -819,11 +838,19 @@ func (s *Server) handleReorderTasks(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusForbidden, storage.ErrTaskListReadOnly.Error()) writeError(w, http.StatusForbidden, storage.ErrTaskListReadOnly.Error())
return return
} }
// ReorderTasks updates by ID only, so every item must be a task the caller may edit
audience := []string{userID}
for _, it := range req.Items { for _, it := range req.Items {
if th, err := s.store.GetThoughtByID(userID, it.ID); err == nil && th.TaskListID != "" && !s.store.CanEditTaskList(userID, th.TaskListID) { th, err := s.store.GetThoughtByID(userID, it.ID)
if err != nil {
writeError(w, http.StatusNotFound, "Aufgabe nicht gefunden")
return
}
if th.TaskListID != "" && !s.store.CanEditTaskList(userID, th.TaskListID) {
writeError(w, http.StatusForbidden, storage.ErrTaskListReadOnly.Error()) writeError(w, http.StatusForbidden, storage.ErrTaskListReadOnly.Error())
return return
} }
audience = append(audience, s.taskAudience(th)...)
} }
if err := s.store.ReorderTasks(taskListID, req.Items); err != nil { if err := s.store.ReorderTasks(taskListID, req.Items); err != nil {
@@ -831,7 +858,7 @@ func (s *Server) handleReorderTasks(w http.ResponseWriter, r *http.Request) {
return return
} }
s.wsHub.BroadcastToAll("TASK_REORDERED", map[string]interface{}{ s.wsHub.BroadcastToUsers(audience, "TASK_REORDERED", map[string]interface{}{
"taskListId": taskListID, "taskListId": taskListID,
"items": req.Items, "items": req.Items,
"userId": userID, "userId": userID,
@@ -859,13 +886,14 @@ func (s *Server) handleUpdateThought(w http.ResponseWriter, r *http.Request) {
return return
} }
before, _ := s.store.GetThoughtByID(userID, thoughtID)
updated, err := s.store.UpdateThoughtDetails(userID, thoughtID, req.Content, req.IsCompleted, req.DueDate, req.Priority, req.TaskListID, req.KanbanStatus, req.Notes, req.Subtasks, req.Tags) updated, err := s.store.UpdateThoughtDetails(userID, thoughtID, req.Content, req.IsCompleted, req.DueDate, req.Priority, req.TaskListID, req.KanbanStatus, req.Notes, req.Subtasks, req.Tags)
if err != nil { if err != nil {
writeStoreError(w, http.StatusBadRequest, err) writeStoreError(w, http.StatusBadRequest, err)
return return
} }
s.store.SyncDocBlockAndTask(userID, thoughtID, updated.IsCompleted) s.store.SyncDocBlockAndTask(userID, thoughtID, updated.IsCompleted)
s.wsHub.BroadcastToAll("TASK_UPDATED", updated) s.wsHub.BroadcastToUsers(append(s.taskAudience(before), s.taskAudience(updated)...), "TASK_UPDATED", updated)
writeJSON(w, http.StatusOK, updated) writeJSON(w, http.StatusOK, updated)
} }
@@ -879,16 +907,18 @@ func (s *Server) handleUnlinkEnhancedTask(w http.ResponseWriter, r *http.Request
func (s *Server) handleDeleteThought(w http.ResponseWriter, r *http.Request) { func (s *Server) handleDeleteThought(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context()) userID := auth.GetUserID(r.Context())
thoughtID := chi.URLParam(r, "id") thoughtID := chi.URLParam(r, "id")
if th, err := s.store.GetThoughtByID(userID, thoughtID); err == nil && th.TaskListID != "" && !s.store.CanEditTaskList(userID, th.TaskListID) { th, err := s.store.GetThoughtByID(userID, thoughtID)
if err == nil && th.TaskListID != "" && !s.store.CanEditTaskList(userID, th.TaskListID) {
writeError(w, http.StatusForbidden, storage.ErrTaskListReadOnly.Error()) writeError(w, http.StatusForbidden, storage.ErrTaskListReadOnly.Error())
return return
} }
audience := s.taskAudience(th)
s.store.UnlinkTaskFromDocBlock(userID, thoughtID) s.store.UnlinkTaskFromDocBlock(userID, thoughtID)
if err := s.store.DeleteThought(userID, thoughtID); err != nil { if err := s.store.DeleteThought(userID, thoughtID); err != nil {
writeStoreError(w, http.StatusInternalServerError, err) writeStoreError(w, http.StatusInternalServerError, err)
return return
} }
s.wsHub.BroadcastToAll("TASK_DELETED", map[string]string{"id": thoughtID}) s.wsHub.BroadcastToUsers(audience, "TASK_DELETED", map[string]string{"id": thoughtID})
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true}) writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
} }
@@ -1096,7 +1126,7 @@ func (s *Server) handleCreateFolder(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, err.Error()) writeError(w, http.StatusBadRequest, err.Error())
return return
} }
s.wsHub.BroadcastDocEvent("", "WORKSPACE_FOLDERS_UPDATED", map[string]string{"path": cleanPath}) s.wsHub.BroadcastToUser(userID, "WORKSPACE_FOLDERS_UPDATED", map[string]string{"path": cleanPath})
writeJSON(w, http.StatusCreated, map[string]string{"path": cleanPath}) writeJSON(w, http.StatusCreated, map[string]string{"path": cleanPath})
} }
@@ -1117,7 +1147,7 @@ func (s *Server) handleDeleteFolder(w http.ResponseWriter, r *http.Request) {
for _, docID := range deletedDocIDs { for _, docID := range deletedDocIDs {
s.wsHub.BroadcastDocEvent(docID, "DOC_DELETED", map[string]string{"id": docID}) s.wsHub.BroadcastDocEvent(docID, "DOC_DELETED", map[string]string{"id": docID})
} }
s.wsHub.BroadcastDocEvent("", "WORKSPACE_FOLDERS_UPDATED", map[string]string{"path": folderPath}) s.wsHub.BroadcastToUser(userID, "WORKSPACE_FOLDERS_UPDATED", map[string]string{"path": folderPath})
writeJSON(w, http.StatusOK, map[string]interface{}{ writeJSON(w, http.StatusOK, map[string]interface{}{
"deleted": true, "deleted": true,
@@ -1146,7 +1176,7 @@ func (s *Server) handleMoveFolder(w http.ResponseWriter, r *http.Request) {
s.broadcastDocToAuthorizedUsers(id, "DOC_UPDATED") s.broadcastDocToAuthorizedUsers(id, "DOC_UPDATED")
} }
} }
s.wsHub.BroadcastDocEvent("", "WORKSPACE_FOLDERS_UPDATED", map[string]string{ s.wsHub.BroadcastToUser(userID, "WORKSPACE_FOLDERS_UPDATED", map[string]string{
"sourcePath": req.SourcePath, "sourcePath": req.SourcePath,
"newPath": newPath, "newPath": newPath,
}) })
@@ -1183,7 +1213,7 @@ func (s *Server) handleCreateSharedFolder(w http.ResponseWriter, r *http.Request
writeError(w, http.StatusBadRequest, err.Error()) writeError(w, http.StatusBadRequest, err.Error())
return return
} }
s.wsHub.BroadcastDocEvent("", "WORKSPACE_FOLDERS_UPDATED", folder) s.wsHub.BroadcastToUser(userID, "WORKSPACE_FOLDERS_UPDATED", folder)
writeJSON(w, http.StatusCreated, folder) writeJSON(w, http.StatusCreated, folder)
} }
@@ -1201,11 +1231,12 @@ func (s *Server) handleGetSharedFolderByID(w http.ResponseWriter, r *http.Reques
func (s *Server) handleDeleteSharedFolder(w http.ResponseWriter, r *http.Request) { func (s *Server) handleDeleteSharedFolder(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context()) userID := auth.GetUserID(r.Context())
folderID := chi.URLParam(r, "id") folderID := chi.URLParam(r, "id")
audience := s.store.GetSharedFolderUserIDs(folderID)
if err := s.store.DeleteSharedFolder(userID, folderID); err != nil { if err := s.store.DeleteSharedFolder(userID, folderID); err != nil {
writeError(w, http.StatusInternalServerError, err.Error()) writeError(w, http.StatusInternalServerError, err.Error())
return return
} }
s.wsHub.BroadcastDocEvent("", "WORKSPACE_FOLDERS_UPDATED", map[string]string{"folderId": folderID}) s.wsHub.BroadcastToUsers(append(audience, userID), "WORKSPACE_FOLDERS_UPDATED", map[string]string{"folderId": folderID})
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true}) writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
} }
@@ -1273,7 +1304,7 @@ func (s *Server) handleCreateFolderShare(w http.ResponseWriter, r *http.Request)
writeError(w, http.StatusBadRequest, err.Error()) writeError(w, http.StatusBadRequest, err.Error())
return return
} }
s.wsHub.BroadcastDocEvent("", "WORKSPACE_FOLDERS_UPDATED", share) s.wsHub.BroadcastToUsers(s.store.GetSharedFolderUserIDs(folderID), "WORKSPACE_FOLDERS_UPDATED", map[string]string{"folderId": folderID})
if docIDs, err := s.store.GetSharedFolderDocIDs(folderID); err == nil { if docIDs, err := s.store.GetSharedFolderDocIDs(folderID); err == nil {
for _, docID := range docIDs { for _, docID := range docIDs {
s.broadcastDocToAuthorizedUsers(docID, "DOC_CREATED") s.broadcastDocToAuthorizedUsers(docID, "DOC_CREATED")
@@ -1287,13 +1318,24 @@ func (s *Server) handleDeleteFolderShare(w http.ResponseWriter, r *http.Request)
folderID := chi.URLParam(r, "id") folderID := chi.URLParam(r, "id")
shareID := chi.URLParam(r, "shareId") shareID := chi.URLParam(r, "shareId")
docIDs, _ := s.store.GetSharedFolderDocIDs(folderID) docIDs, _ := s.store.GetSharedFolderDocIDs(folderID)
audienceBefore := s.store.GetSharedFolderUserIDs(folderID)
if err := s.store.DeleteFolderShare(userID, folderID, shareID); err != nil { if err := s.store.DeleteFolderShare(userID, folderID, shareID); err != nil {
writeError(w, http.StatusInternalServerError, err.Error()) writeError(w, http.StatusInternalServerError, err.Error())
return return
} }
s.wsHub.BroadcastDocEvent("", "WORKSPACE_FOLDERS_UPDATED", map[string]string{"folderId": folderID, "shareId": shareID}) audienceAfter := s.store.GetSharedFolderUserIDs(folderID)
stillAllowed := make(map[string]bool, len(audienceAfter))
for _, uid := range audienceAfter {
stillAllowed[uid] = true
}
s.wsHub.BroadcastToUsers(append(audienceBefore, audienceAfter...), "WORKSPACE_FOLDERS_UPDATED", map[string]string{"folderId": folderID})
for _, docID := range docIDs { for _, docID := range docIDs {
s.wsHub.BroadcastToUser(shareID, "DOC_DELETED", map[string]string{"id": docID}) // Users who lost access remove the notes; the rest refresh them
for _, uid := range audienceBefore {
if !stillAllowed[uid] {
s.wsHub.BroadcastToUser(uid, "DOC_DELETED", map[string]string{"id": docID})
}
}
s.broadcastDocToAuthorizedUsers(docID, "DOC_UPDATED") s.broadcastDocToAuthorizedUsers(docID, "DOC_UPDATED")
} }
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true}) writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
@@ -1316,7 +1358,8 @@ func (s *Server) handleMoveDocument(w http.ResponseWriter, r *http.Request) {
} }
s.wsHub.BroadcastDocEvent(docID, "DOC_UPDATED", doc) s.wsHub.BroadcastDocEvent(docID, "DOC_UPDATED", doc)
s.broadcastDocToAuthorizedUsers(docID, "DOC_UPDATED") s.broadcastDocToAuthorizedUsers(docID, "DOC_UPDATED")
s.wsHub.BroadcastDocEvent("", "WORKSPACE_FOLDERS_UPDATED", nil) docUsers, _ := s.store.GetAuthorizedDocUsers(docID)
s.wsHub.BroadcastToUsers(append(docUsers, userID), "WORKSPACE_FOLDERS_UPDATED", map[string]string{"docId": docID})
writeJSON(w, http.StatusOK, doc) writeJSON(w, http.StatusOK, doc)
} }
@@ -1751,7 +1794,8 @@ func (s *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) {
userID := s.authenticateUserFromReq(r) userID := s.authenticateUserFromReq(r)
if userID == "" { if userID == "" {
userID = "anonymous" writeError(w, http.StatusUnauthorized, "Nicht autorisiert – bitte anmelden")
return
} }
s.wsHub.ServeWS(w, r, roomID, userID) s.wsHub.ServeWS(w, r, roomID, userID)
} }
@@ -1764,7 +1808,8 @@ func (s *Server) handleSSE(w http.ResponseWriter, r *http.Request) {
userID := s.authenticateUserFromReq(r) userID := s.authenticateUserFromReq(r)
if userID == "" { if userID == "" {
userID = "anonymous" writeError(w, http.StatusUnauthorized, "Nicht autorisiert – bitte anmelden")
return
} }
var initialRooms []string var initialRooms []string
@@ -1784,6 +1829,7 @@ func (s *Server) handleSSE(w http.ResponseWriter, r *http.Request) {
} }
func (s *Server) handleSSERooms(w http.ResponseWriter, r *http.Request) { func (s *Server) handleSSERooms(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
var req struct { var req struct {
SSEID string `json:"sseId"` SSEID string `json:"sseId"`
Room string `json:"room"` Room string `json:"room"`
@@ -1803,9 +1849,13 @@ func (s *Server) handleSSERooms(w http.ResponseWriter, r *http.Request) {
} }
if req.Action == "leave" { if req.Action == "leave" {
s.wsHub.LeaveSSERoomByID(req.SSEID, req.Room) s.wsHub.LeaveSSERoomByID(req.SSEID, userID, req.Room)
} else { writeJSON(w, http.StatusOK, map[string]bool{"success": true})
s.wsHub.JoinSSERoomByID(req.SSEID, req.Room) return
}
if !s.wsHub.JoinSSERoomByID(req.SSEID, userID, req.Room) {
writeError(w, http.StatusForbidden, "Kein Zugriff auf diesen Raum")
return
} }
writeJSON(w, http.StatusOK, map[string]bool{"success": true}) writeJSON(w, http.StatusOK, map[string]bool{"success": true})
} }
@@ -1943,7 +1993,7 @@ func (s *Server) handleWebhookIngest(w http.ResponseWriter, r *http.Request) {
return return
} }
s.wsHub.BroadcastTaskEvent(thought.UserID, "TASK_CREATED", thought) s.wsHub.BroadcastToUsers(s.taskAudience(thought), "TASK_CREATED", thought)
writeJSON(w, http.StatusCreated, map[string]interface{}{ writeJSON(w, http.StatusCreated, map[string]interface{}{
"status": "ingested", "status": "ingested",
@@ -2180,23 +2230,25 @@ func (s *Server) handleUpdateTaskList(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "Ungültiges JSON") writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return return
} }
audienceBefore := s.store.GetTaskListUserIDs(listID)
tl, err := s.store.UpdateTaskList(userID, listID, req.Title, req.Icon, req.Color, req.TeamID, req.SpaceID, req.Columns) tl, err := s.store.UpdateTaskList(userID, listID, req.Title, req.Icon, req.Color, req.TeamID, req.SpaceID, req.Columns)
if err != nil { if err != nil {
writeStoreError(w, http.StatusBadRequest, err) writeStoreError(w, http.StatusBadRequest, err)
return return
} }
s.wsHub.BroadcastToAll("TASK_LIST_UPDATED", tl) s.broadcastTaskListChange(listID, audienceBefore, "TASK_LIST_UPDATED")
writeJSON(w, http.StatusOK, tl) writeJSON(w, http.StatusOK, tl)
} }
func (s *Server) handleDeleteTaskList(w http.ResponseWriter, r *http.Request) { func (s *Server) handleDeleteTaskList(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context()) userID := auth.GetUserID(r.Context())
listID := chi.URLParam(r, "id") listID := chi.URLParam(r, "id")
audienceBefore := s.store.GetTaskListUserIDs(listID)
if err := s.store.DeleteTaskList(userID, listID); err != nil { if err := s.store.DeleteTaskList(userID, listID); err != nil {
writeError(w, http.StatusInternalServerError, err.Error()) writeError(w, http.StatusInternalServerError, err.Error())
return return
} }
s.wsHub.BroadcastToAll("TASK_LIST_DELETED", map[string]string{"id": listID}) s.broadcastTaskListChange(listID, audienceBefore, "TASK_LIST_DELETED")
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true}) writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
} }
@@ -2245,8 +2297,7 @@ func (s *Server) handleCreateTaskListShare(w http.ResponseWriter, r *http.Reques
writeError(w, http.StatusBadRequest, err.Error()) writeError(w, http.StatusBadRequest, err.Error())
return return
} }
updatedList, _ := s.store.GetTaskListByID(userID, listID) s.broadcastTaskListChange(listID, nil, "TASK_LIST_UPDATED")
s.wsHub.BroadcastToAll("TASK_LIST_UPDATED", updatedList)
writeJSON(w, http.StatusCreated, share) writeJSON(w, http.StatusCreated, share)
} }
@@ -2263,15 +2314,22 @@ func (s *Server) handleDeleteTaskListShare(w http.ResponseWriter, r *http.Reques
writeError(w, http.StatusForbidden, "Nur der Eigentümer kann Freigaben entfernen") writeError(w, http.StatusForbidden, "Nur der Eigentümer kann Freigaben entfernen")
return return
} }
audienceBefore := s.store.GetTaskListUserIDs(listID)
if err := s.store.RemoveTaskListShare(listID, shareID); err != nil { if err := s.store.RemoveTaskListShare(listID, shareID); err != nil {
writeError(w, http.StatusInternalServerError, err.Error()) writeError(w, http.StatusInternalServerError, err.Error())
return return
} }
updatedList, _ := s.store.GetTaskListByID(userID, listID) s.broadcastTaskListChange(listID, audienceBefore, "TASK_LIST_UPDATED")
s.wsHub.BroadcastToAll("TASK_LIST_UPDATED", updatedList)
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true}) writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
} }
// broadcastTaskListChange notifies everyone who could see the list before or can see it now. Only the ID is sent,
// because the list payload depends on the receiver's permission; clients reload their lists.
func (s *Server) broadcastTaskListChange(listID string, audienceBefore []string, event string) {
users := append(audienceBefore, s.store.GetTaskListUserIDs(listID)...)
s.wsHub.BroadcastToUsers(users, event, map[string]string{"id": listID})
}
// --- ORGANIZATIONS & TEAMS --- // --- ORGANIZATIONS & TEAMS ---
func (s *Server) handleGetOrganizations(w http.ResponseWriter, r *http.Request) { func (s *Server) handleGetOrganizations(w http.ResponseWriter, r *http.Request) {
+49
View File
@@ -4747,6 +4747,55 @@ func (s *Store) GetTaskListShares(taskListID string) ([]domain.TaskListShare, er
return shares, nil return shares, nil
} }
// queryUserIDs runs a query returning a single user_id column and collects the distinct IDs.
func (s *Store) queryUserIDs(query string, args ...interface{}) []string {
rows, err := s.db.Query(query, args...)
if err != nil {
return []string{}
}
defer rows.Close()
ids := make([]string, 0)
seen := make(map[string]bool)
for rows.Next() {
var id string
if rows.Scan(&id) == nil && id != "" && !seen[id] {
seen[id] = true
ids = append(ids, id)
}
}
return ids
}
// GetTaskListUserIDs returns every user who can see the task list: owner, direct shares, members of shared teams
// and members of the legacy team_id.
func (s *Store) GetTaskListUserIDs(listID string) []string {
if listID == "" {
return []string{}
}
return s.queryUserIDs(
`SELECT user_id FROM task_lists WHERE id = ?
UNION SELECT target_id FROM task_list_shares WHERE task_list_id = ? AND target_type = 'user'
UNION SELECT tm.user_id FROM team_members tm
WHERE tm.team_id IN (SELECT target_id FROM task_list_shares WHERE task_list_id = ? AND target_type = 'team')
OR tm.team_id IN (SELECT team_id FROM task_lists WHERE id = ? AND team_id != '')`,
listID, listID, listID, listID,
)
}
// GetSharedFolderUserIDs returns the owner of a shared workspace folder and everyone it is shared with.
func (s *Store) GetSharedFolderUserIDs(folderID string) []string {
if folderID == "" {
return []string{}
}
return s.queryUserIDs(
`SELECT owner_id FROM workspace_shared_folders WHERE id = ?
UNION SELECT target_id FROM workspace_folder_shares WHERE folder_id = ? AND target_type = 'user'
UNION SELECT tm.user_id FROM team_members tm
WHERE tm.team_id IN (SELECT target_id FROM workspace_folder_shares WHERE folder_id = ? AND target_type = 'team')`,
folderID, folderID, folderID,
)
}
func (s *Store) getMembersForTaskList(tl *domain.TaskList) []domain.TaskListMember { func (s *Store) getMembersForTaskList(tl *domain.TaskList) []domain.TaskListMember {
memberMap := make(map[string]domain.TaskListMember) memberMap := make(map[string]domain.TaskListMember)
+59 -37
View File
@@ -93,6 +93,27 @@ type Hub struct {
sseUserRooms map[string]map[*SSEClient]bool sseUserRooms map[string]map[*SSEClient]bool
sseRooms map[string]map[*SSEClient]bool sseRooms map[string]map[*SSEClient]bool
closed bool closed bool
// authorizeRoom decides whether a user may receive events of a room; without it every join is denied
authorizeRoom func(userID, roomID string) bool
}
// SetRoomAuthorizer installs the permission check used for every room join (URL room, JOIN_ROOM, SSE rooms).
func (h *Hub) SetRoomAuthorizer(f func(userID, roomID string) bool) {
h.mu.Lock()
h.authorizeRoom = f
h.mu.Unlock()
}
// canJoin must be called without holding h.mu, since the authorizer queries the database.
func (h *Hub) canJoin(userID, roomID string) bool {
if roomID == "" || userID == "" || userID == "anonymous" {
return false
}
h.mu.RLock()
f := h.authorizeRoom
h.mu.RUnlock()
return f != nil && f(userID, roomID)
} }
func NewHub() *Hub { func NewHub() *Hub {
@@ -107,6 +128,9 @@ func NewHub() *Hub {
} }
func (h *Hub) Register(c *Client) { func (h *Hub) Register(c *Client) {
if c.roomID != "" && !h.canJoin(c.userID, c.roomID) {
c.roomID = ""
}
h.mu.Lock() h.mu.Lock()
h.allClients[c] = true h.allClients[c] = true
if c.roomID != "" { if c.roomID != "" {
@@ -167,6 +191,14 @@ func (h *Hub) Unregister(c *Client) {
} }
func (h *Hub) RegisterSSE(c *SSEClient) { func (h *Hub) RegisterSSE(c *SSEClient) {
if c.roomID != "" && !h.canJoin(c.userID, c.roomID) {
c.roomID = ""
}
for rID := range c.subscribedRooms {
if !h.canJoin(c.userID, rID) {
delete(c.subscribedRooms, rID)
}
}
h.mu.Lock() h.mu.Lock()
h.sseClients[c] = true h.sseClients[c] = true
if c.roomID != "" { if c.roomID != "" {
@@ -232,11 +264,15 @@ func (h *Hub) UnregisterSSE(c *SSEClient) {
h.mu.Unlock() h.mu.Unlock()
} }
func (h *Hub) JoinSSERoomByID(sseID, roomID string) bool { // JoinSSERoomByID adds the SSE connection sseID to a room; it only succeeds for the user owning that connection.
func (h *Hub) JoinSSERoomByID(sseID, userID, roomID string) bool {
if !h.canJoin(userID, roomID) {
return false
}
h.mu.Lock() h.mu.Lock()
defer h.mu.Unlock() defer h.mu.Unlock()
for client := range h.sseClients { for client := range h.sseClients {
if client.id == sseID { if client.id == sseID && client.userID == userID {
if h.sseRooms[roomID] == nil { if h.sseRooms[roomID] == nil {
h.sseRooms[roomID] = make(map[*SSEClient]bool) h.sseRooms[roomID] = make(map[*SSEClient]bool)
} }
@@ -251,11 +287,11 @@ func (h *Hub) JoinSSERoomByID(sseID, roomID string) bool {
return false return false
} }
func (h *Hub) LeaveSSERoomByID(sseID, roomID string) bool { func (h *Hub) LeaveSSERoomByID(sseID, userID, roomID string) bool {
h.mu.Lock() h.mu.Lock()
defer h.mu.Unlock() defer h.mu.Unlock()
for client := range h.sseClients { for client := range h.sseClients {
if client.id == sseID { if client.id == sseID && client.userID == userID {
if room, ok := h.sseRooms[roomID]; ok { if room, ok := h.sseRooms[roomID]; ok {
delete(room, client) delete(room, client)
if len(room) == 0 { if len(room) == 0 {
@@ -305,32 +341,6 @@ func (h *Hub) Close() {
// Broadcasting Methods // Broadcasting Methods
func (h *Hub) BroadcastToAll(event string, payload interface{}) {
msg := domain.WSMessage{
Event: event,
Payload: payload,
Timestamp: domain.NowISO(),
}
data, err := json.Marshal(msg)
if err != nil {
return
}
h.mu.RLock()
if h.closed {
h.mu.RUnlock()
return
}
defer h.mu.RUnlock()
for client := range h.allClients {
client.trySend(data)
}
for sseClient := range h.sseClients {
sseClient.trySend(data)
}
}
func (h *Hub) BroadcastToUser(userID, event string, payload interface{}) { func (h *Hub) BroadcastToUser(userID, event string, payload interface{}) {
msg := domain.WSMessage{ msg := domain.WSMessage{
Event: event, Event: event,
@@ -361,15 +371,21 @@ func (h *Hub) BroadcastToUser(userID, event string, payload interface{}) {
} }
} }
func (h *Hub) BroadcastTaskEvent(userID, event string, payload interface{}) { // BroadcastToUsers sends one event to every connection of the given users (duplicates are ignored).
h.BroadcastToUser(userID, event, payload) func (h *Hub) BroadcastToUsers(userIDs []string, event string, payload interface{}) {
seen := make(map[string]bool, len(userIDs))
for _, uid := range userIDs {
if uid == "" || seen[uid] {
continue
}
seen[uid] = true
h.BroadcastToUser(uid, event, payload)
}
} }
func (h *Hub) BroadcastDocEvent(docID, event string, payload interface{}) { func (h *Hub) BroadcastDocEvent(docID, event string, payload interface{}) {
if docID != "" { if docID != "" {
h.BroadcastRoom("doc:"+docID, event, payload, nil) h.BroadcastRoom("doc:"+docID, event, payload, nil)
} else {
h.BroadcastToAll(event, payload)
} }
} }
@@ -413,7 +429,10 @@ func (h *Hub) BroadcastListEvent(listID, event string, payload interface{}) {
h.Broadcast("", listID, event, payload) h.Broadcast("", listID, event, payload)
} }
func (h *Hub) JoinRoom(c *Client, roomID string) { func (h *Hub) JoinRoom(c *Client, roomID string) bool {
if !h.canJoin(c.userID, roomID) {
return false
}
h.mu.Lock() h.mu.Lock()
defer h.mu.Unlock() defer h.mu.Unlock()
if h.rooms[roomID] == nil { if h.rooms[roomID] == nil {
@@ -424,6 +443,7 @@ func (h *Hub) JoinRoom(c *Client, roomID string) {
c.subscribedRooms = make(map[string]bool) c.subscribedRooms = make(map[string]bool)
} }
c.subscribedRooms[roomID] = true c.subscribedRooms[roomID] = true
return true
} }
func (h *Hub) LeaveRoom(c *Client, roomID string) { func (h *Hub) LeaveRoom(c *Client, roomID string) {
@@ -512,11 +532,13 @@ func (h *Hub) ServeSSE(w http.ResponseWriter, r *http.Request, roomID, userID st
return return
} }
// The server's WriteTimeout would cut long-lived streams; lift it for this response only
_ = http.NewResponseController(w).SetWriteDeadline(time.Time{})
w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache, no-transform") w.Header().Set("Cache-Control", "no-cache, no-transform")
w.Header().Set("Connection", "keep-alive") w.Header().Set("Connection", "keep-alive")
w.Header().Set("X-Accel-Buffering", "no") w.Header().Set("X-Accel-Buffering", "no")
w.Header().Set("Access-Control-Allow-Origin", "*")
client := &SSEClient{ client := &SSEClient{
id: domain.NewID("sse"), id: domain.NewID("sse"),
@@ -591,7 +613,7 @@ func (c *Client) readPump() {
break break
} }
// Handle client-initiated lock, CRDT, room subscription, and presence messages // Handle client-initiated room subscription messages
var incoming struct { var incoming struct {
Event string `json:"event"` Event string `json:"event"`
DocID string `json:"docId"` DocID string `json:"docId"`
+36 -2
View File
@@ -22,7 +22,7 @@ func TestHubCloseAndGracefulShutdown(t *testing.T) {
} }
// Verify broadcast works // Verify broadcast works
hub.BroadcastToAll("TEST_EVENT", map[string]string{"msg": "hello"}) hub.BroadcastToUser("user-1", "TEST_EVENT", map[string]string{"msg": "hello"})
// Concurrently invoke Close() and Broadcast() to verify no panics or deadlocks // Concurrently invoke Close() and Broadcast() to verify no panics or deadlocks
var wg sync.WaitGroup var wg sync.WaitGroup
@@ -31,7 +31,7 @@ func TestHubCloseAndGracefulShutdown(t *testing.T) {
go func() { go func() {
defer wg.Done() defer wg.Done()
for i := 0; i < 50; i++ { for i := 0; i < 50; i++ {
hub.BroadcastToAll("SPAM", map[string]int{"i": i}) hub.BroadcastToUser("user-1", "SPAM", map[string]int{"i": i})
time.Sleep(1 * time.Millisecond) time.Sleep(1 * time.Millisecond)
} }
}() }()
@@ -56,3 +56,37 @@ func TestHubCloseAndGracefulShutdown(t *testing.T) {
} }
} }
} }
func TestRoomJoinsRequireAuthorization(t *testing.T) {
hub := NewHub()
c := &Client{hub: hub, userID: "user-1", send: make(chan []byte, 10)}
if hub.JoinRoom(c, "lst_1") {
t.Fatal("join must be denied without an authorizer")
}
hub.SetRoomAuthorizer(func(userID, roomID string) bool {
return userID == "user-1" && roomID == "lst_allowed"
})
if hub.JoinRoom(c, "lst_other") {
t.Fatal("join to an unauthorized room must be denied")
}
if !hub.JoinRoom(c, "lst_allowed") {
t.Fatal("join to an authorized room must succeed")
}
anon := &Client{hub: hub, userID: "anonymous", send: make(chan []byte, 10)}
if hub.JoinRoom(anon, "lst_allowed") {
t.Fatal("anonymous clients must never join rooms")
}
sse := &SSEClient{id: "sse_1", userID: "user-1", send: make(chan []byte, 10),
subscribedRooms: map[string]bool{"lst_allowed": true, "doc:secret": true}}
hub.RegisterSSE(sse)
if sse.subscribedRooms["doc:secret"] {
t.Fatal("unauthorized initial SSE rooms must be dropped")
}
if hub.JoinSSERoomByID("sse_1", "user-2", "lst_allowed") {
t.Fatal("joining with another user's sseId must fail")
}
}