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
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:
1 parent
74797e3f44
commit
bcc06da3ee
10 files changed
+526
-86
No files matched your search
+87
-29
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user