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

+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,
staticFS: staticFS,
}
wsHub.SetRoomAuthorizer(srv.canJoinRoom)
go srv.startAlarmTicker()
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 {
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/lists/{listId}", s.handleWebSocket)
r.Get("/api/v1/events", s.handleSSE)
r.Post("/api/v1/events/rooms", s.handleSSERooms)
// Authenticated API Routes
r.Group(func(r chi.Router) {
r.Use(s.authMiddleware)
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/revoke-all", s.handleRevokeAllSessions)
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)
}
@@ -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)
}
@@ -819,11 +838,19 @@ func (s *Server) handleReorderTasks(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusForbidden, storage.ErrTaskListReadOnly.Error())
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 {
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())
return
}
audience = append(audience, s.taskAudience(th)...)
}
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
}
s.wsHub.BroadcastToAll("TASK_REORDERED", map[string]interface{}{
s.wsHub.BroadcastToUsers(audience, "TASK_REORDERED", map[string]interface{}{
"taskListId": taskListID,
"items": req.Items,
"userId": userID,
@@ -859,13 +886,14 @@ func (s *Server) handleUpdateThought(w http.ResponseWriter, r *http.Request) {
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)
if err != nil {
writeStoreError(w, http.StatusBadRequest, err)
return
}
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)
}
@@ -879,16 +907,18 @@ func (s *Server) handleUnlinkEnhancedTask(w http.ResponseWriter, r *http.Request
func (s *Server) handleDeleteThought(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
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())
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
}
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})
}
@@ -1096,7 +1126,7 @@ func (s *Server) handleCreateFolder(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, err.Error())
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})
}
@@ -1117,7 +1147,7 @@ func (s *Server) handleDeleteFolder(w http.ResponseWriter, r *http.Request) {
for _, docID := range deletedDocIDs {
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{}{
"deleted": true,
@@ -1146,7 +1176,7 @@ func (s *Server) handleMoveFolder(w http.ResponseWriter, r *http.Request) {
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,
"newPath": newPath,
})
@@ -1183,7 +1213,7 @@ func (s *Server) handleCreateSharedFolder(w http.ResponseWriter, r *http.Request
writeError(w, http.StatusBadRequest, err.Error())
return
}
s.wsHub.BroadcastDocEvent("", "WORKSPACE_FOLDERS_UPDATED", folder)
s.wsHub.BroadcastToUser(userID, "WORKSPACE_FOLDERS_UPDATED", 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) {
userID := auth.GetUserID(r.Context())
folderID := chi.URLParam(r, "id")
audience := s.store.GetSharedFolderUserIDs(folderID)
if err := s.store.DeleteSharedFolder(userID, folderID); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
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})
}
@@ -1273,7 +1304,7 @@ func (s *Server) handleCreateFolderShare(w http.ResponseWriter, r *http.Request)
writeError(w, http.StatusBadRequest, err.Error())
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 {
for _, docID := range docIDs {
s.broadcastDocToAuthorizedUsers(docID, "DOC_CREATED")
@@ -1287,13 +1318,24 @@ func (s *Server) handleDeleteFolderShare(w http.ResponseWriter, r *http.Request)
folderID := chi.URLParam(r, "id")
shareID := chi.URLParam(r, "shareId")
docIDs, _ := s.store.GetSharedFolderDocIDs(folderID)
audienceBefore := s.store.GetSharedFolderUserIDs(folderID)
if err := s.store.DeleteFolderShare(userID, folderID, shareID); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
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 {
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")
}
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.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)
}
@@ -1751,7 +1794,8 @@ func (s *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) {
userID := s.authenticateUserFromReq(r)
if userID == "" {
userID = "anonymous"
writeError(w, http.StatusUnauthorized, "Nicht autorisiert – bitte anmelden")
return
}
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)
if userID == "" {
userID = "anonymous"
writeError(w, http.StatusUnauthorized, "Nicht autorisiert – bitte anmelden")
return
}
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) {
userID := auth.GetUserID(r.Context())
var req struct {
SSEID string `json:"sseId"`
Room string `json:"room"`
@@ -1803,9 +1849,13 @@ func (s *Server) handleSSERooms(w http.ResponseWriter, r *http.Request) {
}
if req.Action == "leave" {
s.wsHub.LeaveSSERoomByID(req.SSEID, req.Room)
} else {
s.wsHub.JoinSSERoomByID(req.SSEID, req.Room)
s.wsHub.LeaveSSERoomByID(req.SSEID, userID, req.Room)
writeJSON(w, http.StatusOK, map[string]bool{"success": true})
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})
}
@@ -1943,7 +1993,7 @@ func (s *Server) handleWebhookIngest(w http.ResponseWriter, r *http.Request) {
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{}{
"status": "ingested",
@@ -2180,23 +2230,25 @@ func (s *Server) handleUpdateTaskList(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
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)
if err != nil {
writeStoreError(w, http.StatusBadRequest, err)
return
}
s.wsHub.BroadcastToAll("TASK_LIST_UPDATED", tl)
s.broadcastTaskListChange(listID, audienceBefore, "TASK_LIST_UPDATED")
writeJSON(w, http.StatusOK, tl)
}
func (s *Server) handleDeleteTaskList(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
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())
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})
}
@@ -2245,8 +2297,7 @@ func (s *Server) handleCreateTaskListShare(w http.ResponseWriter, r *http.Reques
writeError(w, http.StatusBadRequest, err.Error())
return
}
updatedList, _ := s.store.GetTaskListByID(userID, listID)
s.wsHub.BroadcastToAll("TASK_LIST_UPDATED", updatedList)
s.broadcastTaskListChange(listID, nil, "TASK_LIST_UPDATED")
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")
return
}
audienceBefore := s.store.GetTaskListUserIDs(listID)
if err := s.store.RemoveTaskListShare(listID, shareID); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
updatedList, _ := s.store.GetTaskListByID(userID, listID)
s.wsHub.BroadcastToAll("TASK_LIST_UPDATED", updatedList)
s.broadcastTaskListChange(listID, audienceBefore, "TASK_LIST_UPDATED")
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 ---
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
}
// 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 {
memberMap := make(map[string]domain.TaskListMember)
+59 -37
View File
@@ -93,6 +93,27 @@ type Hub struct {
sseUserRooms map[string]map[*SSEClient]bool
sseRooms map[string]map[*SSEClient]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 {
@@ -107,6 +128,9 @@ func NewHub() *Hub {
}
func (h *Hub) Register(c *Client) {
if c.roomID != "" && !h.canJoin(c.userID, c.roomID) {
c.roomID = ""
}
h.mu.Lock()
h.allClients[c] = true
if c.roomID != "" {
@@ -167,6 +191,14 @@ func (h *Hub) Unregister(c *Client) {
}
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.sseClients[c] = true
if c.roomID != "" {
@@ -232,11 +264,15 @@ func (h *Hub) UnregisterSSE(c *SSEClient) {
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()
defer h.mu.Unlock()
for client := range h.sseClients {
if client.id == sseID {
if client.id == sseID && client.userID == userID {
if h.sseRooms[roomID] == nil {
h.sseRooms[roomID] = make(map[*SSEClient]bool)
}
@@ -251,11 +287,11 @@ func (h *Hub) JoinSSERoomByID(sseID, roomID string) bool {
return false
}
func (h *Hub) LeaveSSERoomByID(sseID, roomID string) bool {
func (h *Hub) LeaveSSERoomByID(sseID, userID, roomID string) bool {
h.mu.Lock()
defer h.mu.Unlock()
for client := range h.sseClients {
if client.id == sseID {
if client.id == sseID && client.userID == userID {
if room, ok := h.sseRooms[roomID]; ok {
delete(room, client)
if len(room) == 0 {
@@ -305,32 +341,6 @@ func (h *Hub) Close() {
// 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{}) {
msg := domain.WSMessage{
Event: event,
@@ -361,15 +371,21 @@ func (h *Hub) BroadcastToUser(userID, event string, payload interface{}) {
}
}
func (h *Hub) BroadcastTaskEvent(userID, event string, payload interface{}) {
h.BroadcastToUser(userID, event, payload)
// 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))
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{}) {
if docID != "" {
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)
}
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()
defer h.mu.Unlock()
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[roomID] = true
return true
}
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
}
// 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("Cache-Control", "no-cache, no-transform")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("X-Accel-Buffering", "no")
w.Header().Set("Access-Control-Allow-Origin", "*")
client := &SSEClient{
id: domain.NewID("sse"),
@@ -591,7 +613,7 @@ func (c *Client) readPump() {
break
}
// Handle client-initiated lock, CRDT, room subscription, and presence messages
// Handle client-initiated room subscription messages
var incoming struct {
Event string `json:"event"`
DocID string `json:"docId"`
+36 -2
View File
@@ -22,7 +22,7 @@ func TestHubCloseAndGracefulShutdown(t *testing.T) {
}
// 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
var wg sync.WaitGroup
@@ -31,7 +31,7 @@ func TestHubCloseAndGracefulShutdown(t *testing.T) {
go func() {
defer wg.Done()
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)
}
}()
@@ -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")
}
}