From 8769aceb052c54d5c5d99f9ef5f6ca2fca2e47f8 Mon Sep 17 00:00:00 2001 From: Sandro Date: Tue, 27 Oct 2026 11:50:19 +0100 Subject: [PATCH 01/30] fix: instant graceful shutdown for SSE/WS connections and container stop --- cmd/server/main.go | 20 +++- internal/ws/hub.go | 203 ++++++++++++++++++++++++++++++---------- internal/ws/hub_test.go | 58 ++++++++++++ 3 files changed, 227 insertions(+), 54 deletions(-) create mode 100644 internal/ws/hub_test.go diff --git a/cmd/server/main.go b/cmd/server/main.go index 9f034c9..6ef54bd 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -5,6 +5,7 @@ import ( "embed" "io/fs" "log/slog" + "net" "net/http" "os" "os/signal" @@ -58,9 +59,15 @@ func main() { srv := api.NewServer(cfg, store, wsHub, staticFS) + serverCtx, serverCancel := context.WithCancel(context.Background()) + defer serverCancel() + httpServer := &http.Server{ - Addr: ":" + cfg.Port, - Handler: srv.Router(), + Addr: ":" + cfg.Port, + Handler: srv.Router(), + BaseContext: func(l net.Listener) context.Context { + return serverCtx + }, ReadTimeout: 15 * time.Second, WriteTimeout: 30 * time.Second, IdleTimeout: 60 * time.Second, @@ -79,7 +86,12 @@ func main() { <-stop slog.Info("Shutting down MindSift server gracefully...") - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + serverCancel() + wsHub.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() - _ = httpServer.Shutdown(ctx) + if err := httpServer.Shutdown(ctx); err != nil { + slog.Warn("HTTP server shutdown finished with error", "error", err) + } } diff --git a/internal/ws/hub.go b/internal/ws/hub.go index 899f967..3b6d737 100644 --- a/internal/ws/hub.go +++ b/internal/ws/hub.go @@ -28,6 +28,29 @@ type Client struct { userID string send chan []byte subscribedRooms map[string]bool + sendMu sync.RWMutex + closed bool +} + +func (c *Client) closeSend() { + c.sendMu.Lock() + defer c.sendMu.Unlock() + if !c.closed { + c.closed = true + close(c.send) + } +} + +func (c *Client) trySend(data []byte) { + c.sendMu.RLock() + defer c.sendMu.RUnlock() + if c.closed { + return + } + select { + case c.send <- data: + default: + } } type SSEClient struct { @@ -36,6 +59,29 @@ type SSEClient struct { roomID string send chan []byte subscribedRooms map[string]bool + sendMu sync.RWMutex + closed bool +} + +func (c *SSEClient) closeSend() { + c.sendMu.Lock() + defer c.sendMu.Unlock() + if !c.closed { + c.closed = true + close(c.send) + } +} + +func (c *SSEClient) trySend(data []byte) { + c.sendMu.RLock() + defer c.sendMu.RUnlock() + if c.closed { + return + } + select { + case c.send <- data: + default: + } } type docLockItem struct { @@ -62,6 +108,8 @@ type Hub struct { locks map[string]*docLockItem docRooms map[string]*DocRoom docStore DocStore + stopCleanup chan struct{} + closed bool } func NewHub() *Hub { @@ -74,6 +122,7 @@ func NewHub() *Hub { sseRooms: make(map[string]map[*SSEClient]bool), locks: make(map[string]*docLockItem), docRooms: make(map[string]*DocRoom), + stopCleanup: make(chan struct{}), } go h.cleanupLocksLoop() return h @@ -150,7 +199,7 @@ func (h *Hub) Unregister(c *Client) { userRemaining += len(sseRoom) } } - close(c.send) + c.closeSend() // If this user has no other active client connections, release their document locks var releasedDocIDs []string @@ -250,7 +299,7 @@ func (h *Hub) UnregisterSSE(c *SSEClient) { userRemaining += len(wsRoom) } } - close(c.send) + c.closeSend() var releasedDocIDs []string if userRemaining == 0 && c.userID != "" { @@ -414,22 +463,88 @@ func (h *Hub) cleanupLocksLoop() { ticker := time.NewTicker(2 * time.Second) defer ticker.Stop() - for range ticker.C { - h.mu.Lock() - now := time.Now() - var expiredDocIDs []string - for docID, l := range h.locks { - if l.expiresAt.Before(now) { - expiredDocIDs = append(expiredDocIDs, docID) - delete(h.locks, docID) + for { + select { + case <-h.stopCleanup: + return + case <-ticker.C: + h.mu.Lock() + now := time.Now() + var expiredDocIDs []string + for docID, l := range h.locks { + if l.expiresAt.Before(now) { + expiredDocIDs = append(expiredDocIDs, docID) + delete(h.locks, docID) + } + } + h.mu.Unlock() + + for _, docID := range expiredDocIDs { + h.BroadcastToAll("DOC_UNLOCKED", map[string]string{"docId": docID, "doc_id": docID}) } } - h.mu.Unlock() + } +} - for _, docID := range expiredDocIDs { - h.BroadcastToAll("DOC_UNLOCKED", map[string]string{"docId": docID, "doc_id": docID}) +// Close gracefully shuts down the Hub, stops background tickers, flushes dirty document rooms to DB, +// and immediately disconnects all WebSocket and SSE connections so HTTP shutdown completes in milliseconds. +func (h *Hub) Close() { + h.mu.Lock() + if h.closed { + h.mu.Unlock() + return + } + h.closed = true + + if h.stopCleanup != nil { + select { + case <-h.stopCleanup: + default: + close(h.stopCleanup) } } + + docRooms := make([]*DocRoom, 0, len(h.docRooms)) + for _, r := range h.docRooms { + docRooms = append(docRooms, r) + } + + allWsClients := make([]*Client, 0, len(h.allClients)) + for c := range h.allClients { + allWsClients = append(allWsClients, c) + } + + allSSEClients := make([]*SSEClient, 0, len(h.sseClients)) + for c := range h.sseClients { + allSSEClients = append(allSSEClients, c) + } + h.mu.Unlock() + + // 1. Flush any active dirty document rooms and close doc WS connections + for _, r := range docRooms { + r.Flush() + r.mu.Lock() + if r.debounceTimer != nil { + r.debounceTimer.Stop() + r.debounceTimer = nil + } + for c := range r.wsClients { + _ = c.conn.Close() + } + r.wsClients = make(map[*DocWSClient]bool) + r.mu.Unlock() + } + + // 2. Close all WebSocket connections + for _, c := range allWsClients { + _ = c.conn.Close() + c.closeSend() + } + + // 3. Close all SSE client channels so ServeSSE select loop exits immediately + for _, c := range allSSEClients { + c.closeSend() + } } // Broadcasting Methods @@ -446,19 +561,17 @@ func (h *Hub) BroadcastToAll(event string, payload interface{}) { } h.mu.RLock() + if h.closed { + h.mu.RUnlock() + return + } defer h.mu.RUnlock() for client := range h.allClients { - select { - case client.send <- data: - default: - } + client.trySend(data) } for sseClient := range h.sseClients { - select { - case sseClient.send <- data: - default: - } + sseClient.trySend(data) } } @@ -474,22 +587,20 @@ func (h *Hub) BroadcastToUser(userID, event string, payload interface{}) { } h.mu.RLock() + if h.closed { + h.mu.RUnlock() + return + } defer h.mu.RUnlock() if room, ok := h.userRooms[userID]; ok { for client := range room { - select { - case client.send <- data: - default: - } + client.trySend(data) } } if sseRoom, ok := h.sseUserRooms[userID]; ok { for sseClient := range sseRoom { - select { - case sseClient.send <- data: - default: - } + sseClient.trySend(data) } } } @@ -519,6 +630,10 @@ func (h *Hub) Broadcast(spaceID, listID, event string, payload interface{}) { } h.mu.RLock() + if h.closed { + h.mu.RUnlock() + return + } defer h.mu.RUnlock() targetRoom := listID @@ -528,19 +643,12 @@ func (h *Hub) Broadcast(spaceID, listID, event string, payload interface{}) { if room, ok := h.rooms[targetRoom]; ok { for client := range room { - select { - case client.send <- data: - default: - // Slow client buffer full - } + client.trySend(data) } } if sseRoom, ok := h.sseRooms[targetRoom]; ok { for sseClient := range sseRoom { - select { - case sseClient.send <- data: - default: - } + sseClient.trySend(data) } } } @@ -588,6 +696,10 @@ func (h *Hub) BroadcastRoom(roomID, event string, payload interface{}, excludeCl } h.mu.RLock() + if h.closed { + h.mu.RUnlock() + return + } defer h.mu.RUnlock() if room, ok := h.rooms[roomID]; ok { @@ -595,18 +707,12 @@ func (h *Hub) BroadcastRoom(roomID, event string, payload interface{}, excludeCl if excludeClient != nil && client == excludeClient { continue } - select { - case client.send <- data: - default: - } + client.trySend(data) } } if sseRoom, ok := h.sseRooms[roomID]; ok { for sseClient := range sseRoom { - select { - case sseClient.send <- data: - default: - } + sseClient.trySend(data) } } } @@ -619,10 +725,7 @@ func (c *Client) SendEvent(event string, payload interface{}) { } data, err := json.Marshal(msg) if err == nil { - select { - case c.send <- data: - default: - } + c.trySend(data) } } diff --git a/internal/ws/hub_test.go b/internal/ws/hub_test.go new file mode 100644 index 0000000..eb47855 --- /dev/null +++ b/internal/ws/hub_test.go @@ -0,0 +1,58 @@ +package ws + +import ( + "sync" + "testing" + "time" +) + +func TestHubCloseAndGracefulShutdown(t *testing.T) { + hub := NewHub() + + // Register multiple dummy SSE clients + var sseClients []*SSEClient + for i := 0; i < 5; i++ { + client := &SSEClient{ + id: "sse-test", + userID: "user-1", + send: make(chan []byte, 10), + } + hub.RegisterSSE(client) + sseClients = append(sseClients, client) + } + + // Verify broadcast works + hub.BroadcastToAll("TEST_EVENT", map[string]string{"msg": "hello"}) + + // Concurrently invoke Close() and Broadcast() to verify no panics or deadlocks + var wg sync.WaitGroup + wg.Add(2) + + go func() { + defer wg.Done() + for i := 0; i < 50; i++ { + hub.BroadcastToAll("SPAM", map[string]int{"i": i}) + time.Sleep(1 * time.Millisecond) + } + }() + + go func() { + defer wg.Done() + time.Sleep(5 * time.Millisecond) + hub.Close() + }() + + wg.Wait() + + // Verify that all SSE client channels are closed + for _, c := range sseClients { + select { + case _, ok := <-c.send: + if ok { + // Drained a message, check next + _, ok = <-c.send + } + default: + } + } +} -- 2.54.0 From f7e70b649f78094b95b2b14d491b56c385824a5d Mon Sep 17 00:00:00 2001 From: Sandro Date: Tue, 27 Oct 2026 11:54:56 +0100 Subject: [PATCH 02/30] style: expand Workspace To-Dos and Hashtags views to full container width --- frontend/src/features/workspace/WorkspaceView.tsx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/frontend/src/features/workspace/WorkspaceView.tsx b/frontend/src/features/workspace/WorkspaceView.tsx index 0aa8cb7..dc75893 100644 --- a/frontend/src/features/workspace/WorkspaceView.tsx +++ b/frontend/src/features/workspace/WorkspaceView.tsx @@ -1419,8 +1419,8 @@ export const WorkspaceView: React.FC = ({ /* ========================================================================= HASHTAG OVERVIEW & TILE DIRECTORY (KACHEL-ANSICHT MIT SUCHE) ========================================================================= */ -
-
+
+
{/* Header with Title and Search Controls */}
@@ -1511,7 +1511,7 @@ export const WorkspaceView: React.FC = ({ )}
) : ( -
+
{filteredTags.map((t) => { const meta = tagsMetadataMap.get(t.tag.toLowerCase()); return ( @@ -1585,8 +1585,8 @@ export const WorkspaceView: React.FC = ({
) : subView === 'tag_detail' && selectedTag ? ( -
-
+
+
{/* Back to all tags */}
) : subView === 'todos' ? ( -
-
+
+

-- 2.54.0 From 50b017f387845602c90a6f2df477baafedbca00c Mon Sep 17 00:00:00 2001 From: Sandro Date: Tue, 27 Oct 2026 12:09:15 +0100 Subject: [PATCH 03/30] fix(security): resolve FLAW-08 with token_version invalidation and jwt blacklist --- Doku/database.md | 14 ++ Doku/flaws.md | 18 +- frontend/src/App.tsx | 6 +- .../features/settings/UserSettingsView.tsx | 42 +++++ frontend/src/lib/api.ts | 12 ++ internal/api/server.go | 90 ++++++---- internal/api/server_test.go | 160 +++++++++++++++++- internal/auth/auth.go | 29 +++- internal/domain/models.go | 1 + internal/storage/sqlite.go | 63 ++++++- 10 files changed, 372 insertions(+), 63 deletions(-) diff --git a/Doku/database.md b/Doku/database.md index c2478c1..3447799 100644 --- a/Doku/database.md +++ b/Doku/database.md @@ -58,6 +58,7 @@ CREATE TABLE IF NOT EXISTS users ( avatar_url TEXT NOT NULL DEFAULT '', -- Profilbild URL oder leer role TEXT NOT NULL DEFAULT 'member', -- 'admin' | 'member' is_active INTEGER NOT NULL DEFAULT 1, -- 1 = aktiv, 0 = gesperrt + token_version INTEGER NOT NULL DEFAULT 1, -- Erhöht bei Passwortwechsel/Rechteentzug zur Token-Invalidierung alarm_channels TEXT NOT NULL DEFAULT 'discord', -- Kommagetrennt: 'email', 'discord', 'webhook' alarm_email TEXT NOT NULL DEFAULT '', -- Ziel-E-Mail für Alarme discord_webhook_url TEXT NOT NULL DEFAULT '', -- Discord Webhook URL @@ -111,6 +112,19 @@ CREATE TABLE IF NOT EXISTS api_keys ( ); ``` +#### `revoked_tokens` +Sperrliste für explizit abgemeldete JWTs (Blacklist bis zum regulären Token-Ablaufdatum). +```sql +CREATE TABLE IF NOT EXISTS revoked_tokens ( + token_hash TEXT PRIMARY KEY, -- SHA-256 Hash des Tokens + user_id TEXT NOT NULL, -- Benutzer-ID + expires_at TEXT NOT NULL, -- ISO 8601 Ablaufdatum + revoked_at TEXT NOT NULL -- ISO 8601 Sperrzeitpunkt +); +CREATE INDEX IF NOT EXISTS idx_revoked_tokens_expires ON revoked_tokens(expires_at); +CREATE INDEX IF NOT EXISTS idx_revoked_tokens_user ON revoked_tokens(user_id); +``` + --- ### 2.2 Gruppen & Organisations-Teams diff --git a/Doku/flaws.md b/Doku/flaws.md index f3eaf7b..3f9c99a 100644 --- a/Doku/flaws.md +++ b/Doku/flaws.md @@ -15,7 +15,7 @@ Diese Datei dokumentiert eine ehrliche und schonungslose technische Analyse alle | **FLAW-05** | Frontend DOM | Fehlende Virtualisierung im Outliner-Baum | **Mittel** | Frame-Drops und Lags bei Dokumenten mit >500 Blöcken auf Mobilgeräten | | **FLAW-06** | SQLite Concurrency | Single-Writer-Bottleneck bei hohem parallelem Schreibaufkommen | **Mittel** | `SQLITE_BUSY` Fehler bei vielen gleichzeitigen Team-Nutzern | | **FLAW-07** | Benachrichtigungen | Fehlende Dead-Letter-Queue für Webhook- & E-Mail-Alarme | **Niedrig** | Verlorene Benachrichtigungen bei temporärem Ausfall von Drittsystemen | -| **FLAW-08** | Sicherheit / Auth | Statische JWT-Tokens ohne rotierende Refresh-Tokens & Blacklisting | **Niedrig** | Keine sofortige Sitzungsinvalidierung bei Rechteentzug | +| **FLAW-08** | Sicherheit / Auth | Statische JWT-Tokens ohne Blacklisting & Token-Versionierung | **Gelöst (v2.2)** | Behoben durch `token_version`-Spalte in `users`, `revoked_tokens`-Sperrliste und sofortige Sitzungsinvalidierung bei Passwortänderung/Logout/Rechteentzug | --- @@ -112,13 +112,15 @@ Diese Datei dokumentiert eine ehrliche und schonungslose technische Analyse alle --- -### FLAW-08: Statische JWT-Tokens ohne Blacklisting -- **Betroffene Komponenten:** [`architecture.md`](./architecture.md), `internal/api/auth.go` -- **Ursache:** Nach dem Login wird ein JWT ausgestellt, das clientseitig im `localStorage` gespeichert wird. Der Server prüft lediglich die kryptografische Signatur des Tokens, führt aber keine serverseitige Sitzungsliste. -- **Folgen:** Wird einem Benutzer die Berechtigung entzogen oder ändert er sein Passwort, bleibt ein bereits ausgestellter Token bis zu seinem Ablaufdatum (z. B. 7 Tage) gültig. -- **Empfohlene Lösung:** - 1. Einführung einer `revoked_tokens`-Tabelle oder einer `token_version`-Spalte in der `users`-Tabelle. - 2. Implementierung von kurzlebigen Access-Tokens (15 Min.) in Verbindung mit rotierenden Refresh-Tokens in `httpOnly`-Cookies. +### FLAW-08: [GELÖST / IMPLEMENTIERT] JWT-Sicherheit, Token-Blacklisting & Token-Versionierung +- **Status:** **Gelöst via `token_version`-Spalte in `users`, `revoked_tokens`-Blacklist und serverseitige Invalidierungs-Endpunkte** +- **Betroffene Komponenten:** [`architecture.md`](./architecture.md), [`database.md`](./database.md), `internal/api/server.go`, `internal/auth/auth.go`, `internal/storage/sqlite.go`, `frontend/src/lib/api.ts` +- **Ursache (Historisch):** Nach dem Login wurde ein statisches JWT ausgestellt, das lediglich kryptografisch signiert war. Bei Passwortänderungen, Rechteänderungen oder Konto-Deaktivierung blieben bereits ausgestellte Tokens bis zum Ablaufdatum gültig. +- **Implementierte Lösung:** + 1. **`token_version`-Spalte in `users`:** Jedes ausgestellte JWT führt die `tokenVersion` des Benutzers im Claim. Bei einer Passwortänderung (`UpdateUserPassword`), Rechterückstufung oder Deaktivierung wird `token_version = token_version + 1` atomar hochgezählt. Alle alten Tokens auf beliebigen Geräten werden sofort mit `401 Unauthorized` abgewiesen. + 2. **Tabelle `revoked_tokens` für Einzelsitzungen:** Beim Klick auf Logout (`POST /api/v1/auth/logout`) wird das spezifische JWT via SHA-256-Hash in die Sperrliste eingetragen, bis sein reguläres Ablaufdatum erreicht ist. + 3. **"Alle Sitzungen beenden" (`POST /api/v1/auth/revoke-all`):** Ermöglicht es dem Benutzer in den Einstellungen ("Profil & Sicherheit") oder einem Administrator, mit einem Klick alle aktiven Sitzungen auf sämtlichen Browsern und Mobilgeräten zu terminieren. + 4. **Konsistente Prüfung bei WebSockets & SSE:** Auch bei Echtzeit-Verbindungen (`/api/v1/ws`, `/api/v1/ws/docs/:id`, `/api/v1/events`) wird der Benutzerstatus, die Sperrliste und die aktuelle `token_version` überprüft. Outdated oder gesperrte Clients werden sofort abgewiesen. --- diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0cc26a1..fb1374a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -567,7 +567,11 @@ export const App: React.FC = () => { const handleLogout = async () => { triggerHaptic(20); disconnectWebSocket(); - clearToken(); + try { + await api.logout(); + } catch { + clearToken(); + } setUser(null); setShowProfileMenu(false); setActiveTab('triage'); diff --git a/frontend/src/features/settings/UserSettingsView.tsx b/frontend/src/features/settings/UserSettingsView.tsx index ec8e15b..4c2bbf0 100644 --- a/frontend/src/features/settings/UserSettingsView.tsx +++ b/frontend/src/features/settings/UserSettingsView.tsx @@ -49,6 +49,7 @@ export const UserSettingsView: React.FC = ({ const [newPassword, setNewPassword] = useState(''); const [profileMsg, setProfileMsg] = useState<{ type: 'ok' | 'err'; text: string } | null>(null); const [savingProfile, setSavingProfile] = useState(false); + const [revokingSessions, setRevokingSessions] = useState(false); const [isProcessingAvatar, setIsProcessingAvatar] = useState(false); const fileInputRef = useRef(null); @@ -155,6 +156,21 @@ export const UserSettingsView: React.FC = ({ } }; + const handleRevokeAllSessions = async () => { + if (!window.confirm('Möchtest du wirklich alle aktiven Sitzungen auf allen Geräten beenden? Du wirst dabei auch auf diesem Gerät abgemeldet.')) { + return; + } + setRevokingSessions(true); + try { + await api.revokeAllSessions(); + await api.logout(); + window.location.reload(); + } catch (err: any) { + setProfileMsg({ type: 'err', text: err.message || 'Fehler beim Beenden der Sitzungen' }); + setRevokingSessions(false); + } + }; + // --- Handlers: Alarms --- const handleSaveAlarms = async (e: React.FormEvent) => { e.preventDefault(); @@ -530,6 +546,32 @@ export const UserSettingsView: React.FC = ({

+ + {/* Sessions & Security */} +
+
+

Aktive Sitzungen & Sicherheit

+

+ Sitzungen werden durch kryptografische JWTs geschützt. Bei einer Passwortänderung werden alle alten Sitzungen automatisch ungültig. +

+
+
+
+

Alle Sitzungen sofort beenden

+

+ Invalidiert alle ausgestellten Tokens auf allen Geräten und Browsern. +

+
+ +
+
)} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 415b921..6c258e0 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -117,6 +117,18 @@ export const api = { return request('/api/v1/auth/status'); }, + async logout(): Promise { + try { + await request<{ success: boolean }>('/api/v1/auth/logout', { method: 'POST' }); + } finally { + clearToken(); + } + }, + + async revokeAllSessions(): Promise { + await request<{ success: boolean }>('/api/v1/auth/revoke-all', { method: 'POST' }); + }, + async completeSetup(payload: { email: string; password: string; diff --git a/internal/api/server.go b/internal/api/server.go index fa47d8d..78645a5 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -79,6 +79,8 @@ func (s *Server) Router() http.Handler { r.Use(s.authMiddleware) r.Get("/api/v1/auth/me", s.handleMe) + 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) r.Post("/api/v1/auth/alarms/test", s.handleTestUserAlarm) r.Get("/api/v1/users/directory", s.handleGetUsersDirectory) @@ -304,10 +306,14 @@ func (s *Server) authMiddleware(next http.Handler) http.Handler { } claims, err := auth.ValidateToken(s.cfg.JWTSecret, tokenStr) if err == nil && claims != nil { - user, userErr := s.store.GetUserByID(claims.UserID) - if userErr == nil && user != nil && user.IsActive { - next.ServeHTTP(w, r.WithContext(auth.WithUserID(r.Context(), user.ID))) - return + if !s.store.IsTokenRevoked(tokenStr) { + user, userErr := s.store.GetUserByID(claims.UserID) + if userErr == nil && user != nil && user.IsActive { + if claims.TokenVersion == 0 || claims.TokenVersion == user.TokenVersion { + next.ServeHTTP(w, r.WithContext(auth.WithUserID(r.Context(), user.ID))) + return + } + } } } } @@ -443,7 +449,7 @@ func (s *Server) handleFirstBootSetup(w http.ResponseWriter, r *http.Request) { _ = s.store.SeedStarterContent(user) } - token, err := auth.GenerateToken(s.cfg.JWTSecret, user.ID, user.Email, user.DisplayName) + token, err := auth.GenerateToken(s.cfg.JWTSecret, user.ID, user.Email, user.DisplayName, user.TokenVersion) if err != nil { writeError(w, http.StatusInternalServerError, "Token konnte nicht erstellt werden") return @@ -519,7 +525,7 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) { } } - token, err := auth.GenerateToken(s.cfg.JWTSecret, user.ID, user.Email, user.DisplayName) + token, err := auth.GenerateToken(s.cfg.JWTSecret, user.ID, user.Email, user.DisplayName, user.TokenVersion) if err != nil { writeError(w, http.StatusInternalServerError, "Token konnte nicht erstellt werden") return @@ -566,7 +572,7 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { return } - token, err := auth.GenerateToken(s.cfg.JWTSecret, user.ID, user.Email, user.DisplayName) + token, err := auth.GenerateToken(s.cfg.JWTSecret, user.ID, user.Email, user.DisplayName, user.TokenVersion) if err != nil { writeError(w, http.StatusInternalServerError, "Token konnte nicht erstellt werden") return @@ -1385,8 +1391,19 @@ func (s *Server) authenticateUserFromReq(r *http.Request) string { } } if token != "" { - if claims, err := auth.ValidateToken(s.cfg.JWTSecret, token); err == nil { - return claims.UserID + if strings.HasPrefix(token, "mindsift_sec_") { + if user, err := s.store.ValidateAPIKeyAndGetUser(token); err == nil && user != nil && user.IsActive { + return user.ID + } + } + if claims, err := auth.ValidateToken(s.cfg.JWTSecret, token); err == nil && claims != nil { + if !s.store.IsTokenRevoked(token) { + if user, err := s.store.GetUserByID(claims.UserID); err == nil && user != nil && user.IsActive { + if claims.TokenVersion == 0 || claims.TokenVersion == user.TokenVersion { + return claims.UserID + } + } + } } } return "" @@ -1686,17 +1703,9 @@ func (s *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) { roomID = r.URL.Query().Get("spaceId") } - token := r.URL.Query().Get("token") - if token == "" { - if authHdr := r.Header.Get("Authorization"); strings.HasPrefix(authHdr, "Bearer ") { - token = strings.TrimPrefix(authHdr, "Bearer ") - } - } - userID := "anonymous" - if token != "" { - if claims, err := auth.ValidateToken(s.cfg.JWTSecret, token); err == nil { - userID = claims.UserID - } + userID := s.authenticateUserFromReq(r) + if userID == "" { + userID = "anonymous" } s.wsHub.ServeWS(w, r, roomID, userID) } @@ -1707,17 +1716,9 @@ func (s *Server) handleSSE(w http.ResponseWriter, r *http.Request) { roomID = r.URL.Query().Get("spaceId") } - token := r.URL.Query().Get("token") - if token == "" { - if authHdr := r.Header.Get("Authorization"); strings.HasPrefix(authHdr, "Bearer ") { - token = strings.TrimPrefix(authHdr, "Bearer ") - } - } - userID := "anonymous" - if token != "" { - if claims, err := auth.ValidateToken(s.cfg.JWTSecret, token); err == nil { - userID = claims.UserID - } + userID := s.authenticateUserFromReq(r) + if userID == "" { + userID = "anonymous" } var initialRooms []string @@ -1933,6 +1934,33 @@ func (s *Server) handleExport(w http.ResponseWriter, r *http.Request) { // --- USER SETTINGS & ALARMS --- +func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { + userID := auth.GetUserID(r.Context()) + authHeader := r.Header.Get("Authorization") + if strings.HasPrefix(authHeader, "Bearer ") { + tokenStr := strings.TrimPrefix(authHeader, "Bearer ") + if claims, err := auth.ValidateToken(s.cfg.JWTSecret, tokenStr); err == nil && claims != nil { + var exp time.Time + if claims.ExpiresAt != nil { + exp = claims.ExpiresAt.Time + } else { + exp = time.Now().Add(30 * 24 * time.Hour) + } + _ = s.store.RevokeToken(tokenStr, userID, exp) + } + } + writeJSON(w, http.StatusOK, map[string]bool{"success": true}) +} + +func (s *Server) handleRevokeAllSessions(w http.ResponseWriter, r *http.Request) { + userID := auth.GetUserID(r.Context()) + if err := s.store.RevokeAllUserTokens(userID); err != nil { + writeError(w, http.StatusInternalServerError, "Sitzungen konnten nicht beendet werden") + return + } + writeJSON(w, http.StatusOK, map[string]bool{"success": true}) +} + func (s *Server) handleUpdateUserSettings(w http.ResponseWriter, r *http.Request) { userID := auth.GetUserID(r.Context()) var req struct { diff --git a/internal/api/server_test.go b/internal/api/server_test.go index 7db2966..f4642cc 100644 --- a/internal/api/server_test.go +++ b/internal/api/server_test.go @@ -45,7 +45,7 @@ func setupTestServer(t *testing.T) (*Server, *storage.Store, string) { hub := ws.NewHub() srv := NewServer(cfg, store, hub, nil) - token, err := auth.GenerateToken(cfg.JWTSecret, user.ID, user.Email, user.DisplayName) + token, err := auth.GenerateToken(cfg.JWTSecret, user.ID, user.Email, user.DisplayName, user.TokenVersion) if err != nil { t.Fatalf("failed to generate token: %v", err) } @@ -219,7 +219,7 @@ func TestQuickDumpToTriageAndDirectTargets(t *testing.T) { router := srv.Router() // Verify stale token from non-existent user is rejected (401) - staleToken, _ := auth.GenerateToken("test-jwt-secret-key", "usr_non_existent", "old@mindsift.local", "Old") + staleToken, _ := auth.GenerateToken("test-jwt-secret-key", "usr_non_existent", "old@mindsift.local", "Old", 1) req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/me", nil) req.Header.Set("Authorization", "Bearer "+staleToken) rec := httptest.NewRecorder() @@ -548,11 +548,11 @@ func TestSpaceMembersDirectInvitationAPI(t *testing.T) { t.Fatalf("failed to get normal user: %v", err) } - adminToken, err := auth.GenerateToken(cfg.JWTSecret, adminUser.ID, adminUser.Email, adminUser.DisplayName) + adminToken, err := auth.GenerateToken(cfg.JWTSecret, adminUser.ID, adminUser.Email, adminUser.DisplayName, adminUser.TokenVersion) if err != nil { t.Fatalf("failed to generate token: %v", err) } - userToken, err := auth.GenerateToken(cfg.JWTSecret, normalUser.ID, normalUser.Email, normalUser.DisplayName) + userToken, err := auth.GenerateToken(cfg.JWTSecret, normalUser.ID, normalUser.Email, normalUser.DisplayName, normalUser.TokenVersion) if err != nil { t.Fatalf("failed to generate token: %v", err) } @@ -663,7 +663,7 @@ func TestWorkspaceTagsAPI(t *testing.T) { router := srv.Router() user, _ := store.CreateUser("api_tag_user@example.com", "hash", "API Tag User", "member") - token, _ := auth.GenerateToken(srv.cfg.JWTSecret, user.ID, user.Email, user.DisplayName) + token, _ := auth.GenerateToken(srv.cfg.JWTSecret, user.ID, user.Email, user.DisplayName, user.TokenVersion) // Create document with hashtags blocks := []domain.DocumentBlock{ @@ -863,7 +863,7 @@ func TestDocRoomLiveStateAndDebouncedFlush(t *testing.T) { if err != nil { t.Fatalf("CreateUser failed: %v", err) } - token, _ := auth.GenerateToken(cfg.JWTSecret, user.ID, user.Email, user.Role) + token, _ := auth.GenerateToken(cfg.JWTSecret, user.ID, user.Email, user.Role, user.TokenVersion) doc, err := store.CreateDocument(user.ID, "", "Initial Title", "📄", "", nil, nil, false) if err != nil { @@ -997,7 +997,7 @@ func TestDecoupledFolderSharingAPI(t *testing.T) { if err != nil { t.Fatalf("failed to create user B: %v", err) } - tokenB, err := auth.GenerateToken(srv.cfg.JWTSecret, userB.ID, userB.Email, userB.DisplayName) + tokenB, err := auth.GenerateToken(srv.cfg.JWTSecret, userB.ID, userB.Email, userB.DisplayName, userB.TokenVersion) if err != nil { t.Fatalf("failed to generate token B: %v", err) } @@ -1150,6 +1150,152 @@ func TestDecoupledFolderSharingAPI(t *testing.T) { } } +func TestTokenBlacklistingAndTokenVersionRevocation(t *testing.T) { + srv, store, _ := setupTestServer(t) + router := srv.Router() + + // 1. Create a fresh user with known password + password := "initialPassword123" + hash, _ := auth.HashPassword(password) + user, err := store.CreateUser("revocation_test@example.com", hash, "Revocation Tester", "member") + if err != nil { + t.Fatalf("failed to create user: %v", err) + } + + // 2. Perform login to get initial JWT + loginBody, _ := json.Marshal(map[string]string{ + "login": user.Email, + "password": password, + }) + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(loginBody)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("login failed: %s", rec.Body.String()) + } + var loginResp struct { + Token string `json:"token"` + } + _ = json.Unmarshal(rec.Body.Bytes(), &loginResp) + token1 := loginResp.Token + if token1 == "" { + t.Fatalf("expected token in login response") + } + + // 3. Verify token1 works + req = httptest.NewRequest(http.MethodGet, "/api/v1/auth/me", nil) + req.Header.Set("Authorization", "Bearer "+token1) + rec = httptest.NewRecorder() + router.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("token1 should be valid, got %d", rec.Code) + } + + // 4. Test explicit logout (blacklists token1) + req = httptest.NewRequest(http.MethodPost, "/api/v1/auth/logout", nil) + req.Header.Set("Authorization", "Bearer "+token1) + rec = httptest.NewRecorder() + router.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("logout failed: %s", rec.Body.String()) + } + + // Token1 must now be blacklisted and rejected (401) + req = httptest.NewRequest(http.MethodGet, "/api/v1/auth/me", nil) + req.Header.Set("Authorization", "Bearer "+token1) + rec = httptest.NewRecorder() + router.ServeHTTP(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("expected 401 Unauthorized for blacklisted token1, got %d", rec.Code) + } + + // Also check authenticateUserFromReq rejects token1 + req = httptest.NewRequest(http.MethodGet, "/api/v1/ws?token="+token1, nil) + if uid := srv.authenticateUserFromReq(req); uid != "" { + t.Fatalf("authenticateUserFromReq should reject blacklisted token1, got uid %q", uid) + } + + // 5. Login again to get token2 + req = httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(loginBody)) + req.Header.Set("Content-Type", "application/json") + rec = httptest.NewRecorder() + router.ServeHTTP(rec, req) + var loginResp2 struct { + Token string `json:"token"` + } + _ = json.Unmarshal(rec.Body.Bytes(), &loginResp2) + token2 := loginResp2.Token + + // Token2 works + req = httptest.NewRequest(http.MethodGet, "/api/v1/auth/me", nil) + req.Header.Set("Authorization", "Bearer "+token2) + rec = httptest.NewRecorder() + router.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("token2 should be valid, got %d", rec.Code) + } + + // 6. Change password via UpdateUserPassword -> increments token_version + newPassword := "newSecretPassword456" + newHash, _ := auth.HashPassword(newPassword) + if err := store.UpdateUserPassword(user.ID, newHash); err != nil { + t.Fatalf("failed to update password: %v", err) + } + + // Token2 must now be REJECTED (401) because its token_version is outdated! + req = httptest.NewRequest(http.MethodGet, "/api/v1/auth/me", nil) + req.Header.Set("Authorization", "Bearer "+token2) + rec = httptest.NewRecorder() + router.ServeHTTP(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("expected 401 Unauthorized for token2 after password change, got %d", rec.Code) + } + + // 7. Login with new password to get token3 + loginNewBody, _ := json.Marshal(map[string]string{ + "login": user.Email, + "password": newPassword, + }) + req = httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(loginNewBody)) + req.Header.Set("Content-Type", "application/json") + rec = httptest.NewRecorder() + router.ServeHTTP(rec, req) + var loginResp3 struct { + Token string `json:"token"` + } + _ = json.Unmarshal(rec.Body.Bytes(), &loginResp3) + token3 := loginResp3.Token + + // Token3 works + req = httptest.NewRequest(http.MethodGet, "/api/v1/auth/me", nil) + req.Header.Set("Authorization", "Bearer "+token3) + rec = httptest.NewRecorder() + router.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("token3 should be valid, got %d", rec.Code) + } + + // 8. Test revoke-all endpoint -> increments token_version and invalidates all existing sessions + req = httptest.NewRequest(http.MethodPost, "/api/v1/auth/revoke-all", nil) + req.Header.Set("Authorization", "Bearer "+token3) + rec = httptest.NewRecorder() + router.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("revoke-all failed: %s", rec.Body.String()) + } + + // Token3 must now be REJECTED (401) + req = httptest.NewRequest(http.MethodGet, "/api/v1/auth/me", nil) + req.Header.Set("Authorization", "Bearer "+token3) + rec = httptest.NewRecorder() + router.ServeHTTP(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("expected 401 Unauthorized for token3 after revoke-all, got %d", rec.Code) + } +} + + diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 329c5ef..dc255f7 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -18,9 +18,10 @@ type contextKey string const UserIDContextKey contextKey = "mindsift_user_id" type Claims struct { - UserID string `json:"userId"` - Email string `json:"email"` - DisplayName string `json:"displayName"` + UserID string `json:"userId"` + Email string `json:"email"` + DisplayName string `json:"displayName"` + TokenVersion int `json:"tokenVersion"` jwt.RegisteredClaims } @@ -34,14 +35,24 @@ func CheckPassword(password, hash string) bool { return err == nil } -func GenerateToken(secret, userID, email, displayName string) (string, error) { +func GenerateToken(secret, userID, email, displayName string, tokenVersion int) (string, error) { + if tokenVersion <= 0 { + tokenVersion = 1 + } + b := make([]byte, 16) + _, _ = rand.Read(b) + jti := hex.EncodeToString(b) + + now := time.Now() claims := &Claims{ - UserID: userID, - Email: email, - DisplayName: displayName, + UserID: userID, + Email: email, + DisplayName: displayName, + TokenVersion: tokenVersion, RegisteredClaims: jwt.RegisteredClaims{ - ExpiresAt: jwt.NewNumericDate(time.Now().Add(30 * 24 * time.Hour)), - IssuedAt: jwt.NewNumericDate(time.Now()), + ID: jti, + ExpiresAt: jwt.NewNumericDate(now.Add(30 * 24 * time.Hour)), + IssuedAt: jwt.NewNumericDate(now), Issuer: "mindsift", }, } diff --git a/internal/domain/models.go b/internal/domain/models.go index f343c4a..c2241f7 100644 --- a/internal/domain/models.go +++ b/internal/domain/models.go @@ -101,6 +101,7 @@ type User struct { AvatarURL string `json:"avatarUrl,omitempty"` Role string `json:"role"` // admin | member IsActive bool `json:"isActive"` + TokenVersion int `json:"tokenVersion"` AlarmChannels []string `json:"alarmChannels"` // email | discord | webhook AlarmEmail string `json:"alarmEmail"` DiscordWebhookURL string `json:"discordWebhookUrl"` diff --git a/internal/storage/sqlite.go b/internal/storage/sqlite.go index c744eb6..0d41584 100644 --- a/internal/storage/sqlite.go +++ b/internal/storage/sqlite.go @@ -72,6 +72,7 @@ func (s *Store) migrate() error { display_name TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'member', is_active INTEGER NOT NULL DEFAULT 1, + token_version INTEGER NOT NULL DEFAULT 1, alarm_channels TEXT NOT NULL DEFAULT 'discord', alarm_email TEXT NOT NULL DEFAULT '', discord_webhook_url TEXT NOT NULL DEFAULT '', @@ -307,6 +308,16 @@ func (s *Store) migrate() error { key TEXT PRIMARY KEY, value TEXT NOT NULL ); + + CREATE TABLE IF NOT EXISTS revoked_tokens ( + token_hash TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + expires_at TEXT NOT NULL, + revoked_at TEXT NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_revoked_tokens_expires ON revoked_tokens(expires_at); + CREATE INDEX IF NOT EXISTS idx_revoked_tokens_user ON revoked_tokens(user_id); ` if _, err := s.db.Exec(schema); err != nil { return err @@ -318,6 +329,7 @@ func (s *Store) migrate() error { "CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);", "ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'member';", "ALTER TABLE users ADD COLUMN is_active INTEGER NOT NULL DEFAULT 1;", + "ALTER TABLE users ADD COLUMN token_version INTEGER NOT NULL DEFAULT 1;", "ALTER TABLE users ADD COLUMN alarm_channels TEXT NOT NULL DEFAULT 'discord';", "ALTER TABLE users ADD COLUMN alarm_email TEXT NOT NULL DEFAULT '';", "ALTER TABLE users ADD COLUMN discord_webhook_url TEXT NOT NULL DEFAULT '';", @@ -536,7 +548,7 @@ func (s *Store) EnsureDefaultUser() (*domain.User, error) { // --- USERS --- -const userSelectCols = `id, email, username, password_hash, display_name, role, is_active, alarm_channels, alarm_email, discord_webhook_url, custom_webhook_url, notify_before_minutes, avatar_url, created_at` +const userSelectCols = `id, email, username, password_hash, display_name, role, is_active, token_version, alarm_channels, alarm_email, discord_webhook_url, custom_webhook_url, notify_before_minutes, avatar_url, created_at` func parseAlarmChannels(raw string) []string { parts := strings.Split(raw, ",") @@ -558,13 +570,16 @@ func scanUser(row interface{ Scan(dest ...any) error }) (*domain.User, error) { var channelsRaw string err := row.Scan( &u.ID, &u.Email, &u.Username, &u.PasswordHash, &u.DisplayName, - &u.Role, &isActiveInt, &channelsRaw, &u.AlarmEmail, + &u.Role, &isActiveInt, &u.TokenVersion, &channelsRaw, &u.AlarmEmail, &u.DiscordWebhookURL, &u.CustomWebhookURL, &u.NotifyBeforeMinutes, &u.AvatarURL, &u.CreatedAt, ) if err != nil { return nil, err } u.IsActive = isActiveInt != 0 + if u.TokenVersion <= 0 { + u.TokenVersion = 1 + } if u.Role == "" { u.Role = "member" } @@ -604,6 +619,7 @@ func (s *Store) CreateUserWithUsername(email, username, passwordHash, displayNam DisplayName: strings.TrimSpace(displayName), Role: role, IsActive: true, + TokenVersion: 1, AlarmChannels: []string{}, AlarmEmail: cleanEmail, DiscordWebhookURL: "", @@ -613,8 +629,8 @@ func (s *Store) CreateUserWithUsername(email, username, passwordHash, displayNam CreatedAt: domain.NowISO(), } _, err := s.db.Exec( - `INSERT INTO users (id, email, username, password_hash, display_name, role, is_active, alarm_channels, alarm_email, discord_webhook_url, custom_webhook_url, notify_before_minutes, avatar_url, created_at) - VALUES (?, ?, ?, ?, ?, ?, 1, '', ?, '', '', 30, '', ?)`, + `INSERT INTO users (id, email, username, password_hash, display_name, role, is_active, token_version, alarm_channels, alarm_email, discord_webhook_url, custom_webhook_url, notify_before_minutes, avatar_url, created_at) + VALUES (?, ?, ?, ?, ?, ?, 1, 1, '', ?, '', '', 30, '', ?)`, u.ID, u.Email, u.Username, u.PasswordHash, u.DisplayName, u.Role, u.AlarmEmail, u.CreatedAt, ) if err != nil { @@ -811,14 +827,17 @@ func (s *Store) UpdateUserFull( if avatarURL != nil { u.AvatarURL = strings.TrimSpace(*avatarURL) } + if (role != nil && *role != u.Role) || (isActive != nil && !*isActive && u.IsActive) { + u.TokenVersion++ + } activeInt := 0 if u.IsActive { activeInt = 1 } channelsStr := strings.Join(u.AlarmChannels, ",") _, err = s.db.Exec( - `UPDATE users SET display_name = ?, email = ?, role = ?, is_active = ?, alarm_channels = ?, alarm_email = ?, discord_webhook_url = ?, custom_webhook_url = ?, notify_before_minutes = ?, avatar_url = ? WHERE id = ?`, - u.DisplayName, u.Email, u.Role, activeInt, channelsStr, u.AlarmEmail, u.DiscordWebhookURL, u.CustomWebhookURL, u.NotifyBeforeMinutes, u.AvatarURL, userID, + `UPDATE users SET display_name = ?, email = ?, role = ?, is_active = ?, token_version = ?, alarm_channels = ?, alarm_email = ?, discord_webhook_url = ?, custom_webhook_url = ?, notify_before_minutes = ?, avatar_url = ? WHERE id = ?`, + u.DisplayName, u.Email, u.Role, activeInt, u.TokenVersion, channelsStr, u.AlarmEmail, u.DiscordWebhookURL, u.CustomWebhookURL, u.NotifyBeforeMinutes, u.AvatarURL, userID, ) if err != nil { return nil, err @@ -827,15 +846,45 @@ func (s *Store) UpdateUserFull( } func (s *Store) UpdateUserPassword(userID, passwordHash string) error { - _, err := s.db.Exec(`UPDATE users SET password_hash = ? WHERE id = ?`, passwordHash, userID) + _, err := s.db.Exec(`UPDATE users SET password_hash = ?, token_version = token_version + 1 WHERE id = ?`, passwordHash, userID) return err } func (s *Store) DeleteUser(userID string) error { + _, _ = s.db.Exec(`DELETE FROM revoked_tokens WHERE user_id = ?`, userID) _, err := s.db.Exec(`DELETE FROM users WHERE id = ?`, userID) return err } +// RevokeToken adds an individual JWT to the blacklist until its expiration date +func (s *Store) RevokeToken(tokenStr, userID string, expiresAt time.Time) error { + hash := auth.HashAPIKey(tokenStr) + _, err := s.db.Exec( + `INSERT OR REPLACE INTO revoked_tokens (token_hash, user_id, expires_at, revoked_at) VALUES (?, ?, ?, ?)`, + hash, userID, expiresAt.UTC().Format(time.RFC3339), time.Now().UTC().Format(time.RFC3339), + ) + // Idempotently prune old expired tokens + _, _ = s.db.Exec(`DELETE FROM revoked_tokens WHERE expires_at < ?`, time.Now().UTC().Format(time.RFC3339)) + return err +} + +// IsTokenRevoked checks if a specific JWT token has been explicitly blacklisted +func (s *Store) IsTokenRevoked(tokenStr string) bool { + hash := auth.HashAPIKey(tokenStr) + var count int + err := s.db.QueryRow( + `SELECT COUNT(1) FROM revoked_tokens WHERE token_hash = ? AND expires_at >= ?`, + hash, time.Now().UTC().Format(time.RFC3339), + ).Scan(&count) + return err == nil && count > 0 +} + +// RevokeAllUserTokens increments the user's token_version, instantly invalidating all existing JWTs for that user +func (s *Store) RevokeAllUserTokens(userID string) error { + _, err := s.db.Exec(`UPDATE users SET token_version = token_version + 1 WHERE id = ?`, userID) + return err +} + // --- THOUGHTS & TRIAGE --- func (s *Store) CreateThought(userID, content, priority, source, dueDate string) (*domain.Thought, error) { -- 2.54.0 From 0e06cc5577c30359c5bf53524976b797897e0052 Mon Sep 17 00:00:00 2001 From: Sandro Date: Tue, 27 Oct 2026 14:02:38 +0100 Subject: [PATCH 04/30] feat(collab): redesign note collaboration with REST auto-save, opt-in live session, and block locking --- .../src/features/workspace/DocumentEditor.tsx | 435 ++++++++++++++++-- .../src/features/workspace/OutlinerBlock.tsx | 34 +- .../src/features/workspace/OutlinerTree.tsx | 82 ++-- .../src/features/workspace/WorkspaceView.tsx | 222 +++++++-- frontend/src/lib/api.ts | 36 ++ frontend/src/lib/types.ts | 22 + frontend/src/lib/useDocumentSync.ts | 206 ++++----- internal/api/server.go | 196 ++++++++ internal/api/server_test.go | 121 +++++ internal/domain/models.go | 23 + internal/ws/hub.go | 235 ++++++++++ 11 files changed, 1373 insertions(+), 239 deletions(-) diff --git a/frontend/src/features/workspace/DocumentEditor.tsx b/frontend/src/features/workspace/DocumentEditor.tsx index 80d9f44..6f81208 100644 --- a/frontend/src/features/workspace/DocumentEditor.tsx +++ b/frontend/src/features/workspace/DocumentEditor.tsx @@ -15,11 +15,14 @@ import { Sparkles, Plus, } from 'lucide-react'; -import { Document, DocumentBlock, User } from '../../lib/types'; -import { api } from '../../lib/api'; -import { useDocumentSync, DocSyncEvent, RemoteCursor, DocSyncStatus } from '../../lib/useDocumentSync'; +import { Document, DocumentBlock, User, BlockLockInfo, LiveSessionInfo, WSMessage } from '../../lib/types'; +import { api, getToken, onWebSocketMessage } from '../../lib/api'; +import { useDocumentSync, DocSyncEvent, RemoteCursor, DocSyncStatus, getUserColor } from '../../lib/useDocumentSync'; import { OutlinerTree } from './OutlinerTree'; +export type DocSaveStatus = 'saved' | 'saving' | 'unsaved' | 'error'; +export type DocLiveState = 'idle' | 'connecting' | 'active' | 'blocked' | 'locked_by_other'; + export interface DocumentEditorProps { docId: string; currentUser?: User | null; @@ -34,6 +37,9 @@ export interface DocumentEditorProps { onWikiLinkClick?: (pageTitle: string) => void; onBackMobile?: () => void; onSyncStatusChange?: (status: DocSyncStatus) => void; + onSaveStatusChange?: (status: DocSaveStatus, errorMsg?: string) => void; + onLiveStateChange?: (state: DocLiveState, initiatorName?: string) => void; + registerLiveToggle?: (handler: () => void) => void; } const EMOJI_PALETTE = ['📝', '📄', '💡', '🚀', '📌', '📂', '🧠', '⚡', '🎯', '🛠️', '📊', '🔒']; @@ -154,6 +160,9 @@ export const DocumentEditor: React.FC = ({ onWikiLinkClick, onBackMobile, onSyncStatusChange, + onSaveStatusChange, + onLiveStateChange, + registerLiveToggle, }) => { const [doc, setDoc] = useState(initialDoc || null); const [title, setTitle] = useState(initialDoc?.title || ''); @@ -165,11 +174,29 @@ export const DocumentEditor: React.FC = ({ const [showEmojiPicker, setShowEmojiPicker] = useState(false); const [zoomedBlockId, setZoomedBlockId] = useState(null); + // Live Collaboration & Locks state + const [liveSession, setLiveSession] = useState(null); + const [isLiveEnabled, setIsLiveEnabled] = useState(false); + const [activeBlockLocks, setActiveBlockLocks] = useState>({}); + + // AutoSave state + const [saveStatus, setSaveStatus] = useState('saved'); + const [, setSaveErrorMessage] = useState(null); + const saveTimeoutRef = useRef(null); + const isSavingRef = useRef(false); + const lastSavedSnapshotRef = useRef(''); + const isInitialLoadRef = useRef(true); + const titleInputRef = useRef(null); const titleDebounceRef = useRef(null); const docRef = useRef(doc); docRef.current = doc; + const currentTitleRef = useRef(title); + currentTitleRef.current = title; + const currentBlocksRef = useRef(blocks); + currentBlocksRef.current = blocks; + useEffect(() => { if (!loading && (autoFocusTitle || (doc?.title === 'Neue Notiz' && (!doc?.blocks || doc?.blocks.length <= 1)))) { const focusTitle = () => { @@ -209,6 +236,10 @@ export const DocumentEditor: React.FC = ({ return doc.permission === 'owner' || doc.permission === 'write'; }, [doc]); + // Is this document completely locked because another user has an active live session? + const isLockedByOtherLive = Boolean(liveSession && liveSession.initiatorId !== currentUser?.id); + const effectiveCanEdit = canEdit && !isLockedByOtherLive; + // Handle incoming WebSocket events for THIS document room const handleRemoteEvent = useCallback( (evt: DocSyncEvent) => { @@ -334,6 +365,137 @@ export const DocumentEditor: React.FC = ({ [docId, onDocMetadataChange] ); + // Extract all #tags present across the current blocks + const extractedTags = useMemo(() => { + const tagSet = new Set(); + for (const b of blocks) { + const matches = b.content?.match(/#([a-zA-Z0-9äöüÄÖÜß_-]+)/g); + if (matches) { + for (const m of matches) { + tagSet.add(m.slice(1).toLowerCase()); + } + } + } + return Array.from(tagSet); + }, [blocks]); + + const currentTagsRef = useRef(extractedTags); + currentTagsRef.current = extractedTags; + + // AutoSave implementation (authoritative persistence via HTTP REST) + const currentSnapshot = useMemo(() => { + return JSON.stringify({ + title, + blocks, + tags: extractedTags, + }); + }, [title, blocks, extractedTags]); + + const currentSnapshotRef = useRef(currentSnapshot); + currentSnapshotRef.current = currentSnapshot; + + const executeAutoSave = useCallback( + async (immediate: boolean = false) => { + if (!docId || !canEdit || isLockedByOtherLive) return; + if (isSavingRef.current && !immediate) return; + + const snapshot = currentSnapshotRef.current; + if (snapshot === lastSavedSnapshotRef.current) { + setSaveStatus('saved'); + onSaveStatusChange?.('saved'); + return; + } + + isSavingRef.current = true; + setSaveStatus('saving'); + onSaveStatusChange?.('saving'); + + try { + const updated = await api.updateDocument(docId, { + title: currentTitleRef.current, + blocks: currentBlocksRef.current, + tags: currentTagsRef.current, + }); + isSavingRef.current = false; + lastSavedSnapshotRef.current = snapshot; + setSaveStatus('saved'); + setSaveErrorMessage(null); + onSaveStatusChange?.('saved'); + if (docRef.current) { + docRef.current = updated; + onDocMetadataChange(updated); + } + } catch (err: any) { + isSavingRef.current = false; + const msg = err?.message || 'Fehler beim Speichern'; + setSaveStatus('error'); + setSaveErrorMessage(msg); + onSaveStatusChange?.('error', msg); + } + }, + [docId, canEdit, isLockedByOtherLive, onSaveStatusChange, onDocMetadataChange] + ); + + useEffect(() => { + if (isInitialLoadRef.current) return; + if (currentSnapshot === lastSavedSnapshotRef.current) return; + + setSaveStatus('unsaved'); + onSaveStatusChange?.('unsaved'); + + if (saveTimeoutRef.current !== null) { + window.clearTimeout(saveTimeoutRef.current); + } + saveTimeoutRef.current = window.setTimeout(() => { + saveTimeoutRef.current = null; + executeAutoSave(); + }, 800); + + return () => { + if (saveTimeoutRef.current !== null) { + window.clearTimeout(saveTimeoutRef.current); + saveTimeoutRef.current = null; + } + }; + }, [currentSnapshot, executeAutoSave, onSaveStatusChange]); + + // Flush on page unload / pagehide via keepalive fetch + useEffect(() => { + const handleUnload = () => { + if (saveTimeoutRef.current !== null) { + window.clearTimeout(saveTimeoutRef.current); + saveTimeoutRef.current = null; + } + const snapshot = currentSnapshotRef.current; + if (snapshot !== lastSavedSnapshotRef.current && canEdit && !isLockedByOtherLive) { + const token = getToken(); + if (token) { + fetch(`/api/v1/documents/${docId}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + title: currentTitleRef.current, + blocks: currentBlocksRef.current, + tags: currentTagsRef.current, + }), + keepalive: true, + }).catch(() => {}); + } + } + }; + + window.addEventListener('beforeunload', handleUnload); + window.addEventListener('pagehide', handleUnload); + return () => { + window.removeEventListener('beforeunload', handleUnload); + window.removeEventListener('pagehide', handleUnload); + handleUnload(); + }; + }, [docId, canEdit, isLockedByOtherLive]); + const { status: syncStatus, remoteCursors, @@ -343,12 +505,158 @@ export const DocumentEditor: React.FC = ({ sendBlockDeleted, sendBlockMoved, sendTitleUpdated, - } = useDocumentSync(docId, handleRemoteEvent, canEdit, currentUser); + } = useDocumentSync(docId, handleRemoteEvent, effectiveCanEdit, currentUser, isLiveEnabled); useEffect(() => { onSyncStatusChange?.(syncStatus); }, [syncStatus, onSyncStatusChange]); + const computedLiveState: DocLiveState = useMemo(() => { + if (isLockedByOtherLive) return 'locked_by_other'; + if (!isLiveEnabled) return 'idle'; + if (syncStatus === 'connecting' || syncStatus === 'reconnecting') return 'connecting'; + if (syncStatus === 'blocked') return 'blocked'; + if (syncStatus === 'connected') return 'active'; + return 'idle'; + }, [isLockedByOtherLive, isLiveEnabled, syncStatus]); + + useEffect(() => { + onLiveStateChange?.(computedLiveState, liveSession?.initiatorName); + }, [computedLiveState, liveSession?.initiatorName, onLiveStateChange]); + + const handleToggleLive = useCallback(() => { + if (isLiveEnabled) { + api.endLiveSession(docId).catch(() => {}); + setIsLiveEnabled(false); + setLiveSession(null); + } else { + if (isLockedByOtherLive) return; + api.startLiveSession(docId) + .then((session) => { + setLiveSession(session); + setIsLiveEnabled(true); + }) + .catch((err) => { + console.error('[DocumentEditor] Failed to start live session:', err); + }); + } + }, [docId, isLiveEnabled, isLockedByOtherLive]); + + useEffect(() => { + if (registerLiveToggle) { + registerLiveToggle(handleToggleLive); + } + }, [registerLiveToggle, handleToggleLive]); + + // Load initial locks on mount + useEffect(() => { + let cancelled = false; + api.getDocLocks(docId) + .then((res) => { + if (cancelled) return; + if (res.liveSession) { + setLiveSession(res.liveSession); + if (res.liveSession.initiatorId === currentUser?.id) { + setIsLiveEnabled(true); + } + } + if (Array.isArray(res.blockLocks)) { + const map: Record = {}; + for (const l of res.blockLocks) { + map[l.blockId] = l; + } + setActiveBlockLocks(map); + } + }) + .catch(() => {}); + + return () => { + cancelled = true; + }; + }, [docId, currentUser?.id]); + + // Subscribe to realtime lock & live session events + useEffect(() => { + const unsub = onWebSocketMessage((msg: WSMessage) => { + if (msg.event === 'DOC_BLOCK_LOCKED' && msg.payload?.docId === docId) { + setActiveBlockLocks((prev) => ({ + ...prev, + [msg.payload.blockId]: msg.payload, + })); + } else if (msg.event === 'DOC_BLOCK_UNLOCKED' && msg.payload?.docId === docId) { + setActiveBlockLocks((prev) => { + const next = { ...prev }; + delete next[msg.payload.blockId]; + return next; + }); + } else if (msg.event === 'DOC_LIVE_STARTED' && msg.payload?.docId === docId) { + setLiveSession(msg.payload); + } else if (msg.event === 'DOC_LIVE_ENDED' && msg.payload?.docId === docId) { + setLiveSession(null); + if (liveSession?.initiatorId === currentUser?.id) { + setIsLiveEnabled(false); + } + } + }); + + return () => { + unsub(); + }; + }, [docId, currentUser?.id, liveSession?.initiatorId]); + + // Block focus/blur locks + const localUserColor = useMemo( + () => getUserColor(currentUser?.id || 'anon'), + [currentUser?.id] + ); + const focusedBlockIdRef = useRef(null); + const lockKeepaliveTimerRef = useRef(null); + + const handleFocusBlock = useCallback( + (blockId: string) => { + if (!effectiveCanEdit) return; + focusedBlockIdRef.current = blockId; + api.lockBlock(docId, blockId, localUserColor, 30).catch(() => {}); + + if (lockKeepaliveTimerRef.current !== null) { + window.clearInterval(lockKeepaliveTimerRef.current); + } + lockKeepaliveTimerRef.current = window.setInterval(() => { + if (focusedBlockIdRef.current === blockId) { + api.lockBlock(docId, blockId, localUserColor, 30).catch(() => {}); + } + }, 15000); + }, + [docId, effectiveCanEdit, localUserColor] + ); + + const handleBlurBlock = useCallback( + (blockId: string) => { + if (lockKeepaliveTimerRef.current !== null) { + window.clearInterval(lockKeepaliveTimerRef.current); + lockKeepaliveTimerRef.current = null; + } + if (focusedBlockIdRef.current === blockId) { + focusedBlockIdRef.current = null; + } + api.unlockBlock(docId, blockId).catch(() => {}); + executeAutoSave(); + }, + [docId, executeAutoSave] + ); + + useEffect(() => { + return () => { + if (lockKeepaliveTimerRef.current !== null) { + window.clearInterval(lockKeepaliveTimerRef.current); + lockKeepaliveTimerRef.current = null; + } + if (focusedBlockIdRef.current) { + api.unlockBlock(docId, focusedBlockIdRef.current).catch(() => {}); + } + }; + }, [docId]); + // Fetch isolated document state on mount useEffect(() => { let isCancelled = false; @@ -361,11 +669,31 @@ export const DocumentEditor: React.FC = ({ .getDocument(docId) .then((fetched) => { if (isCancelled) return; + const normalized = ensureNonEmptyBlocks(docId, fetched.blocks); setDoc(fetched); setTitle(fetched.title || 'Unbenannte Notiz'); - setBlocks(ensureNonEmptyBlocks(docId, fetched.blocks)); + setBlocks(normalized); setLoading(false); onDocMetadataChange(fetched); + + const initialTags: string[] = []; + for (const b of normalized) { + const matches = b.content?.match(/#([a-zA-Z0-9äöüÄÖÜß_-]+)/g); + if (matches) { + for (const m of matches) { + const tag = m.slice(1).toLowerCase(); + if (!initialTags.includes(tag)) initialTags.push(tag); + } + } + } + lastSavedSnapshotRef.current = JSON.stringify({ + title: fetched.title || 'Unbenannte Notiz', + blocks: normalized, + tags: initialTags, + }); + isInitialLoadRef.current = false; + setSaveStatus('saved'); + onSaveStatusChange?.('saved'); }) .catch((err: any) => { if (isCancelled) return; @@ -382,20 +710,6 @@ export const DocumentEditor: React.FC = ({ }; }, [docId]); - // Extract all #tags present across the current blocks - const extractedTags = useMemo(() => { - const tagSet = new Set(); - for (const b of blocks) { - const matches = b.content?.match(/#([a-zA-Z0-9äöüÄÖÜß_-]+)/g); - if (matches) { - for (const m of matches) { - tagSet.add(m.slice(1).toLowerCase()); - } - } - } - return Array.from(tagSet); - }, [blocks]); - // Keep parent sidebar metadata in sync when blocks/tags change useEffect(() => { if (!docRef.current) return; @@ -425,7 +739,7 @@ export const DocumentEditor: React.FC = ({ }, [remoteCursors]); const handleTitleChange = (e: React.ChangeEvent) => { - if (!canEdit) return; + if (!effectiveCanEdit) return; const nextTitle = e.target.value; setTitle(nextTitle); @@ -434,7 +748,9 @@ export const DocumentEditor: React.FC = ({ } titleDebounceRef.current = window.setTimeout(() => { titleDebounceRef.current = null; - sendTitleUpdated(nextTitle); + if (isLiveEnabled) { + sendTitleUpdated(nextTitle); + } }, 250); }; @@ -442,8 +758,11 @@ export const DocumentEditor: React.FC = ({ if (titleDebounceRef.current !== null) { window.clearTimeout(titleDebounceRef.current); titleDebounceRef.current = null; - sendTitleUpdated(title); + if (isLiveEnabled) { + sendTitleUpdated(title); + } } + executeAutoSave(); }; // Flush title debouncing on window blur so background window never holds stale pending title @@ -452,25 +771,31 @@ export const DocumentEditor: React.FC = ({ if (titleDebounceRef.current !== null) { window.clearTimeout(titleDebounceRef.current); titleDebounceRef.current = null; - sendTitleUpdated(title); + if (isLiveEnabled) { + sendTitleUpdated(title); + } } }; window.addEventListener('blur', handleWindowBlur); return () => window.removeEventListener('blur', handleWindowBlur); - }, [title, sendTitleUpdated]); + }, [title, isLiveEnabled, sendTitleUpdated]); const handleUpdateBlock = useCallback( (updatedBlock: DocumentBlock) => { + if (!effectiveCanEdit) return; setBlocks((prev) => prev.map((b) => (b.id === updatedBlock.id ? updatedBlock : b)) ); - sendBlockUpdated(updatedBlock); + if (isLiveEnabled) { + sendBlockUpdated(updatedBlock); + } }, - [sendBlockUpdated] + [effectiveCanEdit, isLiveEnabled, sendBlockUpdated] ); const handleCreateBlock = useCallback( (newBlock: DocumentBlock, afterBlockId?: string) => { + if (!effectiveCanEdit) return; setBlocks((prev) => { const next = [...prev]; if (afterBlockId) { @@ -478,33 +803,41 @@ export const DocumentEditor: React.FC = ({ if (idx >= 0) { next.splice(idx + 1, 0, newBlock); const normalized = ensureNonEmptyBlocks(docId, next); - sendBlockCreated(newBlock, afterBlockId, normalized); + if (isLiveEnabled) { + sendBlockCreated(newBlock, afterBlockId, normalized); + } return normalized; } } next.push(newBlock); const normalized = ensureNonEmptyBlocks(docId, next); - sendBlockCreated(newBlock, afterBlockId, normalized); + if (isLiveEnabled) { + sendBlockCreated(newBlock, afterBlockId, normalized); + } return normalized; }); }, - [docId, sendBlockCreated] + [docId, effectiveCanEdit, isLiveEnabled, sendBlockCreated] ); const handleDeleteBlock = useCallback( (blockId: string) => { + if (!effectiveCanEdit) return; setBlocks((prev) => { const filtered = prev.filter((b) => b.id !== blockId); const normalized = ensureNonEmptyBlocks(docId, filtered); - sendBlockDeleted(blockId, normalized); + if (isLiveEnabled) { + sendBlockDeleted(blockId, normalized); + } return normalized; }); }, - [docId, sendBlockDeleted] + [docId, effectiveCanEdit, isLiveEnabled, sendBlockDeleted] ); const handleMoveBlock = useCallback( (blockId: string, newIndex: number, level?: number) => { + if (!effectiveCanEdit) return; setBlocks((prev) => { const fromIdx = prev.findIndex((b) => b.id === blockId); if (fromIdx === -1) return prev; @@ -517,11 +850,13 @@ export const DocumentEditor: React.FC = ({ const boundedIdx = Math.max(0, Math.min(newIndex, copy.length)); copy.splice(boundedIdx, 0, updatedMoving); const normalized = ensureNonEmptyBlocks(docId, copy); - sendBlockMoved(blockId, boundedIdx, updatedMoving.level, normalized); + if (isLiveEnabled) { + sendBlockMoved(blockId, boundedIdx, updatedMoving.level, normalized); + } return normalized; }); }, - [docId, sendBlockMoved] + [docId, effectiveCanEdit, isLiveEnabled, sendBlockMoved] ); const handleTogglePin = async () => { @@ -537,7 +872,7 @@ export const DocumentEditor: React.FC = ({ const handleSelectIcon = async (icon: string) => { setShowEmojiPicker(false); - if (!doc || !canEdit) return; + if (!doc || !effectiveCanEdit) return; try { const updated = await api.updateDocument(doc.id, { icon }); setDoc(updated); @@ -790,6 +1125,16 @@ export const DocumentEditor: React.FC = ({
+ {/* Live Session Active Banner (when another collaborator is live) */} + {isLockedByOtherLive && ( +
+ + + Live-Sitzung aktiv (Bearbeitet von {liveSession?.initiatorName || 'anderer Nutzer'}) – Dokument schreibgeschützt, um Versionskonflikte zu vermeiden. + +
+ )} + {/* Main Content Area */}
{zoomedBlock ? ( @@ -830,7 +1175,7 @@ export const DocumentEditor: React.FC = ({ } }} placeholder="Ast-Titel…" - readOnly={!canEdit} + readOnly={!effectiveCanEdit} className="w-full text-2xl sm:text-3xl font-extrabold text-main bg-transparent border-0 focus:outline-none focus:ring-0 tracking-tight leading-tight" />
@@ -850,7 +1195,7 @@ export const DocumentEditor: React.FC = ({ {childBlocks.length === 0 ? (

Dieser Ast hat noch keine Unterpunkte.

- {canEdit && ( + {effectiveCanEdit && (
@@ -900,13 +1249,13 @@ export const DocumentEditor: React.FC = ({
- {showEmojiPicker && canEdit && ( + {showEmojiPicker && effectiveCanEdit && (
{EMOJI_PALETTE.map((emoji) => (
- {/* Right Header Section: Live Sync Status (identical to SharedListsView) */} + {/* Right Header Section: Save Status & Live Collaboration Button */}
- {(() => { - const isDocActive = subView === 'editor' && Boolean(selectedDocId); - const isConnected = isDocActive - ? activeDocSyncStatus === 'connected' - : realtimeConnected; - const isConnecting = isDocActive && (activeDocSyncStatus === 'connecting' || activeDocSyncStatus === 'reconnecting'); - const isSSE = !isDocActive && realtimeTransport === 'sse'; - - return ( + {subView === 'editor' && selectedDocId ? ( + <> + {/* Save Status Pill */}
- + {docSaveStatus === 'saved' && ( + <> + + Gespeichert + + )} + {docSaveStatus === 'saving' && ( + <> + + Speichert… + + )} + {docSaveStatus === 'unsaved' && ( + <> + + Ungespeichert + + )} + {docSaveStatus === 'error' && ( + <> + + Fehler + + )}
- ); - })()} + + {/* Interactive Live Collaboration Button (replaces passive radio icon) */} + {docLiveState === 'locked_by_other' ? ( +
+ + Live: {docLiveInitiator || 'Gesperrt'} +
+ ) : docLiveState === 'blocked' ? ( + + ) : docLiveState === 'connecting' ? ( + + ) : docLiveState === 'active' ? ( + + ) : ( + + )} + + ) : ( + /* Standalone Realtime Status Indicator for Todos / Hashtags */ + (() => { + const isConnected = realtimeConnected; + const isSSE = realtimeTransport === 'sse'; + return ( +
+ +
+ ); + })() + )}
@@ -1414,6 +1515,17 @@ export const WorkspaceView: React.FC = ({ onWikiLinkClick={handleWikiLinkClick} onBackMobile={() => setMobileShowDetail(false)} onSyncStatusChange={setActiveDocSyncStatus} + onSaveStatusChange={(status, err) => { + setDocSaveStatus(status); + setDocSaveError(err || null); + }} + onLiveStateChange={(state, initiator) => { + setDocLiveState(state); + setDocLiveInitiator(initiator || null); + }} + registerLiveToggle={(handler) => { + liveToggleRef.current = handler; + }} /> ) : subView === 'tags' ? ( /* ========================================================================= @@ -2403,6 +2515,50 @@ export const WorkspaceView: React.FC = ({ }} /> )} + + {/* WebSocket Blocked Modal */} + {showWsBlockedModal && ( +
+
+
+
+ +
+
+

+ WebSockets in diesem Netzwerk blockiert +

+

+ Deine Firewall oder dein Firmen-Proxy blockiert WebSocket-Verbindungen (ws:// / wss://). +

+
+
+ +
+
+ + Kein Datenverlust: Auto-Save per HTTPS ist aktiv! +
+

+ Jede getippte Zeile und alle Änderungen werden zuverlässig über Standard-HTTPS-Anfragen im Hintergrund gespeichert. +

+

+ Lediglich die gleichzeitige Tastendruck-Echtzeitanzeige mit anderen Nutzern im selben Moment ist ohne WebSockets eingeschränkt. +

+
+ +
+ +
+
+
+ )}
); diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 6c258e0..2fb032a 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -6,6 +6,9 @@ import { DocumentBlock, DocShare, DocLockInfo, + BlockLockInfo, + LiveSessionInfo, + DocLocksResponse, KanbanColumn, ListItem, ListShare, @@ -625,6 +628,39 @@ export const api = { await request(`/api/v1/documents/${id}`, { method: 'DELETE' }); }, + async getDocLocks(docId: string): Promise { + const res = await request(`/api/v1/documents/${docId}/locks`); + return { + liveSession: res.liveSession || null, + blockLocks: ensureArray(res.blockLocks), + }; + }, + + async lockBlock(docId: string, blockId: string, userColor?: string, ttlSeconds: number = 30): Promise { + return request(`/api/v1/documents/${docId}/blocks/${encodeURIComponent(blockId)}/lock`, { + method: 'POST', + body: JSON.stringify({ userColor, ttlSeconds }), + }); + }, + + async unlockBlock(docId: string, blockId: string): Promise<{ status: string }> { + return request<{ status: string }>(`/api/v1/documents/${docId}/blocks/${encodeURIComponent(blockId)}/unlock`, { + method: 'POST', + }); + }, + + async startLiveSession(docId: string): Promise { + return request(`/api/v1/documents/${docId}/live/start`, { + method: 'POST', + }); + }, + + async endLiveSession(docId: string): Promise<{ status: string }> { + return request<{ status: string }>(`/api/v1/documents/${docId}/live/end`, { + method: 'POST', + }); + }, + async moveDocument(id: string, folderPath: string): Promise { const d = await request(`/api/v1/documents/${id}/move`, { method: 'PUT', diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index ea44b68..7a5e479 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -207,6 +207,28 @@ export interface DocLockInfo { expiresAt: string; } +export interface BlockLockInfo { + docId: string; + blockId: string; + userId: string; + userName: string; + userColor?: string; + expiresAt: string; +} + +export interface LiveSessionInfo { + docId: string; + initiatorId: string; + initiatorName: string; + startedAt: string; + expiresAt: string; +} + +export interface DocLocksResponse { + liveSession?: LiveSessionInfo | null; + blockLocks: BlockLockInfo[]; +} + export interface Document { id: string; userId: string; diff --git a/frontend/src/lib/useDocumentSync.ts b/frontend/src/lib/useDocumentSync.ts index b27b57f..7d70d89 100644 --- a/frontend/src/lib/useDocumentSync.ts +++ b/frontend/src/lib/useDocumentSync.ts @@ -2,7 +2,7 @@ import { useEffect, useRef, useState, useCallback, useMemo } from 'react'; import { DocumentBlock } from './types'; import { getToken, api } from './api'; -export type DocSyncStatus = 'connecting' | 'connected' | 'reconnecting' | 'offline'; +export type DocSyncStatus = 'idle' | 'connecting' | 'connected' | 'reconnecting' | 'offline' | 'blocked'; export interface RemoteCursor { clientId: string; @@ -73,9 +73,10 @@ export function useDocumentSync( docId: string | null, onRemoteEvent: (evt: DocSyncEvent) => void, canEdit: boolean = true, - currentUser?: { id: string; email?: string; displayName?: string } | null + currentUser?: { id: string; email?: string; displayName?: string } | null, + enabled: boolean = false ) { - const [status, setStatus] = useState('connecting'); + const [status, setStatus] = useState(enabled ? 'connecting' : 'idle'); const [remoteCursors, setRemoteCursors] = useState>({}); const wsRef = useRef(null); const clientIdRef = useRef( @@ -136,10 +137,16 @@ export function useDocumentSync( }, []); useEffect(() => { - if (!docId) { - setStatus('offline'); + if (!docId || !enabled) { + setStatus('idle'); setRemoteCursors({}); pendingMessagesQueueRef.current = []; + if (wsRef.current) { + try { + wsRef.current.close(); + } catch {} + wsRef.current = null; + } return; } @@ -173,13 +180,24 @@ export function useDocumentSync( try { ws = new WebSocket(wsUrl); } catch { - setStatus('offline'); + setStatus('blocked'); return; } wsRef.current = ws; + const handshakeTimer = window.setTimeout(() => { + if (ws.readyState !== WebSocket.OPEN) { + console.warn('[DocSync] WebSocket handshake timed out (corporate firewall/proxy block detected).'); + setStatus('blocked'); + try { + ws.close(); + } catch {} + } + }, 4500); + ws.onopen = () => { + window.clearTimeout(handshakeTimer); if (isDisposed) { ws.close(); return; @@ -266,12 +284,17 @@ export function useDocumentSync( }; ws.onclose = () => { + window.clearTimeout(handshakeTimer); if (isDisposed) return; wsRef.current = null; retryCount += 1; - setStatus(retryCount > 2 ? 'offline' : 'reconnecting'); - const delay = Math.min(1000 * Math.pow(1.6, retryCount), 8000); - reconnectTimerRef.current = window.setTimeout(connect, delay); + if (retryCount >= 2) { + setStatus('blocked'); + } else { + setStatus('reconnecting'); + const delay = Math.min(1000 * Math.pow(1.6, retryCount), 4000); + reconnectTimerRef.current = window.setTimeout(connect, delay); + } }; }; @@ -304,7 +327,7 @@ export function useDocumentSync( wsRef.current = null; } }; - }, [docId, localUserName, localUserColor]); + }, [docId, enabled, localUserName, localUserColor]); const executeSendCursor = useCallback( (blockId: string | null, selectionStart?: number, selectionEnd?: number) => { @@ -377,165 +400,90 @@ export function useDocumentSync( ); const sendRawEvent = useCallback( - (evt: DocSyncEvent, fallbackFn?: () => void) => { + (evt: DocSyncEvent) => { const activeDocId = docIdRef.current; const activeCanEdit = canEditRef.current; if (!activeDocId || !activeCanEdit) return; const ws = wsRef.current; + if (!ws || ws.readyState !== WebSocket.OPEN) return; + const payload: DocSyncEvent = { ...evt, docId: activeDocId, clientId: clientIdRef.current, }; - const jsonStr = JSON.stringify(payload); - - if (ws && ws.readyState === WebSocket.OPEN) { - try { - ws.send(jsonStr); - } catch (err) { - console.warn('[DocSync] Failed to send WebSocket message, buffering:', err); - pendingMessagesQueueRef.current.push(jsonStr); - } - } else { - console.warn( - `[DocSync] WebSocket not OPEN (state=${ws ? ws.readyState : 'null'}). Buffering event:`, - evt.type - ); - pendingMessagesQueueRef.current.push(jsonStr); - - if (fallbackFn) { - if (fallbackTimerRef.current !== null) { - window.clearTimeout(fallbackTimerRef.current); - } - fallbackTimerRef.current = window.setTimeout(() => { - fallbackFn(); - }, 600); - } + try { + ws.send(JSON.stringify(payload)); + } catch (err) { + console.warn('[DocSync] Failed to send WebSocket message:', err); } }, [] ); const sendBlockCreated = useCallback( - (block: DocumentBlock, afterBlockId?: string, allBlocks?: DocumentBlock[]) => { - sendRawEvent( - { - type: 'block_created', - block, - afterBlockId, - }, - () => { - if (docId && allBlocks) { - api - .patchDocumentBlocks(docId, { - upserted: [block], - blockOrder: allBlocks.map((b) => b.id), - }) - .catch(() => {}); - } - } - ); + (block: DocumentBlock, afterBlockId?: string, _allBlocks?: DocumentBlock[]) => { + sendRawEvent({ + type: 'block_created', + block, + afterBlockId, + }); }, - [docId, sendRawEvent] + [sendRawEvent] ); const sendBlockUpdated = useCallback( (block: DocumentBlock) => { - sendRawEvent( - { - type: 'block_updated', - blockId: block.id, - block, - }, - () => { - if (docId) { - api.patchDocumentBlocks(docId, { upserted: [block] }).catch(() => {}); - } - } - ); + sendRawEvent({ + type: 'block_updated', + blockId: block.id, + block, + }); }, - [docId, sendRawEvent] + [sendRawEvent] ); const sendBlockDeleted = useCallback( - (blockId: string, allBlocks?: DocumentBlock[]) => { - sendRawEvent( - { - type: 'block_deleted', - blockId, - }, - () => { - if (docId) { - api - .patchDocumentBlocks(docId, { - deletedIds: [blockId], - blockOrder: allBlocks ? allBlocks.map((b) => b.id) : undefined, - }) - .catch(() => {}); - } - } - ); + (blockId: string, _allBlocks?: DocumentBlock[]) => { + sendRawEvent({ + type: 'block_deleted', + blockId, + }); }, - [docId, sendRawEvent] + [sendRawEvent] ); const sendBlockMoved = useCallback( - (blockId: string, newIndex: number, level?: number, allBlocks?: DocumentBlock[]) => { - sendRawEvent( - { - type: 'block_moved', - blockId, - newIndex, - level, - }, - () => { - if (docId && allBlocks) { - api - .patchDocumentBlocks(docId, { - upserted: allBlocks, - blockOrder: allBlocks.map((b) => b.id), - }) - .catch(() => {}); - } - } - ); + (blockId: string, newIndex: number, level?: number, _allBlocks?: DocumentBlock[]) => { + sendRawEvent({ + type: 'block_moved', + blockId, + newIndex, + level, + }); }, - [docId, sendRawEvent] + [sendRawEvent] ); const sendTitleUpdated = useCallback( (title: string) => { - sendRawEvent( - { - type: 'title_updated', - title, - }, - () => { - if (docId) { - api.updateDocument(docId, { title }).catch(() => {}); - } - } - ); + sendRawEvent({ + type: 'title_updated', + title, + }); }, - [docId, sendRawEvent] + [sendRawEvent] ); const sendBlocksReplaced = useCallback( (blocks: DocumentBlock[]) => { - sendRawEvent( - { - type: 'blocks_replaced', - blocks, - }, - () => { - if (docId) { - api.updateDocument(docId, { blocks }).catch(() => {}); - } - } - ); + sendRawEvent({ + type: 'blocks_replaced', + blocks, + }); }, - [docId, sendRawEvent] + [sendRawEvent] ); return { diff --git a/internal/api/server.go b/internal/api/server.go index 78645a5..8b87870 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -145,6 +145,13 @@ func (s *Server) Router() http.Handler { r.Post("/api/v1/documents/{id}/shares", s.handleCreateDocShare) r.Delete("/api/v1/documents/{id}/shares/{userId}", s.handleDeleteDocShare) + // Document Locks & Live-Collaboration Sessions + r.Get("/api/v1/documents/{id}/locks", s.handleGetDocLocks) + r.Post("/api/v1/documents/{id}/blocks/{blockId}/lock", s.handleLockBlock) + r.Post("/api/v1/documents/{id}/blocks/{blockId}/unlock", s.handleUnlockBlock) + r.Post("/api/v1/documents/{id}/live/start", s.handleStartLiveSession) + r.Post("/api/v1/documents/{id}/live/end", s.handleEndLiveSession) + // Shared Lists & Realtime Collaboration r.Get("/api/v1/lists", s.handleGetAllLists) r.Post("/api/v1/lists", s.handleCreateList) @@ -923,6 +930,19 @@ func (s *Server) handleUpdateDocument(w http.ResponseWriter, r *http.Request) { userID := auth.GetUserID(r.Context()) docID := chi.URLParam(r, "id") + if s.wsHub != nil { + if ls := s.wsHub.GetLiveSession(docID); ls != nil && ls.InitiatorID != userID { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusLocked) + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "error": "Dokument wird aktuell in einer Live-Sitzung bearbeitet", + "lockedBy": ls.InitiatorName, + "isLocked": true, + }) + return + } + } + var req struct { Title *string `json:"title"` Icon *string `json:"icon"` @@ -954,6 +974,19 @@ func (s *Server) handlePatchDocumentBlocks(w http.ResponseWriter, r *http.Reques userID := auth.GetUserID(r.Context()) docID := chi.URLParam(r, "id") + if s.wsHub != nil { + if ls := s.wsHub.GetLiveSession(docID); ls != nil && ls.InitiatorID != userID { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusLocked) + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "error": "Dokument wird aktuell in einer Live-Sitzung bearbeitet", + "lockedBy": ls.InitiatorName, + "isLocked": true, + }) + return + } + } + var req domain.DocBlocksPatchRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "Ungültiges JSON") @@ -1381,6 +1414,169 @@ func (s *Server) handleGetCollaborators(w http.ResponseWriter, r *http.Request) writeJSON(w, http.StatusOK, users) } +// --- DOCUMENT LOCKS & LIVE COLLABORATION SESSIONS --- + +func (s *Server) handleGetDocLocks(w http.ResponseWriter, r *http.Request) { + docID := chi.URLParam(r, "id") + var blockLocks []domain.BlockLockInfo + var liveSession *domain.LiveSessionInfo + if s.wsHub != nil { + blockLocks = s.wsHub.GetDocBlockLocks(docID) + liveSession = s.wsHub.GetLiveSession(docID) + } + if blockLocks == nil { + blockLocks = []domain.BlockLockInfo{} + } + writeJSON(w, http.StatusOK, domain.DocLocksResponse{ + DocID: docID, + BlockLocks: blockLocks, + LiveSession: liveSession, + }) +} + +func (s *Server) handleLockBlock(w http.ResponseWriter, r *http.Request) { + userID := auth.GetUserID(r.Context()) + docID := chi.URLParam(r, "id") + blockID := chi.URLParam(r, "blockId") + + // If live session is active by someone else, reject + if s.wsHub != nil { + if ls := s.wsHub.GetLiveSession(docID); ls != nil && ls.InitiatorID != userID { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusLocked) + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "error": "Dokument wird aktuell in einer Live-Sitzung bearbeitet", + "lockedBy": ls.InitiatorName, + "isLocked": true, + }) + return + } + } + + user, _ := s.store.GetUserByID(userID) + userName := "Anonym" + userColor := "#3b82f6" + if user != nil { + if user.DisplayName != "" { + userName = user.DisplayName + } else { + userName = user.Username + } + } + + var req struct { + UserColor string `json:"userColor"` + TTLSeconds int `json:"ttlSeconds"` + } + _ = json.NewDecoder(r.Body).Decode(&req) + if req.UserColor != "" { + userColor = req.UserColor + } + ttl := 30 * time.Second + if req.TTLSeconds > 5 && req.TTLSeconds <= 120 { + ttl = time.Duration(req.TTLSeconds) * time.Second + } + + if s.wsHub != nil { + lockInfo, ok, existing := s.wsHub.AcquireBlockLock(docID, blockID, userID, userName, userColor, ttl) + if !ok && existing != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusConflict) + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "error": "Block ist von anderem Benutzer gesperrt", + "lockedBy": existing.UserName, + "lock": existing, + }) + return + } + s.wsHub.BroadcastRoom("doc:"+docID, "DOC_BLOCK_LOCKED", lockInfo, nil) + writeJSON(w, http.StatusOK, map[string]interface{}{ + "success": true, + "lock": lockInfo, + }) + return + } + writeJSON(w, http.StatusOK, map[string]bool{"success": true}) +} + +func (s *Server) handleUnlockBlock(w http.ResponseWriter, r *http.Request) { + userID := auth.GetUserID(r.Context()) + docID := chi.URLParam(r, "id") + blockID := chi.URLParam(r, "blockId") + + if s.wsHub != nil { + s.wsHub.ReleaseBlockLock(docID, blockID, userID) + s.wsHub.BroadcastRoom("doc:"+docID, "DOC_BLOCK_UNLOCKED", map[string]string{ + "docId": docID, + "blockId": blockID, + "userId": userID, + }, nil) + } + writeJSON(w, http.StatusOK, map[string]bool{"success": true}) +} + +func (s *Server) handleStartLiveSession(w http.ResponseWriter, r *http.Request) { + userID := auth.GetUserID(r.Context()) + docID := chi.URLParam(r, "id") + + user, _ := s.store.GetUserByID(userID) + userName := "Anonym" + if user != nil { + if user.DisplayName != "" { + userName = user.DisplayName + } else { + userName = user.Username + } + } + + ttl := 120 * time.Second // 2 min lease with heartbeat while active + if s.wsHub != nil { + info, ok, existing := s.wsHub.StartLiveSession(docID, userID, userName, ttl) + if !ok && existing != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusConflict) + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "error": "Live-Sitzung bereits von anderem Benutzer aktiv", + "session": existing, + "initiator": existing.InitiatorName, + }) + return + } + s.wsHub.BroadcastRoom("doc:"+docID, "DOC_LIVE_STARTED", info, nil) + if users, err := s.store.GetAuthorizedDocUsers(docID); err == nil { + for _, uid := range users { + s.wsHub.BroadcastToUser(uid, "DOC_LIVE_STARTED", info) + } + } + writeJSON(w, http.StatusOK, map[string]interface{}{ + "success": true, + "session": info, + }) + return + } + writeJSON(w, http.StatusOK, map[string]bool{"success": true}) +} + +func (s *Server) handleEndLiveSession(w http.ResponseWriter, r *http.Request) { + userID := auth.GetUserID(r.Context()) + docID := chi.URLParam(r, "id") + + if s.wsHub != nil { + s.wsHub.EndLiveSession(docID, userID) + evt := map[string]string{ + "docId": docID, + "userId": userID, + } + s.wsHub.BroadcastRoom("doc:"+docID, "DOC_LIVE_ENDED", evt, nil) + if users, err := s.store.GetAuthorizedDocUsers(docID); err == nil { + for _, uid := range users { + s.wsHub.BroadcastToUser(uid, "DOC_LIVE_ENDED", evt) + } + } + } + writeJSON(w, http.StatusOK, map[string]bool{"success": true}) +} + // --- DOCUMENT REALTIME WEBSOCKET ROOM --- func (s *Server) authenticateUserFromReq(r *http.Request) string { diff --git a/internal/api/server_test.go b/internal/api/server_test.go index f4642cc..5bb19cb 100644 --- a/internal/api/server_test.go +++ b/internal/api/server_test.go @@ -1295,6 +1295,127 @@ func TestTokenBlacklistingAndTokenVersionRevocation(t *testing.T) { } } +func TestBlockLockingAndLiveSessionGuards(t *testing.T) { + srv, store, _ := setupTestServer(t) + router := srv.Router() + + userA, _ := store.CreateUser("user_a_lock@example.com", "hash", "User A", "member") + tokenA, _ := auth.GenerateToken(srv.cfg.JWTSecret, userA.ID, userA.Email, userA.DisplayName, userA.TokenVersion) + + userB, _ := store.CreateUser("user_b_lock@example.com", "hash", "User B", "member") + tokenB, _ := auth.GenerateToken(srv.cfg.JWTSecret, userB.ID, userB.Email, userB.DisplayName, userB.TokenVersion) + + // Create document owned by User A, shared with User B + blocks := []domain.DocumentBlock{ + {ID: "b1", Type: "bullet", Content: "Row 1", Level: 0}, + {ID: "b2", Type: "bullet", Content: "Row 2", Level: 0}, + } + doc, err := store.CreateDocument(userA.ID, "", "Lock Test Doc", "📝", "", blocks, nil, false) + if err != nil { + t.Fatalf("CreateDocument failed: %v", err) + } + _, _ = store.CreateDocShare(userA.ID, doc.ID, userB.ID, "write") + + // 1. Initial locks should be empty + req := httptest.NewRequest(http.MethodGet, "/api/v1/documents/"+doc.ID+"/locks", nil) + req.Header.Set("Authorization", "Bearer "+tokenA) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("get locks failed: %d", rec.Code) + } + var locksResp domain.DocLocksResponse + _ = json.Unmarshal(rec.Body.Bytes(), &locksResp) + if len(locksResp.BlockLocks) != 0 || locksResp.LiveSession != nil { + t.Fatalf("expected 0 locks initially, got %v", locksResp) + } + + // 2. User A locks block b1 + req = httptest.NewRequest(http.MethodPost, "/api/v1/documents/"+doc.ID+"/blocks/b1/lock", bytes.NewReader([]byte(`{"ttlSeconds": 30}`))) + req.Header.Set("Authorization", "Bearer "+tokenA) + req.Header.Set("Content-Type", "application/json") + rec = httptest.NewRecorder() + router.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("user A lock b1 failed: %d, body: %s", rec.Code, rec.Body.String()) + } + + // 3. User B tries to lock b1 -> must receive 409 Conflict + req = httptest.NewRequest(http.MethodPost, "/api/v1/documents/"+doc.ID+"/blocks/b1/lock", bytes.NewReader([]byte(`{"ttlSeconds": 30}`))) + req.Header.Set("Authorization", "Bearer "+tokenB) + req.Header.Set("Content-Type", "application/json") + rec = httptest.NewRecorder() + router.ServeHTTP(rec, req) + if rec.Code != http.StatusConflict { + t.Fatalf("expected 409 Conflict for user B on b1, got %d", rec.Code) + } + + // 4. User A unlocks b1 + req = httptest.NewRequest(http.MethodPost, "/api/v1/documents/"+doc.ID+"/blocks/b1/unlock", nil) + req.Header.Set("Authorization", "Bearer "+tokenA) + rec = httptest.NewRecorder() + router.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("user A unlock b1 failed: %d", rec.Code) + } + + // 5. User B can now lock b1 + req = httptest.NewRequest(http.MethodPost, "/api/v1/documents/"+doc.ID+"/blocks/b1/lock", bytes.NewReader([]byte(`{"ttlSeconds": 30}`))) + req.Header.Set("Authorization", "Bearer "+tokenB) + rec = httptest.NewRecorder() + router.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("user B lock b1 should succeed now, got %d", rec.Code) + } + // User B unlocks b1 + req = httptest.NewRequest(http.MethodPost, "/api/v1/documents/"+doc.ID+"/blocks/b1/unlock", nil) + req.Header.Set("Authorization", "Bearer "+tokenB) + rec = httptest.NewRecorder() + router.ServeHTTP(rec, req) + + // 6. User A starts a Live-Session on doc + req = httptest.NewRequest(http.MethodPost, "/api/v1/documents/"+doc.ID+"/live/start", nil) + req.Header.Set("Authorization", "Bearer "+tokenA) + rec = httptest.NewRecorder() + router.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("user A start live session failed: %d, body: %s", rec.Code, rec.Body.String()) + } + + // 7. User B tries to patch document blocks while User A's live session is active -> must receive 423 Locked! + patchBody, _ := json.Marshal(domain.DocBlocksPatchRequest{ + Upserted: []domain.DocumentBlock{{ID: "b2", Content: "Hacked by User B"}}, + }) + req = httptest.NewRequest(http.MethodPatch, "/api/v1/documents/"+doc.ID+"/blocks", bytes.NewReader(patchBody)) + req.Header.Set("Authorization", "Bearer "+tokenB) + req.Header.Set("Content-Type", "application/json") + rec = httptest.NewRecorder() + router.ServeHTTP(rec, req) + if rec.Code != http.StatusLocked { + t.Fatalf("expected 423 Locked for User B while live session is active, got %d, body: %s", rec.Code, rec.Body.String()) + } + + // 8. User A ends the Live-Session + req = httptest.NewRequest(http.MethodPost, "/api/v1/documents/"+doc.ID+"/live/end", nil) + req.Header.Set("Authorization", "Bearer "+tokenA) + rec = httptest.NewRecorder() + router.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("user A end live session failed: %d", rec.Code) + } + + // 9. User B can now edit again + req = httptest.NewRequest(http.MethodPatch, "/api/v1/documents/"+doc.ID+"/blocks", bytes.NewReader(patchBody)) + req.Header.Set("Authorization", "Bearer "+tokenB) + req.Header.Set("Content-Type", "application/json") + rec = httptest.NewRecorder() + router.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("User B edit should succeed after live session ended, got %d, body: %s", rec.Code, rec.Body.String()) + } +} + + diff --git a/internal/domain/models.go b/internal/domain/models.go index c2241f7..46c1934 100644 --- a/internal/domain/models.go +++ b/internal/domain/models.go @@ -344,6 +344,29 @@ type DocLockInfo struct { ExpiresAt string `json:"expiresAt"` } +type BlockLockInfo struct { + DocID string `json:"docId"` + BlockID string `json:"blockId"` + UserID string `json:"userId"` + UserName string `json:"userName"` + UserColor string `json:"userColor,omitempty"` + ExpiresAt string `json:"expiresAt"` +} + +type LiveSessionInfo struct { + DocID string `json:"docId"` + InitiatorID string `json:"initiatorId"` + InitiatorName string `json:"initiatorName"` + StartedAt string `json:"startedAt"` + ExpiresAt string `json:"expiresAt"` +} + +type DocLocksResponse struct { + DocID string `json:"docId"` + BlockLocks []BlockLockInfo `json:"blockLocks"` + LiveSession *LiveSessionInfo `json:"liveSession,omitempty"` +} + type Document struct { ID string `json:"id"` UserID string `json:"userId"` diff --git a/internal/ws/hub.go b/internal/ws/hub.go index 3b6d737..5936de8 100644 --- a/internal/ws/hub.go +++ b/internal/ws/hub.go @@ -92,6 +92,24 @@ type docLockItem struct { expiresAt time.Time } +type blockLockItem struct { + docID string + blockID string + userID string + userName string + userColor string + lockedAt time.Time + expiresAt time.Time +} + +type liveSessionItem struct { + docID string + initiatorID string + initiatorName string + startedAt time.Time + expiresAt time.Time +} + type DocStore interface { LoadDocumentState(docID string) (string, []domain.DocumentBlock, error) FlushDocumentState(docID string, title string, blocks []domain.DocumentBlock) error @@ -106,6 +124,8 @@ type Hub struct { sseUserRooms map[string]map[*SSEClient]bool sseRooms map[string]map[*SSEClient]bool locks map[string]*docLockItem + blockLocks map[string]*blockLockItem // key: docID + ":" + blockID + liveSessions map[string]*liveSessionItem // key: docID docRooms map[string]*DocRoom docStore DocStore stopCleanup chan struct{} @@ -121,6 +141,8 @@ func NewHub() *Hub { sseUserRooms: make(map[string]map[*SSEClient]bool), sseRooms: make(map[string]map[*SSEClient]bool), locks: make(map[string]*docLockItem), + blockLocks: make(map[string]*blockLockItem), + liveSessions: make(map[string]*liveSessionItem), docRooms: make(map[string]*DocRoom), stopCleanup: make(chan struct{}), } @@ -459,6 +481,187 @@ func (h *Hub) GetDocLock(docID string) *domain.DocLockInfo { } } +// Block Locking Methods (Option B) + +func (h *Hub) AcquireBlockLock(docID, blockID, userID, userName, userColor string, ttl time.Duration) (*domain.BlockLockInfo, bool, *domain.BlockLockInfo) { + h.mu.Lock() + defer h.mu.Unlock() + + now := time.Now() + key := docID + ":" + blockID + existing, exists := h.blockLocks[key] + if exists && existing.expiresAt.After(now) { + if existing.userID != userID { + info := &domain.BlockLockInfo{ + DocID: existing.docID, + BlockID: existing.blockID, + UserID: existing.userID, + UserName: existing.userName, + UserColor: existing.userColor, + ExpiresAt: existing.expiresAt.Format(time.RFC3339), + } + return nil, false, info + } + // Refresh lease + existing.expiresAt = now.Add(ttl) + if userName != "" { + existing.userName = userName + } + if userColor != "" { + existing.userColor = userColor + } + info := &domain.BlockLockInfo{ + DocID: existing.docID, + BlockID: existing.blockID, + UserID: existing.userID, + UserName: existing.userName, + UserColor: existing.userColor, + ExpiresAt: existing.expiresAt.Format(time.RFC3339), + } + return info, true, nil + } + + item := &blockLockItem{ + docID: docID, + blockID: blockID, + userID: userID, + userName: userName, + userColor: userColor, + lockedAt: now, + expiresAt: now.Add(ttl), + } + h.blockLocks[key] = item + info := &domain.BlockLockInfo{ + DocID: item.docID, + BlockID: item.blockID, + UserID: item.userID, + UserName: item.userName, + UserColor: item.userColor, + ExpiresAt: item.expiresAt.Format(time.RFC3339), + } + return info, true, nil +} + +func (h *Hub) ReleaseBlockLock(docID, blockID, userID string) bool { + h.mu.Lock() + defer h.mu.Unlock() + + key := docID + ":" + blockID + existing, exists := h.blockLocks[key] + if !exists { + return false + } + if userID != "" && existing.userID != userID && userID != "admin" { + return false + } + delete(h.blockLocks, key) + return true +} + +func (h *Hub) GetDocBlockLocks(docID string) []domain.BlockLockInfo { + h.mu.RLock() + defer h.mu.RUnlock() + + now := time.Now() + var result []domain.BlockLockInfo + prefix := docID + ":" + for k, l := range h.blockLocks { + if strings.HasPrefix(k, prefix) && l.expiresAt.After(now) { + result = append(result, domain.BlockLockInfo{ + DocID: l.docID, + BlockID: l.blockID, + UserID: l.userID, + UserName: l.userName, + UserColor: l.userColor, + ExpiresAt: l.expiresAt.Format(time.RFC3339), + }) + } + } + return result +} + +// Live Session Methods (Opt-In Live Collaboration) + +func (h *Hub) StartLiveSession(docID, userID, userName string, ttl time.Duration) (*domain.LiveSessionInfo, bool, *domain.LiveSessionInfo) { + h.mu.Lock() + defer h.mu.Unlock() + + now := time.Now() + existing, exists := h.liveSessions[docID] + if exists && existing.expiresAt.After(now) { + if existing.initiatorID != userID { + info := &domain.LiveSessionInfo{ + DocID: existing.docID, + InitiatorID: existing.initiatorID, + InitiatorName: existing.initiatorName, + StartedAt: existing.startedAt.Format(time.RFC3339), + ExpiresAt: existing.expiresAt.Format(time.RFC3339), + } + return nil, false, info + } + // Refresh lease + existing.expiresAt = now.Add(ttl) + info := &domain.LiveSessionInfo{ + DocID: existing.docID, + InitiatorID: existing.initiatorID, + InitiatorName: existing.initiatorName, + StartedAt: existing.startedAt.Format(time.RFC3339), + ExpiresAt: existing.expiresAt.Format(time.RFC3339), + } + return info, true, nil + } + + item := &liveSessionItem{ + docID: docID, + initiatorID: userID, + initiatorName: userName, + startedAt: now, + expiresAt: now.Add(ttl), + } + h.liveSessions[docID] = item + info := &domain.LiveSessionInfo{ + DocID: item.docID, + InitiatorID: item.initiatorID, + InitiatorName: item.initiatorName, + StartedAt: item.startedAt.Format(time.RFC3339), + ExpiresAt: item.expiresAt.Format(time.RFC3339), + } + return info, true, nil +} + +func (h *Hub) EndLiveSession(docID, userID string) bool { + h.mu.Lock() + defer h.mu.Unlock() + + existing, exists := h.liveSessions[docID] + if !exists { + return false + } + if userID != "" && existing.initiatorID != userID && userID != "admin" { + return false + } + delete(h.liveSessions, docID) + return true +} + +func (h *Hub) GetLiveSession(docID string) *domain.LiveSessionInfo { + h.mu.RLock() + defer h.mu.RUnlock() + + now := time.Now() + existing, exists := h.liveSessions[docID] + if !exists || existing.expiresAt.Before(now) { + return nil + } + return &domain.LiveSessionInfo{ + DocID: existing.docID, + InitiatorID: existing.initiatorID, + InitiatorName: existing.initiatorName, + StartedAt: existing.startedAt.Format(time.RFC3339), + ExpiresAt: existing.expiresAt.Format(time.RFC3339), + } +} + func (h *Hub) cleanupLocksLoop() { ticker := time.NewTicker(2 * time.Second) defer ticker.Stop() @@ -470,6 +673,7 @@ func (h *Hub) cleanupLocksLoop() { case <-ticker.C: h.mu.Lock() now := time.Now() + var expiredDocIDs []string for docID, l := range h.locks { if l.expiresAt.Before(now) { @@ -477,11 +681,42 @@ func (h *Hub) cleanupLocksLoop() { delete(h.locks, docID) } } + + type expBlock struct { + docID string + blockID string + } + var expiredBlocks []expBlock + for k, bl := range h.blockLocks { + if bl.expiresAt.Before(now) { + expiredBlocks = append(expiredBlocks, expBlock{docID: bl.docID, blockID: bl.blockID}) + delete(h.blockLocks, k) + } + } + + var expiredLiveSessions []string + for docID, ls := range h.liveSessions { + if ls.expiresAt.Before(now) { + expiredLiveSessions = append(expiredLiveSessions, docID) + delete(h.liveSessions, docID) + } + } h.mu.Unlock() for _, docID := range expiredDocIDs { h.BroadcastToAll("DOC_UNLOCKED", map[string]string{"docId": docID, "doc_id": docID}) } + for _, b := range expiredBlocks { + h.BroadcastRoom("doc:"+b.docID, "DOC_BLOCK_UNLOCKED", map[string]string{ + "docId": b.docID, + "blockId": b.blockID, + }, nil) + } + for _, docID := range expiredLiveSessions { + h.BroadcastRoom("doc:"+docID, "DOC_LIVE_ENDED", map[string]string{ + "docId": docID, + }, nil) + } } } } -- 2.54.0 From 996f27133cf3021553aa97a650f45b11e65d4ab9 Mon Sep 17 00:00:00 2001 From: Sandro Date: Tue, 27 Oct 2026 14:57:30 +0100 Subject: [PATCH 05/30] fix(collab): fix self-lockout, stuck live sessions, block locking propagation and reactive hashtags --- .../src/features/workspace/DocumentEditor.tsx | 185 +++++++++++++----- .../src/features/workspace/OutlinerTree.tsx | 2 +- .../src/features/workspace/WorkspaceView.tsx | 116 ++++++++--- frontend/src/lib/api.ts | 33 +++- internal/api/server.go | 45 ++++- internal/ws/hub.go | 11 ++ 6 files changed, 303 insertions(+), 89 deletions(-) diff --git a/frontend/src/features/workspace/DocumentEditor.tsx b/frontend/src/features/workspace/DocumentEditor.tsx index 6f81208..1fdf219 100644 --- a/frontend/src/features/workspace/DocumentEditor.tsx +++ b/frontend/src/features/workspace/DocumentEditor.tsx @@ -14,9 +14,10 @@ import { Minimize2, Sparkles, Plus, + AlertTriangle, } from 'lucide-react'; import { Document, DocumentBlock, User, BlockLockInfo, LiveSessionInfo, WSMessage } from '../../lib/types'; -import { api, getToken, onWebSocketMessage } from '../../lib/api'; +import { api, getToken, getCurrentUserId, createRealtimeConnection } from '../../lib/api'; import { useDocumentSync, DocSyncEvent, RemoteCursor, DocSyncStatus, getUserColor } from '../../lib/useDocumentSync'; import { OutlinerTree } from './OutlinerTree'; @@ -229,6 +230,11 @@ export const DocumentEditor: React.FC = ({ return computeBreadcrumbs(title || doc?.title || 'Dokument', blocks, zoomedBlockId); }, [title, doc?.title, blocks, zoomedBlockId]); + // Reliable current user ID from prop or JWT token + const currentUserId = useMemo(() => { + return currentUser?.id || getCurrentUserId() || ''; + }, [currentUser?.id]); + // Crucial fix: Collaborator with write permission can edit! const canEdit = useMemo(() => { if (!doc) return true; @@ -237,9 +243,19 @@ export const DocumentEditor: React.FC = ({ }, [doc]); // Is this document completely locked because another user has an active live session? - const isLockedByOtherLive = Boolean(liveSession && liveSession.initiatorId !== currentUser?.id); + const isLockedByOtherLive = Boolean( + liveSession && + liveSession.initiatorId && + currentUserId && + liveSession.initiatorId !== currentUserId + ); const effectiveCanEdit = canEdit && !isLockedByOtherLive; + const isLiveInitiatorOrOwner = Boolean( + (liveSession && currentUserId && liveSession.initiatorId === currentUserId) || + (doc && (doc.isOwner || doc.permission === 'owner')) + ); + // Handle incoming WebSocket events for THIS document room const handleRemoteEvent = useCallback( (evt: DocSyncEvent) => { @@ -524,23 +540,58 @@ export const DocumentEditor: React.FC = ({ onLiveStateChange?.(computedLiveState, liveSession?.initiatorName); }, [computedLiveState, liveSession?.initiatorName, onLiveStateChange]); - const handleToggleLive = useCallback(() => { - if (isLiveEnabled) { - api.endLiveSession(docId).catch(() => {}); + const isLiveEnabledRef = useRef(isLiveEnabled); + isLiveEnabledRef.current = isLiveEnabled; + + const handleToggleLive = useCallback(async () => { + if (isLiveEnabled || isLiveInitiatorOrOwner) { + try { + await api.endLiveSession(docId); + } catch {} setIsLiveEnabled(false); setLiveSession(null); } else { if (isLockedByOtherLive) return; - api.startLiveSession(docId) - .then((session) => { - setLiveSession(session); - setIsLiveEnabled(true); - }) - .catch((err) => { - console.error('[DocumentEditor] Failed to start live session:', err); - }); + try { + const session = await api.startLiveSession(docId); + setLiveSession(session); + setIsLiveEnabled(true); + } catch (err) { + console.error('[DocumentEditor] Failed to start live session:', err); + } } - }, [docId, isLiveEnabled, isLockedByOtherLive]); + }, [docId, isLiveEnabled, isLiveInitiatorOrOwner, isLockedByOtherLive]); + + // Keep live session lease alive while isLiveEnabled + useEffect(() => { + if (!isLiveEnabled || !docId) return; + const interval = window.setInterval(() => { + api.startLiveSession(docId).catch(() => {}); + }, 20000); + return () => window.clearInterval(interval); + }, [isLiveEnabled, docId]); + + // Cleanup live session on page unload + useEffect(() => { + const handleUnloadLive = () => { + if (isLiveEnabledRef.current) { + const token = getToken(); + if (token) { + fetch(`/api/v1/documents/${docId}/live/end`, { + method: 'POST', + headers: { Authorization: `Bearer ${token}` }, + keepalive: true, + }).catch(() => {}); + } + } + }; + window.addEventListener('beforeunload', handleUnloadLive); + window.addEventListener('pagehide', handleUnloadLive); + return () => { + window.removeEventListener('beforeunload', handleUnloadLive); + window.removeEventListener('pagehide', handleUnloadLive); + }; + }, [docId]); useEffect(() => { if (registerLiveToggle) { @@ -556,7 +607,7 @@ export const DocumentEditor: React.FC = ({ if (cancelled) return; if (res.liveSession) { setLiveSession(res.liveSession); - if (res.liveSession.initiatorId === currentUser?.id) { + if (currentUserId && res.liveSession.initiatorId === currentUserId) { setIsLiveEnabled(true); } } @@ -573,36 +624,51 @@ export const DocumentEditor: React.FC = ({ return () => { cancelled = true; }; - }, [docId, currentUser?.id]); + }, [docId, currentUserId]); // Subscribe to realtime lock & live session events useEffect(() => { - const unsub = onWebSocketMessage((msg: WSMessage) => { - if (msg.event === 'DOC_BLOCK_LOCKED' && msg.payload?.docId === docId) { - setActiveBlockLocks((prev) => ({ - ...prev, - [msg.payload.blockId]: msg.payload, - })); - } else if (msg.event === 'DOC_BLOCK_UNLOCKED' && msg.payload?.docId === docId) { - setActiveBlockLocks((prev) => { - const next = { ...prev }; - delete next[msg.payload.blockId]; - return next; - }); - } else if (msg.event === 'DOC_LIVE_STARTED' && msg.payload?.docId === docId) { - setLiveSession(msg.payload); - } else if (msg.event === 'DOC_LIVE_ENDED' && msg.payload?.docId === docId) { - setLiveSession(null); - if (liveSession?.initiatorId === currentUser?.id) { - setIsLiveEnabled(false); + const conn = createRealtimeConnection({ + docId, + onMessage: (msg: WSMessage) => { + if (msg.event === 'DOC_BLOCK_LOCKED') { + const p = (msg.payload?.lock || msg.payload) as BlockLockInfo; + if (p?.docId === docId && p?.blockId) { + setActiveBlockLocks((prev) => ({ + ...prev, + [p.blockId]: p, + })); + } + } else if (msg.event === 'DOC_BLOCK_UNLOCKED') { + const p = msg.payload; + if (p?.docId === docId && p?.blockId) { + setActiveBlockLocks((prev) => { + const next = { ...prev }; + delete next[p.blockId]; + return next; + }); + } + } else if (msg.event === 'DOC_LIVE_STARTED') { + const s = (msg.payload?.session || msg.payload) as LiveSessionInfo; + if (s?.docId === docId) { + setLiveSession(s); + if (currentUserId && s.initiatorId === currentUserId) { + setIsLiveEnabled(true); + } + } + } else if (msg.event === 'DOC_LIVE_ENDED') { + if (msg.payload?.docId === docId) { + setLiveSession(null); + setIsLiveEnabled(false); + } } - } + }, }); return () => { - unsub(); + conn.close(); }; - }, [docId, currentUser?.id, liveSession?.initiatorId]); + }, [docId, currentUserId]); // Block focus/blur locks const localUserColor = useMemo( @@ -1127,11 +1193,42 @@ export const DocumentEditor: React.FC = ({ {/* Live Session Active Banner (when another collaborator is live) */} {isLockedByOtherLive && ( -
- - - Live-Sitzung aktiv (Bearbeitet von {liveSession?.initiatorName || 'anderer Nutzer'}) – Dokument schreibgeschützt, um Versionskonflikte zu vermeiden. - +
+
+ + + Live-Sitzung aktiv (Bearbeitet von {liveSession?.initiatorName || 'anderer Nutzer'}) – Dokument schreibgeschützt, um Versionskonflikte zu vermeiden. + +
+ {isLiveInitiatorOrOwner && ( + + )} +
+ )} + + {/* WebSocket Blocked Banner (when current user has live enabled but WS fails in corporate network) */} + {isLiveEnabled && syncStatus === 'blocked' && ( +
+
+ + + Live-Zusammenarbeit nicht möglich (WebSockets im Firmennetzwerk blockiert). HTTPS Auto-Save ist aktiv. + +
+
)} @@ -1235,7 +1332,7 @@ export const DocumentEditor: React.FC = ({ activeBlockLocks={activeBlockLocks} onFocusBlock={handleFocusBlock} onBlurBlock={handleBlurBlock} - currentUserId={currentUser?.id} + currentUserId={currentUserId} /> )}
@@ -1303,7 +1400,7 @@ export const DocumentEditor: React.FC = ({ activeBlockLocks={activeBlockLocks} onFocusBlock={handleFocusBlock} onBlurBlock={handleBlurBlock} - currentUserId={currentUser?.id} + currentUserId={currentUserId} /> )} diff --git a/frontend/src/features/workspace/OutlinerTree.tsx b/frontend/src/features/workspace/OutlinerTree.tsx index d292226..94a9cff 100644 --- a/frontend/src/features/workspace/OutlinerTree.tsx +++ b/frontend/src/features/workspace/OutlinerTree.tsx @@ -329,7 +329,7 @@ export const OutlinerTree: React.FC = ({
{visibleEntries.map(({ block, index, hasChildren, maxAllowedLevel }) => { const lockInfo = activeBlockLocks?.[block.id]; - const isLockedByOther = Boolean(lockInfo && lockInfo.userId !== currentUserId); + const isLockedByOther = Boolean(lockInfo && currentUserId && lockInfo.userId !== currentUserId); return ( = ({ const [docs, setDocs] = useState([]); const [folders, setFolders] = useState([]); const [sharedFolders, setSharedFolders] = useState([]); - const [tagsSummary, setTagsSummary] = useState([]); + const [serverTagsSummary, setServerTagsSummary] = useState([]); + + // Dynamically compute hashtag counts directly from in-memory documents + const tagsSummary = useMemo(() => { + if (docs.length === 0) return serverTagsSummary; + const tagMap = new Map(); + for (const d of docs) { + for (const b of d.blocks || []) { + const matches = b.content?.match(/#([a-zA-Z0-9äöüÄÖÜß_-]+)/g); + if (matches) { + for (const m of matches) { + const clean = m.slice(1).toLowerCase(); + tagMap.set(clean, (tagMap.get(clean) || 0) + 1); + } + } + } + } + const list: TagSummary[] = []; + tagMap.forEach((count, tag) => { + list.push({ tag, count }); + }); + return list.sort((a, b) => b.count - a.count || a.tag.localeCompare(b.tag)); + }, [docs, serverTagsSummary]); const [selectedDocId, setSelectedDocId] = useState(initialDocId || null); const [targetBlockId, setTargetBlockId] = useState(null); @@ -356,7 +378,7 @@ export const WorkspaceView: React.FC = ({ setDocs(docsRes); setFolders(foldersRes); setSharedFolders(sharedFoldersRes); - setTagsSummary(tagsRes); + setServerTagsSummary(tagsRes); setSelectedDocId((prevId) => { if (prevId && docsRes.some((d) => d.id === prevId)) { @@ -1196,32 +1218,64 @@ export const WorkspaceView: React.FC = ({ {/* Interactive Live Collaboration Button (replaces passive radio icon) */} {docLiveState === 'locked_by_other' ? ( -
- - Live: {docLiveInitiator || 'Gesperrt'} +
+
+ + Live: {docLiveInitiator || 'Gesperrt'} +
+ {(selectedDocObj?.isOwner || selectedDocObj?.permission === 'owner') && ( + + )}
) : docLiveState === 'blocked' ? ( - +
+ + +
) : docLiveState === 'connecting' ? ( - +
+ + +
) : docLiveState === 'active' ? (
-
+
+
) : ( @@ -220,7 +228,7 @@ export const UnifiedShareModal: React.FC = ({ placeholder="Benutzer suchen (Name oder @Benutzername)…" />
- {allowRoleSelection && ( + {allowRoleSelection ? ( + ) : ( + + + Nur Lesen + )}
)} diff --git a/frontend/src/features/workspace/DocumentEditor.tsx b/frontend/src/features/workspace/DocumentEditor.tsx index 8ebb4bc..77d2bd1 100644 --- a/frontend/src/features/workspace/DocumentEditor.tsx +++ b/frontend/src/features/workspace/DocumentEditor.tsx @@ -7,22 +7,18 @@ import { FileText, Loader2, ArrowLeft, - Hash, Lock, Home, ChevronRight, Minimize2, Sparkles, Plus, - AlertTriangle, } from 'lucide-react'; -import { Document, DocumentBlock, User, BlockLockInfo, LiveSessionInfo, WSMessage } from '../../lib/types'; -import { api, getToken, getCurrentUserId, createRealtimeConnection } from '../../lib/api'; -import { useDocumentSync, DocSyncEvent, RemoteCursor, DocSyncStatus, getUserColor } from '../../lib/useDocumentSync'; +import { Document, DocumentBlock, User } from '../../lib/types'; +import { api, getToken } from '../../lib/api'; import { OutlinerTree } from './OutlinerTree'; export type DocSaveStatus = 'saved' | 'saving' | 'unsaved' | 'error'; -export type DocLiveState = 'idle' | 'connecting' | 'active' | 'blocked' | 'locked_by_other'; export interface DocumentEditorProps { docId: string; @@ -37,10 +33,7 @@ export interface DocumentEditorProps { onSelectTag: (tag: string) => void; onWikiLinkClick?: (pageTitle: string) => void; onBackMobile?: () => void; - onSyncStatusChange?: (status: DocSyncStatus) => void; onSaveStatusChange?: (status: DocSaveStatus, errorMsg?: string) => void; - onLiveStateChange?: (state: DocLiveState, initiatorName?: string) => void; - registerLiveToggle?: (handler: () => void) => void; } const EMOJI_PALETTE = ['📝', '📄', '💡', '🚀', '📌', '📂', '🧠', '⚡', '🎯', '🛠️', '📊', '🔒']; @@ -151,7 +144,6 @@ export const DocumentEditor: React.FC = ({ docId, currentUser, initialDoc, - availableFolders, autoFocusTitle, initialFocusedBlockId, onDocMetadataChange, @@ -160,10 +152,7 @@ export const DocumentEditor: React.FC = ({ onSelectTag, onWikiLinkClick, onBackMobile, - onSyncStatusChange, onSaveStatusChange, - onLiveStateChange, - registerLiveToggle, }) => { const [doc, setDoc] = useState(initialDoc || null); const [title, setTitle] = useState(initialDoc?.title || ''); @@ -175,13 +164,8 @@ export const DocumentEditor: React.FC = ({ const [showEmojiPicker, setShowEmojiPicker] = useState(false); const [zoomedBlockId, setZoomedBlockId] = useState(null); - // Live Collaboration & Locks state - const [liveSession, setLiveSession] = useState(null); - const [isLiveEnabled, setIsLiveEnabled] = useState(false); - const [activeBlockLocks, setActiveBlockLocks] = useState>({}); - - // AutoSave state - const [saveStatus, setSaveStatus] = useState('saved'); + // AutoSave state (REST persistence) + const [, setSaveStatus] = useState('saved'); const [, setSaveErrorMessage] = useState(null); const saveTimeoutRef = useRef(null); const isSavingRef = useRef(false); @@ -189,7 +173,6 @@ export const DocumentEditor: React.FC = ({ const isInitialLoadRef = useRef(true); const titleInputRef = useRef(null); - const titleDebounceRef = useRef(null); const docRef = useRef(doc); docRef.current = doc; @@ -231,157 +214,12 @@ export const DocumentEditor: React.FC = ({ return computeBreadcrumbs(title || doc?.title || 'Dokument', blocks, zoomedBlockId); }, [title, doc?.title, blocks, zoomedBlockId]); - // Reliable current user ID from prop or JWT token - const currentUserId = useMemo(() => { - return currentUser?.id || getCurrentUserId() || ''; - }, [currentUser?.id]); - - // Crucial fix: Collaborator with write permission can edit! + // Strictly Read-Only for shared documents: only the document owner can edit! const canEdit = useMemo(() => { if (!doc) return true; - if (doc.isOwner) return true; - return doc.permission === 'owner' || doc.permission === 'write'; + return Boolean(doc.isOwner || doc.permission === 'owner'); }, [doc]); - // Is this document completely locked because another user has an active live session? - const isLockedByOtherLive = Boolean( - liveSession && - liveSession.initiatorId && - currentUserId && - liveSession.initiatorId !== currentUserId - ); - const effectiveCanEdit = canEdit && !isLockedByOtherLive; - - const isLiveInitiatorOrOwner = Boolean( - (liveSession && currentUserId && liveSession.initiatorId === currentUserId) || - (doc && (doc.isOwner || doc.permission === 'owner')) - ); - - // Handle incoming WebSocket events for THIS document room - const handleRemoteEvent = useCallback( - (evt: DocSyncEvent) => { - if (evt.docId && evt.docId !== docId) return; - - switch (evt.type) { - case 'doc_state': { - if (evt.title !== undefined) { - const isTitleActivelyEditing = - titleInputRef.current !== null && - document.activeElement === titleInputRef.current && - typeof document !== 'undefined' && - document.hasFocus(); - if (!isTitleActivelyEditing) { - setTitle(evt.title); - } - } - if (Array.isArray(evt.blocks) && evt.blocks.length > 0) { - setBlocks(ensureNonEmptyBlocks(docId, evt.blocks)); - } - break; - } - - case 'title_updated': { - if (evt.title !== undefined) { - const isTitleActivelyEditing = - titleInputRef.current !== null && - document.activeElement === titleInputRef.current && - typeof document !== 'undefined' && - document.hasFocus(); - if (!isTitleActivelyEditing) { - setTitle(evt.title); - } - if (docRef.current) { - const updated = { ...docRef.current, title: evt.title }; - setDoc(updated); - onDocMetadataChange(updated); - } - } - break; - } - - case 'block_created': { - if (!evt.block) return; - const incoming = evt.block; - setBlocks((prev) => { - if (prev.some((b) => b.id === incoming.id)) { - return prev.map((b) => (b.id === incoming.id ? incoming : b)); - } - const next = [...prev]; - if (evt.afterBlockId) { - const idx = next.findIndex((b) => b.id === evt.afterBlockId); - if (idx >= 0) { - next.splice(idx + 1, 0, incoming); - return ensureNonEmptyBlocks(docId, next); - } - } - next.push(incoming); - return ensureNonEmptyBlocks(docId, next); - }); - break; - } - - case 'block_updated': { - const targetId = evt.blockId || evt.block?.id; - if (!targetId) return; - setBlocks((prev) => - prev.map((b) => { - if (b.id !== targetId) return b; - if (evt.block) { - return { ...b, ...evt.block }; - } - return { - ...b, - content: evt.content !== undefined ? evt.content : b.content, - level: evt.level !== undefined ? evt.level : b.level, - checked: evt.checked !== undefined ? evt.checked : b.checked, - collapsed: evt.collapsed !== undefined ? evt.collapsed : b.collapsed, - }; - }) - ); - break; - } - - case 'block_deleted': { - if (!evt.blockId) return; - setBlocks((prev) => - ensureNonEmptyBlocks( - docId, - prev.filter((b) => b.id !== evt.blockId) - ) - ); - break; - } - - case 'block_moved': { - if (!evt.blockId || evt.newIndex === undefined) return; - const targetIndex = evt.newIndex; - setBlocks((prev) => { - const fromIdx = prev.findIndex((b) => b.id === evt.blockId); - if (fromIdx === -1) return prev; - const copy = [...prev]; - const [moving] = copy.splice(fromIdx, 1); - const updatedMoving: DocumentBlock = { - ...moving, - level: evt.level !== undefined ? evt.level : moving.level, - }; - const boundedIdx = Math.max(0, Math.min(targetIndex, copy.length)); - copy.splice(boundedIdx, 0, updatedMoving); - return ensureNonEmptyBlocks(docId, copy); - }); - break; - } - - case 'blocks_replaced': { - if (Array.isArray(evt.blocks)) { - setBlocks(ensureNonEmptyBlocks(docId, evt.blocks)); - } - break; - } - } - }, - [docId, onDocMetadataChange] - ); - // Extract all #tags present across the current blocks const extractedTags = useMemo(() => { const tagSet = new Set(); @@ -399,7 +237,7 @@ export const DocumentEditor: React.FC = ({ const currentTagsRef = useRef(extractedTags); currentTagsRef.current = extractedTags; - // AutoSave implementation (authoritative persistence via HTTP REST) + // AutoSave snapshot const currentSnapshot = useMemo(() => { return JSON.stringify({ title, @@ -413,7 +251,7 @@ export const DocumentEditor: React.FC = ({ const executeAutoSave = useCallback( async (immediate: boolean = false) => { - if (!docId || !canEdit || isLockedByOtherLive) return; + if (!docId || !canEdit) return; if (isSavingRef.current && !immediate) return; const snapshot = currentSnapshotRef.current; @@ -451,11 +289,12 @@ export const DocumentEditor: React.FC = ({ onSaveStatusChange?.('error', msg); } }, - [docId, canEdit, isLockedByOtherLive, onSaveStatusChange, onDocMetadataChange] + [docId, canEdit, onSaveStatusChange, onDocMetadataChange] ); useEffect(() => { if (isInitialLoadRef.current) return; + if (!canEdit) return; if (currentSnapshot === lastSavedSnapshotRef.current) return; setSaveStatus('unsaved'); @@ -475,7 +314,7 @@ export const DocumentEditor: React.FC = ({ saveTimeoutRef.current = null; } }; - }, [currentSnapshot, executeAutoSave, onSaveStatusChange]); + }, [currentSnapshot, canEdit, executeAutoSave, onSaveStatusChange]); // Flush on page unload / pagehide via keepalive fetch useEffect(() => { @@ -485,7 +324,7 @@ export const DocumentEditor: React.FC = ({ saveTimeoutRef.current = null; } const snapshot = currentSnapshotRef.current; - if (snapshot !== lastSavedSnapshotRef.current && canEdit && !isLockedByOtherLive) { + if (snapshot !== lastSavedSnapshotRef.current && canEdit) { const token = getToken(); if (token) { fetch(`/api/v1/documents/${docId}`, { @@ -512,388 +351,9 @@ export const DocumentEditor: React.FC = ({ window.removeEventListener('pagehide', handleUnload); handleUnload(); }; - }, [docId, canEdit, isLockedByOtherLive]); + }, [docId, canEdit]); - const { - status: syncStatus, - remoteCursors, - sendCursorMoved, - sendBlockCreated, - sendBlockUpdated, - sendBlockDeleted, - sendBlockMoved, - sendTitleUpdated, - } = useDocumentSync(docId, handleRemoteEvent, effectiveCanEdit, currentUser, isLiveEnabled); - - useEffect(() => { - onSyncStatusChange?.(syncStatus); - }, [syncStatus, onSyncStatusChange]); - - const computedLiveState: DocLiveState = useMemo(() => { - if (isLockedByOtherLive) return 'locked_by_other'; - if (!isLiveEnabled) return 'idle'; - if (syncStatus === 'connecting' || syncStatus === 'reconnecting') return 'connecting'; - if (syncStatus === 'blocked') return 'blocked'; - if (syncStatus === 'connected') return 'active'; - return 'idle'; - }, [isLockedByOtherLive, isLiveEnabled, syncStatus]); - - useEffect(() => { - onLiveStateChange?.(computedLiveState, liveSession?.initiatorName); - }, [computedLiveState, liveSession?.initiatorName, onLiveStateChange]); - - const isLiveEnabledRef = useRef(isLiveEnabled); - isLiveEnabledRef.current = isLiveEnabled; - - const handleToggleLive = useCallback(async () => { - if (isLiveEnabled || isLiveInitiatorOrOwner) { - try { - await api.endLiveSession(docId); - } catch {} - setIsLiveEnabled(false); - setLiveSession(null); - } else { - if (isLockedByOtherLive) return; - try { - const session = await api.startLiveSession(docId); - setLiveSession(session); - setIsLiveEnabled(true); - } catch (err) { - console.error('[DocumentEditor] Failed to start live session:', err); - } - } - }, [docId, isLiveEnabled, isLiveInitiatorOrOwner, isLockedByOtherLive]); - - // Keep live session lease alive while isLiveEnabled - useEffect(() => { - if (!isLiveEnabled || !docId) return; - const interval = window.setInterval(() => { - api.startLiveSession(docId).catch(() => {}); - }, 20000); - return () => window.clearInterval(interval); - }, [isLiveEnabled, docId]); - - // Cleanup live session on page unload - useEffect(() => { - const handleUnloadLive = () => { - if (isLiveEnabledRef.current) { - const token = getToken(); - if (token) { - fetch(`/api/v1/documents/${docId}/live/end`, { - method: 'POST', - headers: { Authorization: `Bearer ${token}` }, - keepalive: true, - }).catch(() => {}); - } - } - }; - window.addEventListener('beforeunload', handleUnloadLive); - window.addEventListener('pagehide', handleUnloadLive); - return () => { - window.removeEventListener('beforeunload', handleUnloadLive); - window.removeEventListener('pagehide', handleUnloadLive); - }; - }, [docId]); - - useEffect(() => { - if (registerLiveToggle) { - registerLiveToggle(handleToggleLive); - } - }, [registerLiveToggle, handleToggleLive]); - - // Handle remote document updates (from other users saving via HTTP or realtime) - const handleRemoteDocUpdated = useCallback( - (remoteDoc: Document) => { - if (!remoteDoc || remoteDoc.id !== docId || !Array.isArray(remoteDoc.blocks)) return; - - const isTitleActivelyEditing = - titleInputRef.current !== null && - document.activeElement === titleInputRef.current && - typeof document !== 'undefined' && - document.hasFocus(); - - let targetTitle = currentTitleRef.current; - if (!isTitleActivelyEditing && remoteDoc.title !== undefined) { - setTitle(remoteDoc.title); - currentTitleRef.current = remoteDoc.title; - targetTitle = remoteDoc.title; - } - - // Merge remote blocks: preserve any block currently being actively typed by local user, - // update all other blocks from the authoritative server state. - const localFocusedId = focusedBlockIdRef.current; - const localBlocks = currentBlocksRef.current; - const localFocusedBlock = localFocusedId ? localBlocks.find((b) => b.id === localFocusedId) : null; - - const mergedBlocks = remoteDoc.blocks.map((remoteB) => { - if (localFocusedId && remoteB.id === localFocusedId && localFocusedBlock) { - return { ...remoteB, content: localFocusedBlock.content }; - } - return remoteB; - }); - - // If local newly-created block isn't on server yet, retain it - if (localFocusedId && localFocusedBlock && !mergedBlocks.some((b) => b.id === localFocusedId)) { - mergedBlocks.push(localFocusedBlock); - } - - const nonEmpties = ensureNonEmptyBlocks(docId, mergedBlocks); - setBlocks(nonEmpties); - currentBlocksRef.current = nonEmpties; - - const newSnapshot = JSON.stringify({ - title: targetTitle, - blocks: nonEmpties, - tags: remoteDoc.tags || currentTagsRef.current || [], - }); - lastSavedSnapshotRef.current = newSnapshot; - currentSnapshotRef.current = newSnapshot; - if (remoteDoc.updatedAt) { - lastKnownUpdatedAtRef.current = remoteDoc.updatedAt; - } - - if (docRef.current) { - docRef.current = { ...docRef.current, ...remoteDoc }; - } - onDocMetadataChange(remoteDoc); - }, - [docId, onDocMetadataChange] - ); - - const handleRemoteBlocksPatched = useCallback( - (patch: { upserted?: DocumentBlock[]; deletedIDs?: string[]; blockOrder?: string[] }) => { - console.log('[DocumentEditor] DOC_BLOCKS_PATCHED empfangen:', patch); - const localFocusedId = focusedBlockIdRef.current; - const localBlocks = currentBlocksRef.current; - const localFocusedBlock = localFocusedId ? localBlocks.find((b) => b.id === localFocusedId) : null; - - const blockMap = new Map(); - for (const b of localBlocks) { - blockMap.set(b.id, b); - } - - if (Array.isArray(patch.upserted)) { - for (const u of patch.upserted) { - if (localFocusedId && u.id === localFocusedId && localFocusedBlock) { - blockMap.set(u.id, { ...u, content: localFocusedBlock.content }); - } else { - blockMap.set(u.id, u); - } - } - } - - if (Array.isArray(patch.deletedIDs)) { - for (const delId of patch.deletedIDs) { - if (delId !== localFocusedId) { - blockMap.delete(delId); - } - } - } - - let reconciledBlocks: DocumentBlock[] = []; - if (Array.isArray(patch.blockOrder) && patch.blockOrder.length > 0) { - for (const id of patch.blockOrder) { - const b = blockMap.get(id); - if (b) reconciledBlocks.push(b); - } - blockMap.forEach((b, id) => { - if (!patch.blockOrder!.includes(id)) { - reconciledBlocks.push(b); - } - }); - } else { - reconciledBlocks = Array.from(blockMap.values()); - } - - const nonEmpties = ensureNonEmptyBlocks(docId, reconciledBlocks); - setBlocks(nonEmpties); - currentBlocksRef.current = nonEmpties; - - const newSnapshot = JSON.stringify({ - title: currentTitleRef.current, - blocks: nonEmpties, - tags: currentTagsRef.current, - }); - lastSavedSnapshotRef.current = newSnapshot; - currentSnapshotRef.current = newSnapshot; - }, - [docId] - ); - - // Sync when initialDoc prop changes externally - useEffect(() => { - if (initialDoc && initialDoc.id === docId && initialDoc.updatedAt && initialDoc.updatedAt !== lastKnownUpdatedAtRef.current) { - lastKnownUpdatedAtRef.current = initialDoc.updatedAt; - handleRemoteDocUpdated(initialDoc); - } - }, [initialDoc, docId, handleRemoteDocUpdated]); - - // Periodic poll for locks & document text (guarantees text synchronization even without WebSockets) - useEffect(() => { - if (!docId) return; - let isCancelled = false; - - const syncLocksAndDoc = async () => { - try { - // 1. Sync locks - const locksRes = await api.getDocLocks(docId); - if (isCancelled) return; - if (Array.isArray(locksRes.blockLocks)) { - const map: Record = {}; - for (const l of locksRes.blockLocks) { - map[l.blockId] = l; - } - setActiveBlockLocks(map); - } - if (locksRes.liveSession) { - setLiveSession(locksRes.liveSession); - } else { - setLiveSession(null); - } - - // 2. Sync document content if not currently actively saving - if (!isSavingRef.current) { - const remoteDoc = await api.getDocument(docId); - if (isCancelled || !remoteDoc) return; - - // Check if server has newer or different state - if (remoteDoc.updatedAt && remoteDoc.updatedAt !== lastKnownUpdatedAtRef.current) { - console.log(`[DocumentEditor] Polling: Neuer Text-Stand vom Server (${remoteDoc.updatedAt}). Aktualisiere nicht-fokussierte Blöcke...`); - lastKnownUpdatedAtRef.current = remoteDoc.updatedAt; - handleRemoteDocUpdated(remoteDoc); - } - } - } catch (err) { - // ignore poll errors - } - }; - - syncLocksAndDoc(); - const interval = window.setInterval(syncLocksAndDoc, 2000); - return () => { - isCancelled = true; - window.clearInterval(interval); - }; - }, [docId, handleRemoteDocUpdated]); - - // Subscribe to realtime lock & live session events - useEffect(() => { - const conn = createRealtimeConnection({ - docId, - onMessage: (msg: WSMessage) => { - if (msg.event === 'DOC_BLOCK_LOCKED') { - const p = (msg.payload?.lock || msg.payload) as BlockLockInfo; - console.log('[DocumentEditor] DOC_BLOCK_LOCKED empfangen:', p); - if (p?.docId === docId && p?.blockId) { - setActiveBlockLocks((prev) => ({ - ...prev, - [p.blockId]: p, - })); - } - } else if (msg.event === 'DOC_BLOCK_UNLOCKED') { - const p = msg.payload; - console.log('[DocumentEditor] DOC_BLOCK_UNLOCKED empfangen:', p); - if (p?.docId === docId && p?.blockId) { - setActiveBlockLocks((prev) => { - const next = { ...prev }; - delete next[p.blockId]; - return next; - }); - } - } else if (msg.event === 'DOC_UPDATED') { - const p = msg.payload as Document; - console.log('[DocumentEditor] DOC_UPDATED von anderem Benutzer empfangen:', p); - if (p?.id === docId && Array.isArray(p?.blocks)) { - handleRemoteDocUpdated(p); - } - } else if (msg.event === 'DOC_BLOCKS_PATCHED') { - const p = msg.payload; - console.log('[DocumentEditor] DOC_BLOCKS_PATCHED von Server empfangen:', p); - if (p?.docId === docId) { - handleRemoteBlocksPatched(p); - } - } else if (msg.event === 'DOC_LIVE_STARTED') { - const s = (msg.payload?.session || msg.payload) as LiveSessionInfo; - console.log('[DocumentEditor] DOC_LIVE_STARTED empfangen:', s); - if (s?.docId === docId) { - setLiveSession(s); - if (currentUserId && s.initiatorId === currentUserId) { - setIsLiveEnabled(true); - } - } - } else if (msg.event === 'DOC_LIVE_ENDED') { - console.log('[DocumentEditor] DOC_LIVE_ENDED empfangen'); - if (msg.payload?.docId === docId) { - setLiveSession(null); - setIsLiveEnabled(false); - } - } - }, - }); - - return () => { - conn.close(); - }; - }, [docId, currentUserId, handleRemoteDocUpdated, handleRemoteBlocksPatched]); - - // Block focus/blur locks - const localUserColor = useMemo( - () => getUserColor(currentUser?.id || 'anon'), - [currentUser?.id] - ); - const focusedBlockIdRef = useRef(null); - const lockKeepaliveTimerRef = useRef(null); - - const handleFocusBlock = useCallback( - (blockId: string) => { - if (!effectiveCanEdit) return; - focusedBlockIdRef.current = blockId; - console.log(`[DocumentEditor] Fokus auf Block "${blockId}". Sende Lock an Server...`); - api.lockBlock(docId, blockId, localUserColor, 30).catch((err) => { - console.warn(`[DocumentEditor] Lock für Block "${blockId}" fehlgeschlagen:`, err); - }); - - if (lockKeepaliveTimerRef.current !== null) { - window.clearInterval(lockKeepaliveTimerRef.current); - } - lockKeepaliveTimerRef.current = window.setInterval(() => { - if (focusedBlockIdRef.current === blockId) { - api.lockBlock(docId, blockId, localUserColor, 30).catch(() => {}); - } - }, 15000); - }, - [docId, effectiveCanEdit, localUserColor] - ); - - const handleBlurBlock = useCallback( - (blockId: string) => { - console.log(`[DocumentEditor] Blur von Block "${blockId}". Gebe Lock frei und sichere per AutoSave...`); - if (lockKeepaliveTimerRef.current !== null) { - window.clearInterval(lockKeepaliveTimerRef.current); - lockKeepaliveTimerRef.current = null; - } - if (focusedBlockIdRef.current === blockId) { - focusedBlockIdRef.current = null; - } - api.unlockBlock(docId, blockId).catch(() => {}); - executeAutoSave(true); - }, - [docId, executeAutoSave] - ); - - useEffect(() => { - return () => { - if (lockKeepaliveTimerRef.current !== null) { - window.clearInterval(lockKeepaliveTimerRef.current); - lockKeepaliveTimerRef.current = null; - } - if (focusedBlockIdRef.current) { - api.unlockBlock(docId, focusedBlockIdRef.current).catch(() => {}); - } - }; - }, [docId]); - - // Fetch isolated document state on mount + // Fetch document on mount useEffect(() => { let isCancelled = false; if (!initialDoc) { @@ -939,10 +399,6 @@ export const DocumentEditor: React.FC = ({ return () => { isCancelled = true; - if (titleDebounceRef.current !== null) { - window.clearTimeout(titleDebounceRef.current); - titleDebounceRef.current = null; - } }; }, [docId]); @@ -960,78 +416,28 @@ export const DocumentEditor: React.FC = ({ onDocMetadataChange(updatedDoc); }, [blocks, extractedTags, title]); - // Active remote users in this room (excluding self) - const activeUsers = useMemo(() => { - const list: RemoteCursor[] = []; - const seen = new Set(); - for (const c of Object.values(remoteCursors)) { - const key = c.userId || c.clientId; - if (!seen.has(key)) { - seen.add(key); - list.push(c); - } - } - return list; - }, [remoteCursors]); - const handleTitleChange = (e: React.ChangeEvent) => { - if (!effectiveCanEdit) return; - const nextTitle = e.target.value; - setTitle(nextTitle); - - if (titleDebounceRef.current !== null) { - window.clearTimeout(titleDebounceRef.current); - } - titleDebounceRef.current = window.setTimeout(() => { - titleDebounceRef.current = null; - if (isLiveEnabled) { - sendTitleUpdated(nextTitle); - } - }, 250); + if (!canEdit) return; + setTitle(e.target.value); }; const handleTitleBlur = () => { - if (titleDebounceRef.current !== null) { - window.clearTimeout(titleDebounceRef.current); - titleDebounceRef.current = null; - if (isLiveEnabled) { - sendTitleUpdated(title); - } - } executeAutoSave(); }; - // Flush title debouncing on window blur so background window never holds stale pending title - useEffect(() => { - const handleWindowBlur = () => { - if (titleDebounceRef.current !== null) { - window.clearTimeout(titleDebounceRef.current); - titleDebounceRef.current = null; - if (isLiveEnabled) { - sendTitleUpdated(title); - } - } - }; - window.addEventListener('blur', handleWindowBlur); - return () => window.removeEventListener('blur', handleWindowBlur); - }, [title, isLiveEnabled, sendTitleUpdated]); - const handleUpdateBlock = useCallback( (updatedBlock: DocumentBlock) => { - if (!effectiveCanEdit) return; + if (!canEdit) return; setBlocks((prev) => prev.map((b) => (b.id === updatedBlock.id ? updatedBlock : b)) ); - if (isLiveEnabled) { - sendBlockUpdated(updatedBlock); - } }, - [effectiveCanEdit, isLiveEnabled, sendBlockUpdated] + [canEdit] ); const handleUpdateBlocks = useCallback( (updatedBlocks: DocumentBlock[]) => { - if (!effectiveCanEdit || updatedBlocks.length === 0) return; + if (!canEdit || updatedBlocks.length === 0) return; const map = new Map(); for (const b of updatedBlocks) { map.set(b.id, b); @@ -1039,60 +445,43 @@ export const DocumentEditor: React.FC = ({ setBlocks((prev) => prev.map((b) => map.get(b.id) || b) ); - if (isLiveEnabled) { - for (const b of updatedBlocks) { - sendBlockUpdated(b); - } - } }, - [effectiveCanEdit, isLiveEnabled, sendBlockUpdated] + [canEdit] ); const handleCreateBlock = useCallback( (newBlock: DocumentBlock, afterBlockId?: string) => { - if (!effectiveCanEdit) return; + if (!canEdit) return; setBlocks((prev) => { const next = [...prev]; if (afterBlockId) { const idx = next.findIndex((b) => b.id === afterBlockId); if (idx >= 0) { next.splice(idx + 1, 0, newBlock); - const normalized = ensureNonEmptyBlocks(docId, next); - if (isLiveEnabled) { - sendBlockCreated(newBlock, afterBlockId, normalized); - } - return normalized; + return ensureNonEmptyBlocks(docId, next); } } next.push(newBlock); - const normalized = ensureNonEmptyBlocks(docId, next); - if (isLiveEnabled) { - sendBlockCreated(newBlock, afterBlockId, normalized); - } - return normalized; + return ensureNonEmptyBlocks(docId, next); }); }, - [docId, effectiveCanEdit, isLiveEnabled, sendBlockCreated] + [docId, canEdit] ); const handleDeleteBlock = useCallback( (blockId: string) => { - if (!effectiveCanEdit) return; + if (!canEdit) return; setBlocks((prev) => { const filtered = prev.filter((b) => b.id !== blockId); - const normalized = ensureNonEmptyBlocks(docId, filtered); - if (isLiveEnabled) { - sendBlockDeleted(blockId, normalized); - } - return normalized; + return ensureNonEmptyBlocks(docId, filtered); }); }, - [docId, effectiveCanEdit, isLiveEnabled, sendBlockDeleted] + [docId, canEdit] ); const handleMoveBlock = useCallback( (blockId: string, targetIndex: number, newLevel?: number) => { - if (!effectiveCanEdit) return; + if (!canEdit) return; setBlocks((prev) => { const fromIdx = prev.findIndex((b) => b.id === blockId); if (fromIdx === -1) return prev; @@ -1141,15 +530,10 @@ export const DocumentEditor: React.FC = ({ })); copy.splice(insertIndex, 0, ...updatedSubtree); - const normalized = ensureNonEmptyBlocks(docId, copy); - - if (isLiveEnabled) { - sendBlockMoved(blockId, insertIndex, updatedSubtree[0].level, normalized); - } - return normalized; + return ensureNonEmptyBlocks(docId, copy); }); }, - [docId, effectiveCanEdit, isLiveEnabled, sendBlockMoved] + [docId, canEdit] ); const handleTogglePin = async () => { @@ -1165,7 +549,7 @@ export const DocumentEditor: React.FC = ({ const handleSelectIcon = async (icon: string) => { setShowEmojiPicker(false); - if (!doc || !effectiveCanEdit) return; + if (!doc || !canEdit) return; try { const updated = await api.updateDocument(doc.id, { icon }); setDoc(updated); @@ -1308,37 +692,9 @@ export const DocumentEditor: React.FC = ({ )}
- {/* Right Status & Action Buttons */} + {/* Right Action Buttons */}
- {/* Active Presence Avatars (Immer rund: rounded-full mit Überlappung) */} - {activeUsers.length > 0 && ( -
- {activeUsers.map((u) => { - const initials = (u.userName || 'A') - .split(' ') - .map((n) => n[0]) - .slice(0, 2) - .join('') - .toUpperCase(); - return ( -
-
- {initials} -
- {/* Tooltip */} -
- {u.userName} -
-
- ); - })} -
- )} - - {/* Sharing Section (1:1 styling from SharedListsView) */} + {/* Sharing Section */} {doc && (() => { const isOwner = doc.isOwner !== false && (doc.userId === currentUser?.id || !doc.userId || currentUser?.role === 'admin'); const sharesCount = doc.sharedWith?.length || 0; @@ -1418,44 +774,11 @@ export const DocumentEditor: React.FC = ({
- {/* Live Session Active Banner (when another collaborator is live) */} - {isLockedByOtherLive && ( -
-
- - - Live-Sitzung aktiv (Bearbeitet von {liveSession?.initiatorName || 'anderer Nutzer'}) – Dokument schreibgeschützt, um Versionskonflikte zu vermeiden. - -
- {isLiveInitiatorOrOwner && ( - - )} -
- )} - - {/* WebSocket Blocked Banner (when current user has live enabled but WS fails in corporate network) */} - {isLiveEnabled && syncStatus === 'blocked' && ( -
-
- - - Live-Zusammenarbeit nicht möglich (WebSockets im Firmennetzwerk blockiert). HTTPS Auto-Save ist aktiv. - -
- + {/* Read-only notification banner */} + {!canEdit && ( +
+ + Dieses Dokument wurde schreibgeschützt mit dir geteilt (Nur Lesen).
)} @@ -1466,7 +789,6 @@ export const DocumentEditor: React.FC = ({ FOKUS-MODUS & ZOOM-IN (HOISTED SUBTREE VIEW) ========================================================================= */
- {/* Prominent Zoomed Ast-Heading */}
@@ -1499,7 +821,7 @@ export const DocumentEditor: React.FC = ({ } }} placeholder="Ast-Titel…" - readOnly={!effectiveCanEdit} + readOnly={!canEdit} className="w-full text-2xl sm:text-3xl font-extrabold text-main bg-transparent border-0 focus:outline-none focus:ring-0 tracking-tight leading-tight" />
@@ -1515,11 +837,10 @@ export const DocumentEditor: React.FC = ({
- {/* Subtree Children or Empty State */} {childBlocks.length === 0 ? (

Dieser Ast hat noch keine Unterpunkte.

- {effectiveCanEdit && ( + {canEdit && (
@@ -1574,13 +889,13 @@ export const DocumentEditor: React.FC = ({
- {showEmojiPicker && effectiveCanEdit && ( + {showEmojiPicker && canEdit && (
{EMOJI_PALETTE.map((emoji) => (
) : block.type === 'callout' ? ( @@ -630,17 +547,12 @@ export const OutlinerBlock: React.FC = ({ }} rows={1} value={localContent} - readOnly={!canEdit || isLockedByOther} + readOnly={!canEdit} onFocus={handleFocus} onBlur={handleBlur} - onClick={handleClick} - onKeyUp={handleKeyUp} - onSelect={handleSelect} onChange={handleContentChange} onKeyDown={handleKeyDown} - className={`w-full resize-none bg-transparent text-sm text-main border-0 focus:outline-none focus:ring-0 leading-relaxed italic ${ - isLockedByOther ? 'pr-28 cursor-not-allowed opacity-90' : '' - }`} + className="w-full resize-none bg-transparent text-sm text-main border-0 focus:outline-none focus:ring-0 leading-relaxed italic" />
) : ( @@ -652,12 +564,9 @@ export const OutlinerBlock: React.FC = ({ }} rows={1} value={localContent} - readOnly={!canEdit || isLockedByOther} + readOnly={!canEdit} onFocus={handleFocus} onBlur={handleBlur} - onClick={handleClick} - onKeyUp={handleKeyUp} - onSelect={handleSelect} onChange={handleContentChange} onKeyDown={handleKeyDown} placeholder={ @@ -666,8 +575,6 @@ export const OutlinerBlock: React.FC = ({ : '' } className={`w-full resize-none overflow-hidden bg-transparent border-0 focus:outline-none focus:ring-0 leading-relaxed whitespace-pre-wrap break-words ${ - isLockedByOther ? 'pr-28 cursor-not-allowed opacity-90' : '' - } ${ block.type === 'h1' ? 'text-lg font-bold text-main tracking-tight' : block.type === 'h2' @@ -679,45 +586,6 @@ export const OutlinerBlock: React.FC = ({ : 'text-sm text-main' }`} /> - - {/* Remote Caret & Floating Name Tag Overlay */} - {primaryRemoteCursor && - primaryRemoteCursor.selectionStart !== undefined && - primaryRemoteCursor.selectionStart !== null && ( - - )}
)} diff --git a/frontend/src/features/workspace/OutlinerTree.tsx b/frontend/src/features/workspace/OutlinerTree.tsx index c8657d1..4686924 100644 --- a/frontend/src/features/workspace/OutlinerTree.tsx +++ b/frontend/src/features/workspace/OutlinerTree.tsx @@ -1,7 +1,6 @@ import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react'; import { Plus } from 'lucide-react'; -import { DocumentBlock, BlockLockInfo } from '../../lib/types'; -import { RemoteCursor } from '../../lib/useDocumentSync'; +import { DocumentBlock } from '../../lib/types'; import { OutlinerBlock } from './OutlinerBlock'; export interface OutlinerTreeProps { @@ -10,8 +9,6 @@ export interface OutlinerTreeProps { canEdit: boolean; levelOffset?: number; onZoomBlock?: (blockId: string) => void; - remoteCursors?: Record; - sendCursorMoved?: (blockId: string | null, selectionStart?: number, selectionEnd?: number) => void; onUpdateBlock: (updated: DocumentBlock, immediate?: boolean) => void; onUpdateBlocks?: (updated: DocumentBlock[]) => void; onCreateBlock: (newBlock: DocumentBlock, afterBlockId?: string) => void; @@ -20,10 +17,6 @@ export interface OutlinerTreeProps { onTagClick?: (tag: string) => void; onWikiLinkClick?: (pageTitle: string) => void; initialFocusedBlockId?: string | null; - activeBlockLocks?: Record; - onFocusBlock?: (blockId: string) => void; - onBlurBlock?: (blockId: string) => void; - currentUserId?: string; } export const OutlinerTree: React.FC = ({ @@ -32,8 +25,6 @@ export const OutlinerTree: React.FC = ({ canEdit, levelOffset = 0, onZoomBlock, - remoteCursors, - sendCursorMoved, onUpdateBlock, onUpdateBlocks, onCreateBlock, @@ -42,10 +33,6 @@ export const OutlinerTree: React.FC = ({ onTagClick, onWikiLinkClick, initialFocusedBlockId, - activeBlockLocks, - onFocusBlock, - onBlurBlock, - currentUserId, }) => { const [focusedBlockId, setFocusedBlockId] = useState(null); const [highlightedBlockId, setHighlightedBlockId] = useState( @@ -371,24 +358,9 @@ export const OutlinerTree: React.FC = ({ return () => window.removeEventListener('dragend', clearDrag); }, []); - const cursorsByBlock = useMemo(() => { - const map: Record = {}; - if (!remoteCursors) return map; - for (const c of Object.values(remoteCursors)) { - if (c.blockId) { - if (!map[c.blockId]) map[c.blockId] = []; - map[c.blockId].push(c); - } - } - return map; - }, [remoteCursors]); - return (
{visibleEntries.map(({ block, index, hasChildren, maxAllowedLevel }) => { - const lockInfo = activeBlockLocks?.[block.id]; - const isLockedByOther = Boolean(lockInfo && currentUserId && lockInfo.userId !== currentUserId); - return ( = ({ canEdit={canEdit} levelOffset={levelOffset} onZoomBlock={onZoomBlock} - activeRemoteCursors={cursorsByBlock[block.id] || []} - sendCursorMoved={sendCursorMoved} - onFocusBlock={(bId) => { - setFocusedBlockId(bId); - onFocusBlock?.(bId); - }} - onBlurBlock={(bId) => { - onBlurBlock?.(bId); - }} onUpdateBlock={onUpdateBlock} onCreateBlockBelow={handleCreateBlockBelow} onDeleteBlock={handleDeleteBlock} @@ -422,9 +385,6 @@ export const OutlinerTree: React.FC = ({ isDragTarget={dragOverIndex === index && draggedIndex !== index} isTargeted={highlightedBlockId === block.id} registerTextareaRef={registerTextareaRef} - isLockedByOther={isLockedByOther} - lockedByName={lockInfo?.userName} - lockedByColor={lockInfo?.userColor} /> ); })} diff --git a/frontend/src/features/workspace/WorkspaceView.tsx b/frontend/src/features/workspace/WorkspaceView.tsx index 7a0b127..4289465 100644 --- a/frontend/src/features/workspace/WorkspaceView.tsx +++ b/frontend/src/features/workspace/WorkspaceView.tsx @@ -48,10 +48,9 @@ import { getRealtimeTransport, RealtimeTransport, } from '../../lib/api'; -import { DocSyncStatus } from '../../lib/useDocumentSync'; import { UnifiedShareModal } from '../../components/UnifiedShareModal'; import { useMobileActions } from '../../context/MobileActionContext'; -import { DocumentEditor, DocSaveStatus, DocLiveState } from './DocumentEditor'; +import { DocumentEditor, DocSaveStatus } from './DocumentEditor'; interface WorkspaceViewProps { currentUser?: User | null; @@ -328,13 +327,8 @@ export const WorkspaceView: React.FC = ({ // Realtime connection status for top header indicator (identical to SharedListsView) const [realtimeConnected, setRealtimeConnected] = useState(() => isRealtimeConnected()); const [realtimeTransport, setRealtimeTransport] = useState(() => getRealtimeTransport()); - const [activeDocSyncStatus, setActiveDocSyncStatus] = useState('connected'); const [docSaveStatus, setDocSaveStatus] = useState('saved'); const [docSaveError, setDocSaveError] = useState(null); - const [docLiveState, setDocLiveState] = useState('idle'); - const [docLiveInitiator, setDocLiveInitiator] = useState(null); - const [showWsBlockedModal, setShowWsBlockedModal] = useState(false); - const liveToggleRef = useRef<(() => void) | null>(null); useEffect(() => { const unsub = onRealtimeStatus((connected, transport) => { @@ -1168,136 +1162,52 @@ export const WorkspaceView: React.FC = ({ {/* Right Header Section: Save Status & Live Collaboration Button */}
{subView === 'editor' && selectedDocId ? ( - <> - {/* Save Status Pill */} -
- {docSaveStatus === 'saved' && ( - <> - - Gespeichert - - )} - {docSaveStatus === 'saving' && ( - <> - - Speichert… - - )} - {docSaveStatus === 'unsaved' && ( - <> - - Ungespeichert - - )} - {docSaveStatus === 'error' && ( - <> - - Fehler - - )} -
- - {/* Interactive Live Collaboration Button (replaces passive radio icon) */} - {docLiveState === 'locked_by_other' ? ( -
-
- - Live: {docLiveInitiator || 'Gesperrt'} -
- {(selectedDocObj?.isOwner || selectedDocObj?.permission === 'owner') && ( - - )} -
- ) : docLiveState === 'blocked' ? ( -
- - -
- ) : docLiveState === 'connecting' ? ( -
- - -
- ) : docLiveState === 'active' ? ( - - ) : ( - + /* Save Status Pill */ +
+ {docSaveStatus === 'saved' && ( + <> + + Gespeichert + )} - + {docSaveStatus === 'saving' && ( + <> + + Speichert… + + )} + {docSaveStatus === 'unsaved' && ( + <> + + Ungespeichert + + )} + {docSaveStatus === 'error' && ( + <> + + Fehler + + )} +
) : ( /* Standalone Realtime Status Indicator for Todos / Hashtags */ (() => { @@ -1568,18 +1478,10 @@ export const WorkspaceView: React.FC = ({ onSelectTag={handleSelectTag} onWikiLinkClick={handleWikiLinkClick} onBackMobile={() => setMobileShowDetail(false)} - onSyncStatusChange={setActiveDocSyncStatus} onSaveStatusChange={(status, err) => { setDocSaveStatus(status); setDocSaveError(err || null); }} - onLiveStateChange={(state, initiator) => { - setDocLiveState(state); - setDocLiveInitiator(initiator || null); - }} - registerLiveToggle={(handler) => { - liveToggleRef.current = handler; - }} /> ) : subView === 'tags' ? ( /* ========================================================================= @@ -2535,14 +2437,15 @@ export const WorkspaceView: React.FC = ({ shares={unifiedShares} teams={teams} collaborators={collaborators} + allowRoleSelection={!shareModalDoc} onAddShare={async (targetType, targetId, permission) => { - const perm = permission === 'editor' ? 'write' : 'read'; + const perm = (!shareModalDoc && permission === 'editor') ? 'write' : 'read'; if (shareModalDoc) { await api.createDocShare( shareModalDoc.id, targetType === 'team' - ? { teamId: targetId, permission: perm } - : { userId: targetId, permission: perm } + ? { teamId: targetId, permission: 'read' } + : { userId: targetId, permission: 'read' } ); const updatedShares = await api.getDocShares(shareModalDoc.id); setActiveDocShares(updatedShares); @@ -2569,60 +2472,6 @@ export const WorkspaceView: React.FC = ({ }} /> )} - - {/* WebSocket Blocked Modal */} - {showWsBlockedModal && ( -
-
-
-
- -
-
-

- WebSockets in diesem Netzwerk blockiert -

-

- Deine Firewall oder dein Firmen-Proxy blockiert WebSocket-Verbindungen (ws:// / wss://). -

-
-
- -
-
- - Kein Datenverlust: Auto-Save per HTTPS ist aktiv! -
-

- Jede getippte Zeile und alle Änderungen werden zuverlässig über Standard-HTTPS-Anfragen im Hintergrund gespeichert. -

-

- Lediglich die gleichzeitige Tastendruck-Echtzeitanzeige mit anderen Nutzern im selben Moment ist ohne WebSockets eingeschränkt. -

-
- -
- - -
-
-
- )}
); diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 1aca9f9..a70d32a 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -5,10 +5,6 @@ import { Document, DocumentBlock, DocShare, - DocLockInfo, - BlockLockInfo, - LiveSessionInfo, - DocLocksResponse, KanbanColumn, ListItem, ListShare, @@ -641,46 +637,6 @@ export const api = { await request(`/api/v1/documents/${id}`, { method: 'DELETE' }); }, - async getDocLocks(docId: string): Promise { - const res = await request(`/api/v1/documents/${docId}/locks`); - return { - liveSession: res.liveSession || null, - blockLocks: ensureArray(res.blockLocks), - }; - }, - - async lockBlock(docId: string, blockId: string, userColor?: string, ttlSeconds: number = 30): Promise { - console.log(`[MindSift Lock] Lock anfordern für Block "${blockId}" in Notiz "${docId}"...`); - const res = await request(`/api/v1/documents/${docId}/blocks/${encodeURIComponent(blockId)}/lock`, { - method: 'POST', - body: JSON.stringify({ userColor, ttlSeconds, ttl: ttlSeconds }), - }); - console.log(`[MindSift Lock] Lock erfolgreich erhalten für "${blockId}":`, res?.lock || res); - return res?.lock || res; - }, - - async unlockBlock(docId: string, blockId: string): Promise<{ status: string }> { - console.log(`[MindSift Lock] Lock freigeben für Block "${blockId}" in Notiz "${docId}"...`); - const res = await request<{ status: string }>(`/api/v1/documents/${docId}/blocks/${encodeURIComponent(blockId)}/unlock`, { - method: 'POST', - }); - console.log(`[MindSift Lock] Lock freigegeben für "${blockId}"`); - return res; - }, - - async startLiveSession(docId: string): Promise { - const res = await request(`/api/v1/documents/${docId}/live/start`, { - method: 'POST', - }); - return res?.session || res; - }, - - async endLiveSession(docId: string): Promise<{ status: string }> { - return request<{ status: string }>(`/api/v1/documents/${docId}/live/end`, { - method: 'POST', - }); - }, - async moveDocument(id: string, folderPath: string): Promise { const d = await request(`/api/v1/documents/${id}/move`, { method: 'PUT', diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 7a5e479..4ec9d6b 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -200,35 +200,6 @@ export interface DocShare { createdAt: string; } -export interface DocLockInfo { - docId: string; - userId: string; - userName: string; - expiresAt: string; -} - -export interface BlockLockInfo { - docId: string; - blockId: string; - userId: string; - userName: string; - userColor?: string; - expiresAt: string; -} - -export interface LiveSessionInfo { - docId: string; - initiatorId: string; - initiatorName: string; - startedAt: string; - expiresAt: string; -} - -export interface DocLocksResponse { - liveSession?: LiveSessionInfo | null; - blockLocks: BlockLockInfo[]; -} - export interface Document { id: string; userId: string; diff --git a/frontend/src/lib/useDocumentSync.ts b/frontend/src/lib/useDocumentSync.ts deleted file mode 100644 index dd6de15..0000000 --- a/frontend/src/lib/useDocumentSync.ts +++ /dev/null @@ -1,507 +0,0 @@ -import { useEffect, useRef, useState, useCallback, useMemo } from 'react'; -import { DocumentBlock } from './types'; -import { getToken, api } from './api'; - -export type DocSyncStatus = 'idle' | 'connecting' | 'connected' | 'reconnecting' | 'offline' | 'blocked'; - -export interface RemoteCursor { - clientId: string; - userId: string; - userName: string; - userColor: string; - blockId: string | null; - selectionStart?: number; - selectionEnd?: number; - updatedAt: number; -} - -export const CURSOR_COLORS = [ - '#3b82f6', // blue - '#10b981', // emerald - '#f59e0b', // amber - '#ec4899', // pink - '#8b5cf6', // purple - '#06b6d4', // cyan - '#f97316', // orange - '#14b8a6', // teal - '#6366f1', // indigo -]; - -export function getUserColor(idOrName: string): string { - if (!idOrName) return CURSOR_COLORS[0]; - let hash = 0; - for (let i = 0; i < idOrName.length; i++) { - hash = (hash << 5) - hash + idOrName.charCodeAt(i); - hash |= 0; - } - return CURSOR_COLORS[Math.abs(hash) % CURSOR_COLORS.length]; -} - -export interface DocSyncEvent { - type: - | 'doc_state' - | 'block_created' - | 'block_updated' - | 'block_deleted' - | 'block_moved' - | 'title_updated' - | 'blocks_replaced' - | 'cursor_moved' - | 'client_left'; - docId?: string; - clientId?: string; - userId?: string; - userName?: string; - userColor?: string; - selectionStart?: number; - selectionEnd?: number; - activeCursors?: RemoteCursor[]; - title?: string; - block?: DocumentBlock; - blockId?: string; - afterBlockId?: string; - newIndex?: number; - content?: string; - level?: number; - checked?: boolean; - collapsed?: boolean; - blockType?: string; - blocks?: DocumentBlock[]; -} - -export function useDocumentSync( - docId: string | null, - onRemoteEvent: (evt: DocSyncEvent) => void, - canEdit: boolean = true, - currentUser?: { id: string; email?: string; displayName?: string } | null, - enabled: boolean = false -) { - const [status, setStatus] = useState(enabled ? 'connecting' : 'idle'); - const [remoteCursors, setRemoteCursors] = useState>({}); - const wsRef = useRef(null); - const clientIdRef = useRef( - `cli_${Math.random().toString(36).slice(2, 10)}_${Date.now().toString(36)}` - ); - const onRemoteEventRef = useRef(onRemoteEvent); - onRemoteEventRef.current = onRemoteEvent; - - const docIdRef = useRef(docId); - docIdRef.current = docId; - const canEditRef = useRef(canEdit); - canEditRef.current = canEdit; - const currentUserRef = useRef(currentUser); - currentUserRef.current = currentUser; - - const reconnectTimerRef = useRef(null); - const fallbackTimerRef = useRef(null); - - const localUserName = - currentUser?.displayName || currentUser?.email?.split('@')[0] || 'Anonym'; - const localUserColor = useMemo( - () => getUserColor(currentUser?.id || clientIdRef.current), - [currentUser?.id] - ); - const localUserNameRef = useRef(localUserName); - localUserNameRef.current = localUserName; - const localUserColorRef = useRef(localUserColor); - localUserColorRef.current = localUserColor; - - const pendingMessagesQueueRef = useRef([]); - - const lastCursorSendRef = useRef(0); - const pendingCursorTimerRef = useRef(null); - const pendingCursorPayloadRef = useRef<{ - blockId: string | null; - selectionStart?: number; - selectionEnd?: number; - } | null>(null); - - // Clean up stale remote cursors (> 30s) - useEffect(() => { - const interval = setInterval(() => { - const now = Date.now(); - setRemoteCursors((prev) => { - let hasStale = false; - const next: Record = {}; - for (const [key, cursor] of Object.entries(prev)) { - if (now - cursor.updatedAt < 30000) { - next[key] = cursor; - } else { - hasStale = true; - } - } - return hasStale ? next : prev; - }); - }, 5000); - return () => clearInterval(interval); - }, []); - - useEffect(() => { - if (!docId || !enabled) { - setStatus('idle'); - setRemoteCursors({}); - pendingMessagesQueueRef.current = []; - if (wsRef.current) { - try { - wsRef.current.close(); - } catch {} - wsRef.current = null; - } - return; - } - - pendingMessagesQueueRef.current = []; - let isDisposed = false; - let retryCount = 0; - const currentClientId = `cli_${Math.random().toString(36).slice(2, 10)}_${Date.now().toString(36)}`; - clientIdRef.current = currentClientId; - - const connect = () => { - if (isDisposed) return; - - if (typeof window !== 'undefined' && sessionStorage.getItem('mindsift_ws_blocked') === 'true') { - console.info('[DocSync] WebSockets im Firmennetzwerk blockiert (Cache). Live-Transport übersprungen.'); - setStatus('blocked'); - return; - } - - const token = getToken(); - if (!token) { - setStatus('offline'); - return; - } - - const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; - const wsUrl = `${protocol}//${window.location.host}/api/v1/ws/docs/${encodeURIComponent( - docId - )}?token=${encodeURIComponent(token)}&clientId=${encodeURIComponent( - currentClientId - )}&userName=${encodeURIComponent(localUserName)}&userColor=${encodeURIComponent( - localUserColor - )}`; - - setStatus(retryCount === 0 ? 'connecting' : 'reconnecting'); - - let ws: WebSocket; - try { - ws = new WebSocket(wsUrl); - } catch { - setStatus('blocked'); - return; - } - - wsRef.current = ws; - - const handshakeTimer = window.setTimeout(() => { - if (ws.readyState !== WebSocket.OPEN) { - console.warn('[DocSync] WebSocket handshake timed out (corporate firewall/proxy block detected).'); - setStatus('blocked'); - try { - ws.close(); - } catch {} - } - }, 4500); - - ws.onopen = () => { - window.clearTimeout(handshakeTimer); - if (isDisposed) { - ws.close(); - return; - } - retryCount = 0; - setStatus('connected'); - - // Flush any queued messages buffered while connecting or reconnecting - if (pendingMessagesQueueRef.current.length > 0) { - const queue = [...pendingMessagesQueueRef.current]; - pendingMessagesQueueRef.current = []; - for (const msg of queue) { - try { - ws.send(msg); - } catch (err) { - console.warn('[DocSync] Failed to flush queued message:', err); - } - } - } - }; - - ws.onmessage = (event) => { - if (isDisposed) return; - try { - const parsed: DocSyncEvent = JSON.parse(event.data); - if (!parsed || !parsed.type) return; - - // Echo protection: ignore events originating from our own clientId (except initial doc_state) - if (parsed.type !== 'doc_state' && parsed.clientId && parsed.clientId === currentClientId) { - return; - } - - if (parsed.type === 'doc_state') { - if (Array.isArray(parsed.activeCursors)) { - const initialCursors: Record = {}; - for (const c of parsed.activeCursors) { - if (c.clientId && c.clientId !== currentClientId) { - initialCursors[c.clientId] = { - ...c, - updatedAt: c.updatedAt || Date.now(), - }; - } - } - setRemoteCursors(initialCursors); - } - } else if (parsed.type === 'cursor_moved') { - if (parsed.clientId && parsed.clientId !== currentClientId) { - setRemoteCursors((prev) => ({ - ...prev, - [parsed.clientId!]: { - clientId: parsed.clientId!, - userId: parsed.userId || '', - userName: parsed.userName || 'Anonym', - userColor: parsed.userColor || '#3b82f6', - blockId: - parsed.blockId !== undefined - ? parsed.blockId - : prev[parsed.clientId!]?.blockId ?? null, - selectionStart: parsed.selectionStart, - selectionEnd: parsed.selectionEnd, - updatedAt: Date.now(), - }, - })); - } - } else if (parsed.type === 'client_left') { - if (parsed.clientId) { - setRemoteCursors((prev) => { - if (!prev[parsed.clientId!]) return prev; - const next = { ...prev }; - delete next[parsed.clientId!]; - return next; - }); - } - } - - onRemoteEventRef.current(parsed); - } catch { - // Ignore malformed frames - } - }; - - ws.onerror = () => { - // Handled in onclose - }; - - ws.onclose = () => { - window.clearTimeout(handshakeTimer); - if (isDisposed) return; - wsRef.current = null; - retryCount += 1; - if (retryCount >= 2) { - setStatus('blocked'); - } else { - setStatus('reconnecting'); - const delay = Math.min(1000 * Math.pow(1.6, retryCount), 4000); - reconnectTimerRef.current = window.setTimeout(connect, delay); - } - }; - }; - - connect(); - - return () => { - isDisposed = true; - if (reconnectTimerRef.current !== null) { - window.clearTimeout(reconnectTimerRef.current); - reconnectTimerRef.current = null; - } - if (fallbackTimerRef.current !== null) { - window.clearTimeout(fallbackTimerRef.current); - fallbackTimerRef.current = null; - } - if (pendingCursorTimerRef.current !== null) { - window.clearTimeout(pendingCursorTimerRef.current); - pendingCursorTimerRef.current = null; - } - if (wsRef.current) { - try { - wsRef.current.onopen = null; - wsRef.current.onmessage = null; - wsRef.current.onerror = null; - wsRef.current.onclose = null; - wsRef.current.close(); - } catch { - // ignore - } - wsRef.current = null; - } - }; - }, [docId, enabled, localUserName, localUserColor]); - - const executeSendCursor = useCallback( - (blockId: string | null, selectionStart?: number, selectionEnd?: number) => { - const ws = wsRef.current; - const activeDocId = docIdRef.current; - if (!activeDocId || !ws || ws.readyState !== WebSocket.OPEN) return; - const payload: DocSyncEvent = { - type: 'cursor_moved', - docId: activeDocId, - clientId: clientIdRef.current, - userId: currentUserRef.current?.id, - userName: localUserNameRef.current, - userColor: localUserColorRef.current, - blockId: blockId || undefined, - selectionStart, - selectionEnd, - }; - try { - ws.send(JSON.stringify(payload)); - } catch { - // Ephemeral cursor, ignore send failure - } - lastCursorSendRef.current = Date.now(); - }, - [] - ); - - const sendCursorMoved = useCallback( - (blockId: string | null, selectionStart?: number, selectionEnd?: number) => { - if (!docIdRef.current) return; - const now = Date.now(); - const elapsed = now - lastCursorSendRef.current; - - // When leaving focus / blur (blockId === null), send immediately - if (blockId === null) { - if (pendingCursorTimerRef.current !== null) { - window.clearTimeout(pendingCursorTimerRef.current); - pendingCursorTimerRef.current = null; - } - pendingCursorPayloadRef.current = null; - executeSendCursor(null); - return; - } - - // Max once per 100ms - if (elapsed >= 100) { - if (pendingCursorTimerRef.current !== null) { - window.clearTimeout(pendingCursorTimerRef.current); - pendingCursorTimerRef.current = null; - } - executeSendCursor(blockId, selectionStart, selectionEnd); - } else { - pendingCursorPayloadRef.current = { blockId, selectionStart, selectionEnd }; - if (pendingCursorTimerRef.current === null) { - pendingCursorTimerRef.current = window.setTimeout(() => { - pendingCursorTimerRef.current = null; - if (pendingCursorPayloadRef.current) { - executeSendCursor( - pendingCursorPayloadRef.current.blockId, - pendingCursorPayloadRef.current.selectionStart, - pendingCursorPayloadRef.current.selectionEnd - ); - pendingCursorPayloadRef.current = null; - } - }, 100 - elapsed); - } - } - }, - [executeSendCursor] - ); - - const sendRawEvent = useCallback( - (evt: DocSyncEvent) => { - const activeDocId = docIdRef.current; - const activeCanEdit = canEditRef.current; - if (!activeDocId || !activeCanEdit) return; - - const ws = wsRef.current; - if (!ws || ws.readyState !== WebSocket.OPEN) return; - - const payload: DocSyncEvent = { - ...evt, - docId: activeDocId, - clientId: clientIdRef.current, - }; - try { - ws.send(JSON.stringify(payload)); - } catch (err) { - console.warn('[DocSync] Failed to send WebSocket message:', err); - } - }, - [] - ); - - const sendBlockCreated = useCallback( - (block: DocumentBlock, afterBlockId?: string, _allBlocks?: DocumentBlock[]) => { - sendRawEvent({ - type: 'block_created', - block, - afterBlockId, - }); - }, - [sendRawEvent] - ); - - const sendBlockUpdated = useCallback( - (block: DocumentBlock) => { - sendRawEvent({ - type: 'block_updated', - blockId: block.id, - block, - }); - }, - [sendRawEvent] - ); - - const sendBlockDeleted = useCallback( - (blockId: string, _allBlocks?: DocumentBlock[]) => { - sendRawEvent({ - type: 'block_deleted', - blockId, - }); - }, - [sendRawEvent] - ); - - const sendBlockMoved = useCallback( - (blockId: string, newIndex: number, level?: number, _allBlocks?: DocumentBlock[]) => { - sendRawEvent({ - type: 'block_moved', - blockId, - newIndex, - level, - }); - }, - [sendRawEvent] - ); - - const sendTitleUpdated = useCallback( - (title: string) => { - sendRawEvent({ - type: 'title_updated', - title, - }); - }, - [sendRawEvent] - ); - - const sendBlocksReplaced = useCallback( - (blocks: DocumentBlock[]) => { - sendRawEvent({ - type: 'blocks_replaced', - blocks, - }); - }, - [sendRawEvent] - ); - - return { - status, - clientId: clientIdRef.current, - remoteCursors, - sendCursorMoved, - sendBlockCreated, - sendBlockUpdated, - sendBlockDeleted, - sendBlockMoved, - sendTitleUpdated, - sendBlocksReplaced, - }; -} diff --git a/internal/api/server.go b/internal/api/server.go index f2ac2f5..1bd37c7 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -31,9 +31,6 @@ type Server struct { } func NewServer(cfg *config.Config, store *storage.Store, wsHub *ws.Hub, staticFS fs.FS) *Server { - if wsHub != nil && store != nil { - wsHub.SetDocStore(store) - } srv := &Server{ cfg: cfg, store: store, @@ -70,7 +67,6 @@ func (s *Server) Router() http.Handler { r.Get("/api/v1/ws", 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/docs/{id}", s.handleDocWebSocket) r.Get("/api/v1/events", s.handleSSE) r.Post("/api/v1/events/rooms", s.handleSSERooms) @@ -145,13 +141,6 @@ func (s *Server) Router() http.Handler { r.Post("/api/v1/documents/{id}/shares", s.handleCreateDocShare) r.Delete("/api/v1/documents/{id}/shares/{userId}", s.handleDeleteDocShare) - // Document Locks & Live-Collaboration Sessions - r.Get("/api/v1/documents/{id}/locks", s.handleGetDocLocks) - r.Post("/api/v1/documents/{id}/blocks/{blockId}/lock", s.handleLockBlock) - r.Post("/api/v1/documents/{id}/blocks/{blockId}/unlock", s.handleUnlockBlock) - r.Post("/api/v1/documents/{id}/live/start", s.handleStartLiveSession) - r.Post("/api/v1/documents/{id}/live/end", s.handleEndLiveSession) - // Shared Lists & Realtime Collaboration r.Get("/api/v1/lists", s.handleGetAllLists) r.Post("/api/v1/lists", s.handleCreateList) @@ -918,18 +907,6 @@ func (s *Server) handleGetDocument(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusNotFound, "Dokument nicht gefunden") return } - if s.wsHub != nil { - if liveTitle, liveBlocks, ok := s.wsHub.GetActiveDocState(docID); ok { - if liveTitle != "" { - doc.Title = liveTitle - } - if len(liveBlocks) > 0 { - doc.Blocks = liveBlocks - doc.MarkdownCache = domain.CompileBlocksToMarkdown(liveBlocks) - doc.Content = doc.MarkdownCache - } - } - } writeJSON(w, http.StatusOK, doc) } @@ -937,19 +914,6 @@ func (s *Server) handleUpdateDocument(w http.ResponseWriter, r *http.Request) { userID := auth.GetUserID(r.Context()) docID := chi.URLParam(r, "id") - if s.wsHub != nil { - if ls := s.wsHub.GetLiveSession(docID); ls != nil && ls.InitiatorID != userID { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusLocked) - _ = json.NewEncoder(w).Encode(map[string]interface{}{ - "error": "Dokument wird aktuell in einer Live-Sitzung bearbeitet", - "lockedBy": ls.InitiatorName, - "isLocked": true, - }) - return - } - } - var req struct { Title *string `json:"title"` Icon *string `json:"icon"` @@ -968,9 +932,6 @@ func (s *Server) handleUpdateDocument(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, err.Error()) return } - if s.wsHub != nil && (req.Title != nil || req.Blocks != nil) { - s.wsHub.SyncActiveDocRoom(docID, doc.Title, doc.Blocks) - } if req.Title != nil || req.Icon != nil || req.FolderPath != nil || req.Blocks != nil || req.Tags != nil { s.broadcastDocToAuthorizedUsers(docID, "DOC_UPDATED") } @@ -981,19 +942,6 @@ func (s *Server) handlePatchDocumentBlocks(w http.ResponseWriter, r *http.Reques userID := auth.GetUserID(r.Context()) docID := chi.URLParam(r, "id") - if s.wsHub != nil { - if ls := s.wsHub.GetLiveSession(docID); ls != nil && ls.InitiatorID != userID { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusLocked) - _ = json.NewEncoder(w).Encode(map[string]interface{}{ - "error": "Dokument wird aktuell in einer Live-Sitzung bearbeitet", - "lockedBy": ls.InitiatorName, - "isLocked": true, - }) - return - } - } - var req domain.DocBlocksPatchRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "Ungültiges JSON") @@ -1005,9 +953,6 @@ func (s *Server) handlePatchDocumentBlocks(w http.ResponseWriter, r *http.Reques writeError(w, http.StatusBadRequest, err.Error()) return } - if s.wsHub != nil { - s.wsHub.SyncActiveDocRoom(docID, doc.Title, doc.Blocks) - } patchEvent := domain.DocBlocksPatchEvent{ DocID: docID, @@ -1032,9 +977,6 @@ func (s *Server) handleDeleteDocument(w http.ResponseWriter, r *http.Request) { docID := chi.URLParam(r, "id") authorizedUsers, _ := s.store.GetAuthorizedDocUsers(docID) - if s.wsHub != nil { - s.wsHub.CloseDocRoom(docID) - } if err := s.store.DeleteDocument(userID, docID); err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return @@ -1090,7 +1032,6 @@ func (s *Server) handleDeleteFolder(w http.ResponseWriter, r *http.Request) { } for _, docID := range deletedDocIDs { - s.wsHub.ReleaseDocLock(docID, userID, true) s.wsHub.BroadcastDocEvent(docID, "DOC_DELETED", map[string]string{"id": docID}) } s.wsHub.BroadcastDocEvent("", "WORKSPACE_FOLDERS_UPDATED", map[string]string{"path": folderPath}) @@ -1421,202 +1362,6 @@ func (s *Server) handleGetCollaborators(w http.ResponseWriter, r *http.Request) writeJSON(w, http.StatusOK, users) } -// --- DOCUMENT LOCKS & LIVE COLLABORATION SESSIONS --- - -func (s *Server) handleGetDocLocks(w http.ResponseWriter, r *http.Request) { - docID := chi.URLParam(r, "id") - var blockLocks []domain.BlockLockInfo - var liveSession *domain.LiveSessionInfo - if s.wsHub != nil { - blockLocks = s.wsHub.GetDocBlockLocks(docID) - liveSession = s.wsHub.GetLiveSession(docID) - } - if blockLocks == nil { - blockLocks = []domain.BlockLockInfo{} - } - writeJSON(w, http.StatusOK, domain.DocLocksResponse{ - DocID: docID, - BlockLocks: blockLocks, - LiveSession: liveSession, - }) -} - -func (s *Server) handleLockBlock(w http.ResponseWriter, r *http.Request) { - userID := auth.GetUserID(r.Context()) - docID := chi.URLParam(r, "id") - blockID := chi.URLParam(r, "blockId") - - // If live session is active by someone else, reject - if s.wsHub != nil { - if ls := s.wsHub.GetLiveSession(docID); ls != nil && ls.InitiatorID != userID { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusLocked) - _ = json.NewEncoder(w).Encode(map[string]interface{}{ - "error": "Dokument wird aktuell in einer Live-Sitzung bearbeitet", - "lockedBy": ls.InitiatorName, - "isLocked": true, - }) - return - } - } - - user, _ := s.store.GetUserByID(userID) - userName := "Anonym" - userColor := "#3b82f6" - if user != nil { - if user.DisplayName != "" { - userName = user.DisplayName - } else { - userName = user.Username - } - } - - var req struct { - UserColor string `json:"userColor"` - TTLSeconds int `json:"ttlSeconds"` - } - _ = json.NewDecoder(r.Body).Decode(&req) - if req.UserColor != "" { - userColor = req.UserColor - } - ttl := 30 * time.Second - if req.TTLSeconds > 5 && req.TTLSeconds <= 120 { - ttl = time.Duration(req.TTLSeconds) * time.Second - } - - if s.wsHub != nil { - lockInfo, ok, existing := s.wsHub.AcquireBlockLock(docID, blockID, userID, userName, userColor, ttl) - if !ok && existing != nil { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusConflict) - _ = json.NewEncoder(w).Encode(map[string]interface{}{ - "error": "Block ist von anderem Benutzer gesperrt", - "lockedBy": existing.UserName, - "lock": existing, - }) - return - } - s.wsHub.BroadcastRoom("doc:"+docID, "DOC_BLOCK_LOCKED", lockInfo, nil) - if users, err := s.store.GetAuthorizedDocUsers(docID); err == nil { - for _, uid := range users { - s.wsHub.BroadcastToUser(uid, "DOC_BLOCK_LOCKED", lockInfo) - } - } - writeJSON(w, http.StatusOK, map[string]interface{}{ - "success": true, - "lock": lockInfo, - "docId": lockInfo.DocID, - "blockId": lockInfo.BlockID, - "userId": lockInfo.UserID, - "userName": lockInfo.UserName, - "userColor": lockInfo.UserColor, - "expiresAt": lockInfo.ExpiresAt, - }) - return - } - writeJSON(w, http.StatusOK, map[string]bool{"success": true}) -} - -func (s *Server) handleUnlockBlock(w http.ResponseWriter, r *http.Request) { - userID := auth.GetUserID(r.Context()) - docID := chi.URLParam(r, "id") - blockID := chi.URLParam(r, "blockId") - - if s.wsHub != nil { - released := s.wsHub.ReleaseBlockLock(docID, blockID, userID) - if released { - evt := map[string]string{ - "docId": docID, - "blockId": blockID, - "userId": userID, - } - s.wsHub.BroadcastRoom("doc:"+docID, "DOC_BLOCK_UNLOCKED", evt, nil) - if users, err := s.store.GetAuthorizedDocUsers(docID); err == nil { - for _, uid := range users { - s.wsHub.BroadcastToUser(uid, "DOC_BLOCK_UNLOCKED", evt) - } - } - } - } - writeJSON(w, http.StatusOK, map[string]bool{"success": true}) -} - -func (s *Server) handleStartLiveSession(w http.ResponseWriter, r *http.Request) { - userID := auth.GetUserID(r.Context()) - docID := chi.URLParam(r, "id") - - user, _ := s.store.GetUserByID(userID) - userName := "Anonym" - if user != nil { - if user.DisplayName != "" { - userName = user.DisplayName - } else { - userName = user.Username - } - } - - ttl := 60 * time.Second // 1 min lease with heartbeat while active - if s.wsHub != nil { - info, ok, existing := s.wsHub.StartLiveSession(docID, userID, userName, ttl) - if !ok && existing != nil { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusConflict) - _ = json.NewEncoder(w).Encode(map[string]interface{}{ - "error": "Live-Sitzung bereits von anderem Benutzer aktiv", - "session": existing, - "initiator": existing.InitiatorName, - }) - return - } - s.wsHub.BroadcastRoom("doc:"+docID, "DOC_LIVE_STARTED", info, nil) - if users, err := s.store.GetAuthorizedDocUsers(docID); err == nil { - for _, uid := range users { - s.wsHub.BroadcastToUser(uid, "DOC_LIVE_STARTED", info) - } - } - writeJSON(w, http.StatusOK, map[string]interface{}{ - "success": true, - "session": info, - "docId": info.DocID, - "initiatorId": info.InitiatorID, - "initiatorName": info.InitiatorName, - "startedAt": info.StartedAt, - "expiresAt": info.ExpiresAt, - }) - return - } - writeJSON(w, http.StatusOK, map[string]bool{"success": true}) -} - -func (s *Server) handleEndLiveSession(w http.ResponseWriter, r *http.Request) { - userID := auth.GetUserID(r.Context()) - docID := chi.URLParam(r, "id") - - doc, _ := s.store.GetDocumentByID(userID, docID) - isOwner := doc != nil && (doc.IsOwner || doc.Permission == "owner" || doc.UserID == userID) - - if s.wsHub != nil { - if isOwner { - s.wsHub.ForceEndLiveSession(docID) - } else { - s.wsHub.EndLiveSession(docID, userID) - } - evt := map[string]string{ - "docId": docID, - "userId": userID, - } - s.wsHub.BroadcastRoom("doc:"+docID, "DOC_LIVE_ENDED", evt, nil) - if users, err := s.store.GetAuthorizedDocUsers(docID); err == nil { - for _, uid := range users { - s.wsHub.BroadcastToUser(uid, "DOC_LIVE_ENDED", evt) - } - } - } - writeJSON(w, http.StatusOK, map[string]bool{"success": true}) -} - -// --- DOCUMENT REALTIME WEBSOCKET ROOM --- - func (s *Server) authenticateUserFromReq(r *http.Request) string { token := r.URL.Query().Get("token") if token == "" { @@ -1643,46 +1388,6 @@ func (s *Server) authenticateUserFromReq(r *http.Request) string { return "" } -// GET /api/v1/ws/docs/{id} -func (s *Server) handleDocWebSocket(w http.ResponseWriter, r *http.Request) { - if r.Header.Get("Sec-Websocket-Key") != "" { - if !strings.Contains(strings.ToLower(r.Header.Get("Connection")), "upgrade") { - r.Header.Set("Connection", "Upgrade") - } - if !strings.Contains(strings.ToLower(r.Header.Get("Upgrade")), "websocket") { - r.Header.Set("Upgrade", "websocket") - } - } - - docID := chi.URLParam(r, "id") - if docID == "" { - writeError(w, http.StatusBadRequest, "docId erforderlich") - return - } - - userID := s.authenticateUserFromReq(r) - if userID == "" { - userID = auth.GetUserID(r.Context()) - } - if userID == "" { - userID = "anonymous" - } - - canWrite := false - if userID != "anonymous" { - doc, err := s.store.GetDocumentByID(userID, docID) - if err != nil { - writeError(w, http.StatusForbidden, "Kein Zugriff auf dieses Dokument") - return - } - canWrite = doc.IsOwner || doc.Permission == "owner" || doc.Permission == "write" - } - - clientID := r.URL.Query().Get("clientId") - room := s.wsHub.GetOrCreateDocRoom(docID) - room.ServeDocWS(w, r, userID, clientID, canWrite) -} - // --- REALTIME LISTS & SHARING --- func (s *Server) handleGetAllLists(w http.ResponseWriter, r *http.Request) { diff --git a/internal/api/server_test.go b/internal/api/server_test.go index 5bb19cb..1b84a14 100644 --- a/internal/api/server_test.go +++ b/internal/api/server_test.go @@ -841,93 +841,6 @@ func TestWebhookTriageBadgeAndIngestion(t *testing.T) { } } -func TestDocRoomLiveStateAndDebouncedFlush(t *testing.T) { - tmpDir := t.TempDir() - dbPath := filepath.Join(tmpDir, "docroom_test.db") - - store, err := storage.NewStore(dbPath) - if err != nil { - t.Fatalf("NewStore failed: %v", err) - } - defer store.Close() - - cfg := &config.Config{ - JWTSecret: "test-jwt-secret-key", - AllowRegistration: true, - } - hub := ws.NewHub() - srv := NewServer(cfg, store, hub, nil) - router := srv.Router() - - user, err := store.CreateUser("outliner_ws@example.com", "hash", "WS Outliner User", "member") - if err != nil { - t.Fatalf("CreateUser failed: %v", err) - } - token, _ := auth.GenerateToken(cfg.JWTSecret, user.ID, user.Email, user.Role, user.TokenVersion) - - doc, err := store.CreateDocument(user.ID, "", "Initial Title", "📄", "", nil, nil, false) - if err != nil { - t.Fatalf("CreateDocument failed: %v", err) - } - - // 1. Mutate in-memory DocRoom before 1.5s flush fires - room := hub.GetOrCreateDocRoom(doc.ID) - newContent := "Live RAM Block #ramtag" - newLevel := 1 - room.HandleEvent(ws.DocSyncEvent{ - Type: "title_updated", - ClientID: "cli_1", - Title: "RAM Updated Title", - }, nil) - room.HandleEvent(ws.DocSyncEvent{ - Type: "block_created", - ClientID: "cli_1", - AfterBlockID: doc.Blocks[0].ID, - Block: &domain.DocumentBlock{ - ID: "blk_ram_2", - Type: "text", - Content: newContent, - Level: newLevel, - }, - }, nil) - - // 2. Verify GET /api/v1/documents/{id} immediately returns the un-flushed in-memory state - req := httptest.NewRequest(http.MethodGet, "/api/v1/documents/"+doc.ID, nil) - req.Header.Set("Authorization", "Bearer "+token) - rec := httptest.NewRecorder() - router.ServeHTTP(rec, req) - - if rec.Code != http.StatusOK { - t.Fatalf("expected 200 OK from GET document, got %d: %s", rec.Code, rec.Body.String()) - } - var liveDoc domain.Document - _ = json.Unmarshal(rec.Body.Bytes(), &liveDoc) - if liveDoc.Title != "RAM Updated Title" { - t.Fatalf("expected live RAM title 'RAM Updated Title', got '%s'", liveDoc.Title) - } - if len(liveDoc.Blocks) != 2 || liveDoc.Blocks[1].Content != newContent { - t.Fatalf("expected 2 live blocks with RAM content, got %+v", liveDoc.Blocks) - } - - // 3. Flush room to SQLite and verify persistence + tag extraction + markdown_cache - room.Flush() - - persistedDoc, err := store.GetDocumentByID(user.ID, doc.ID) - if err != nil { - t.Fatalf("GetDocumentByID failed: %v", err) - } - if persistedDoc.Title != "RAM Updated Title" { - t.Fatalf("expected persisted title 'RAM Updated Title', got '%s'", persistedDoc.Title) - } - if !strings.Contains(persistedDoc.MarkdownCache, " - Live RAM Block #ramtag") { - t.Fatalf("expected compiled markdown_cache to contain indented bullet, got:\n%s", persistedDoc.MarkdownCache) - } - tags, _ := store.GetWorkspaceTags(user.ID) - if len(tags) != 1 || tags[0].Tag != "ramtag" { - t.Fatalf("expected extracted #ramtag in block_tags, got %+v", tags) - } -} - func TestSSEGlobalEndpoint(t *testing.T) { dbPath := filepath.Join(t.TempDir(), "test_sse.db") store, err := storage.NewStore(dbPath) @@ -1295,94 +1208,54 @@ func TestTokenBlacklistingAndTokenVersionRevocation(t *testing.T) { } } -func TestBlockLockingAndLiveSessionGuards(t *testing.T) { +func TestDocumentSharingStrictlyReadOnly(t *testing.T) { srv, store, _ := setupTestServer(t) router := srv.Router() - userA, _ := store.CreateUser("user_a_lock@example.com", "hash", "User A", "member") + userA, _ := store.CreateUser("user_a_ro@example.com", "hash", "User A", "member") tokenA, _ := auth.GenerateToken(srv.cfg.JWTSecret, userA.ID, userA.Email, userA.DisplayName, userA.TokenVersion) - userB, _ := store.CreateUser("user_b_lock@example.com", "hash", "User B", "member") + userB, _ := store.CreateUser("user_b_ro@example.com", "hash", "User B", "member") tokenB, _ := auth.GenerateToken(srv.cfg.JWTSecret, userB.ID, userB.Email, userB.DisplayName, userB.TokenVersion) - // Create document owned by User A, shared with User B + // Create document owned by User A blocks := []domain.DocumentBlock{ {ID: "b1", Type: "bullet", Content: "Row 1", Level: 0}, {ID: "b2", Type: "bullet", Content: "Row 2", Level: 0}, } - doc, err := store.CreateDocument(userA.ID, "", "Lock Test Doc", "📝", "", blocks, nil, false) + doc, err := store.CreateDocument(userA.ID, "", "Read Only Test Doc", "📝", "", blocks, nil, false) if err != nil { t.Fatalf("CreateDocument failed: %v", err) } - _, _ = store.CreateDocShare(userA.ID, doc.ID, userB.ID, "write") - // 1. Initial locks should be empty - req := httptest.NewRequest(http.MethodGet, "/api/v1/documents/"+doc.ID+"/locks", nil) - req.Header.Set("Authorization", "Bearer "+tokenA) + // Share with User B (forced read-only) + _, err = store.CreateDocShare(userA.ID, doc.ID, userB.ID, "read") + if err != nil { + t.Fatalf("CreateDocShare failed: %v", err) + } + + // 1. User B can read document + req := httptest.NewRequest(http.MethodGet, "/api/v1/documents/"+doc.ID, nil) + req.Header.Set("Authorization", "Bearer "+tokenB) rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusOK { - t.Fatalf("get locks failed: %d", rec.Code) - } - var locksResp domain.DocLocksResponse - _ = json.Unmarshal(rec.Body.Bytes(), &locksResp) - if len(locksResp.BlockLocks) != 0 || locksResp.LiveSession != nil { - t.Fatalf("expected 0 locks initially, got %v", locksResp) + t.Fatalf("User B should be able to read doc, got status: %d", rec.Code) } - // 2. User A locks block b1 - req = httptest.NewRequest(http.MethodPost, "/api/v1/documents/"+doc.ID+"/blocks/b1/lock", bytes.NewReader([]byte(`{"ttlSeconds": 30}`))) - req.Header.Set("Authorization", "Bearer "+tokenA) - req.Header.Set("Content-Type", "application/json") - rec = httptest.NewRecorder() - router.ServeHTTP(rec, req) - if rec.Code != http.StatusOK { - t.Fatalf("user A lock b1 failed: %d, body: %s", rec.Code, rec.Body.String()) - } - - // 3. User B tries to lock b1 -> must receive 409 Conflict - req = httptest.NewRequest(http.MethodPost, "/api/v1/documents/"+doc.ID+"/blocks/b1/lock", bytes.NewReader([]byte(`{"ttlSeconds": 30}`))) + // 2. User B tries to update document via PUT -> must fail (not owner) + newTitle := "Hacked Title" + updateBody, _ := json.Marshal(map[string]interface{}{"title": newTitle}) + req = httptest.NewRequest(http.MethodPut, "/api/v1/documents/"+doc.ID, bytes.NewReader(updateBody)) req.Header.Set("Authorization", "Bearer "+tokenB) req.Header.Set("Content-Type", "application/json") rec = httptest.NewRecorder() router.ServeHTTP(rec, req) - if rec.Code != http.StatusConflict { - t.Fatalf("expected 409 Conflict for user B on b1, got %d", rec.Code) + if rec.Code == http.StatusOK { + t.Fatalf("expected error for User B updating document, got 200 OK") } - // 4. User A unlocks b1 - req = httptest.NewRequest(http.MethodPost, "/api/v1/documents/"+doc.ID+"/blocks/b1/unlock", nil) - req.Header.Set("Authorization", "Bearer "+tokenA) - rec = httptest.NewRecorder() - router.ServeHTTP(rec, req) - if rec.Code != http.StatusOK { - t.Fatalf("user A unlock b1 failed: %d", rec.Code) - } - - // 5. User B can now lock b1 - req = httptest.NewRequest(http.MethodPost, "/api/v1/documents/"+doc.ID+"/blocks/b1/lock", bytes.NewReader([]byte(`{"ttlSeconds": 30}`))) - req.Header.Set("Authorization", "Bearer "+tokenB) - rec = httptest.NewRecorder() - router.ServeHTTP(rec, req) - if rec.Code != http.StatusOK { - t.Fatalf("user B lock b1 should succeed now, got %d", rec.Code) - } - // User B unlocks b1 - req = httptest.NewRequest(http.MethodPost, "/api/v1/documents/"+doc.ID+"/blocks/b1/unlock", nil) - req.Header.Set("Authorization", "Bearer "+tokenB) - rec = httptest.NewRecorder() - router.ServeHTTP(rec, req) - - // 6. User A starts a Live-Session on doc - req = httptest.NewRequest(http.MethodPost, "/api/v1/documents/"+doc.ID+"/live/start", nil) - req.Header.Set("Authorization", "Bearer "+tokenA) - rec = httptest.NewRecorder() - router.ServeHTTP(rec, req) - if rec.Code != http.StatusOK { - t.Fatalf("user A start live session failed: %d, body: %s", rec.Code, rec.Body.String()) - } - - // 7. User B tries to patch document blocks while User A's live session is active -> must receive 423 Locked! + // 3. User B tries to patch document blocks -> must fail (not owner) patchBody, _ := json.Marshal(domain.DocBlocksPatchRequest{ Upserted: []domain.DocumentBlock{{ID: "b2", Content: "Hacked by User B"}}, }) @@ -1391,27 +1264,20 @@ func TestBlockLockingAndLiveSessionGuards(t *testing.T) { req.Header.Set("Content-Type", "application/json") rec = httptest.NewRecorder() router.ServeHTTP(rec, req) - if rec.Code != http.StatusLocked { - t.Fatalf("expected 423 Locked for User B while live session is active, got %d, body: %s", rec.Code, rec.Body.String()) + if rec.Code == http.StatusOK { + t.Fatalf("expected error for User B patching document blocks, got 200 OK") } - // 8. User A ends the Live-Session - req = httptest.NewRequest(http.MethodPost, "/api/v1/documents/"+doc.ID+"/live/end", nil) + // 4. User A (owner) can successfully update document + ownerTitle := "Owner Updated Title" + ownerBody, _ := json.Marshal(map[string]interface{}{"title": ownerTitle}) + req = httptest.NewRequest(http.MethodPut, "/api/v1/documents/"+doc.ID, bytes.NewReader(ownerBody)) req.Header.Set("Authorization", "Bearer "+tokenA) - rec = httptest.NewRecorder() - router.ServeHTTP(rec, req) - if rec.Code != http.StatusOK { - t.Fatalf("user A end live session failed: %d", rec.Code) - } - - // 9. User B can now edit again - req = httptest.NewRequest(http.MethodPatch, "/api/v1/documents/"+doc.ID+"/blocks", bytes.NewReader(patchBody)) - req.Header.Set("Authorization", "Bearer "+tokenB) req.Header.Set("Content-Type", "application/json") rec = httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusOK { - t.Fatalf("User B edit should succeed after live session ended, got %d, body: %s", rec.Code, rec.Body.String()) + t.Fatalf("owner should be able to update document, got %d", rec.Code) } } diff --git a/internal/domain/models.go b/internal/domain/models.go index 46c1934..410b229 100644 --- a/internal/domain/models.go +++ b/internal/domain/models.go @@ -337,36 +337,6 @@ type DocShare struct { CreatedAt string `json:"createdAt"` } -type DocLockInfo struct { - DocID string `json:"docId"` - UserID string `json:"userId"` - UserName string `json:"userName"` - ExpiresAt string `json:"expiresAt"` -} - -type BlockLockInfo struct { - DocID string `json:"docId"` - BlockID string `json:"blockId"` - UserID string `json:"userId"` - UserName string `json:"userName"` - UserColor string `json:"userColor,omitempty"` - ExpiresAt string `json:"expiresAt"` -} - -type LiveSessionInfo struct { - DocID string `json:"docId"` - InitiatorID string `json:"initiatorId"` - InitiatorName string `json:"initiatorName"` - StartedAt string `json:"startedAt"` - ExpiresAt string `json:"expiresAt"` -} - -type DocLocksResponse struct { - DocID string `json:"docId"` - BlockLocks []BlockLockInfo `json:"blockLocks"` - LiveSession *LiveSessionInfo `json:"liveSession,omitempty"` -} - type Document struct { ID string `json:"id"` UserID string `json:"userId"` diff --git a/internal/storage/sqlite.go b/internal/storage/sqlite.go index 0d41584..b453bdc 100644 --- a/internal/storage/sqlite.go +++ b/internal/storage/sqlite.go @@ -414,6 +414,10 @@ func (s *Store) migrate() error { // Backfill username for existing accounts where username is empty _, _ = s.db.Exec(`UPDATE users SET username = LOWER(SUBSTR(email, 1, INSTR(email, '@') - 1)) WHERE (username IS NULL OR username = '') AND INSTR(email, '@') > 0`) + // Ensure all existing document shares are strictly read-only + _, _ = s.db.Exec(`UPDATE doc_shares SET permission = 'read' WHERE permission != 'read'`) + _, _ = s.db.Exec(`UPDATE doc_team_shares SET permission = 'read' WHERE permission != 'read'`) + // Default system settings (public registration disabled by default; invite or admin creation required) _, _ = s.db.Exec(`INSERT OR IGNORE INTO system_settings (key, value) VALUES ('sharing_mode', 'teams_only'), @@ -1526,7 +1530,7 @@ func (s *Store) PatchDocumentBlocks(userID, docID string, req domain.DocBlocksPa if err != nil { return nil, nil, err } - if !doc.IsOwner && doc.Permission != "write" { + if !doc.IsOwner { return nil, nil, fmt.Errorf("keine Schreibberechtigung für dieses Dokument (nur Lesezugriff)") } @@ -2274,7 +2278,7 @@ func (s *Store) UpdateDocument(userID, docID string, title, icon, folderPath *st isOnlyPinChange := isPinned != nil && title == nil && icon == nil && folderPath == nil && blocks == nil && tags == nil isOnlyFolderMove := folderPath != nil && title == nil && icon == nil && blocks == nil && tags == nil && isPinned == nil - if !doc.IsOwner && doc.Permission != "write" && !isOnlyPinChange && !isOnlyFolderMove { + if !doc.IsOwner && !isOnlyPinChange && !isOnlyFolderMove { return nil, fmt.Errorf("keine Schreibberechtigung für dieses Dokument (nur Lesezugriff)") } @@ -2908,7 +2912,7 @@ func (s *Store) MoveDocument(userID, docID, targetFolderPath string) (*domain.Do // Target is NOT inside a shared folder (personal / standalone) if doc.SharedFolderID != "" { // Moving OUT of a shared folder into a personal folder - if !doc.IsOwner && doc.Permission != "write" { + if !doc.IsOwner { return nil, fmt.Errorf("keine Berechtigung, die Notiz aus dem geteilten Ordner zu verschieben") } _, err = s.db.Exec(`UPDATE documents SET shared_folder_id = '', sub_folder_path = '', folder_path = ?, updated_at = ? WHERE id = ?`, @@ -3564,8 +3568,8 @@ func (s *Store) CreateDocTeamShare(currentUserID, docID, teamID, permission stri if err != nil { return nil, err } - if !doc.IsOwner && doc.Permission != "write" { - return nil, fmt.Errorf("nur der Eigentümer oder Bearbeiter kann Freigaben verwalten") + if !doc.IsOwner { + return nil, fmt.Errorf("nur der Eigentümer kann Freigaben verwalten") } cleanTeamID := strings.TrimSpace(teamID) @@ -3575,9 +3579,8 @@ func (s *Store) CreateDocTeamShare(currentUserID, docID, teamID, permission stri return nil, fmt.Errorf("team nicht gefunden") } - if permission != "read" && permission != "write" { - permission = "read" - } + // Document sharing is strictly read-only + permission = "read" now := domain.NowISO() shareID := domain.NewID("share") @@ -3609,20 +3612,19 @@ func (s *Store) CreateDocShare(currentUserID, docID, targetUserIdentifier, permi if err != nil { return nil, err } - if !doc.IsOwner && doc.Permission != "write" { - return nil, fmt.Errorf("nur der Eigentümer oder Bearbeiter kann Freigaben verwalten") + if !doc.IsOwner { + return nil, fmt.Errorf("nur der Eigentümer kann Freigaben verwalten") } cleanTarget := strings.TrimSpace(targetUserIdentifier) // If identifier looks like a team or is prefixed with team_, route to CreateDocTeamShare if strings.HasPrefix(cleanTarget, "team_") { - return s.CreateDocTeamShare(currentUserID, docID, cleanTarget, permission) + return s.CreateDocTeamShare(currentUserID, docID, cleanTarget, "read") } - if permission != "read" && permission != "write" { - permission = "read" - } + // Document sharing is strictly read-only + permission = "read" // Resolve target user by ID, username, or Email var targetUser *domain.User diff --git a/internal/storage/sqlite_test.go b/internal/storage/sqlite_test.go index f5d2b63..22628d9 100644 --- a/internal/storage/sqlite_test.go +++ b/internal/storage/sqlite_test.go @@ -173,16 +173,10 @@ func TestStoreLifecycle(t *testing.T) { t.Fatalf("expected UpdateDocument to fail for read-only user, but succeeded") } - // Upgrade to write permission - _, err = store.CreateDocShare(user.ID, newDoc.ID, user2.ID, "write") - if err != nil { - t.Fatalf("Upgrade CreateDocShare failed: %v", err) - } - - // user2 edits writeable doc -> must succeed + // user2 is a read-only collaborator -> editing must fail _, err = store.UpdateDocument(user2.ID, newDoc.ID, &updatedTitle, nil, nil, nil, nil, nil) - if err != nil { - t.Fatalf("UpdateDocument failed for write collaborator: %v", err) + if err == nil { + t.Fatalf("expected UpdateDocument to fail for read-only collaborator, but succeeded") } // Remove share @@ -551,8 +545,7 @@ func TestDatabaseFirstWorkspaceArchitecture(t *testing.T) { t.Fatalf("MoveDocument by userB failed: %v", err) } - // User B edits the document via PatchDocumentBlocks (modifying b2 and adding b3) - // Simultaneously, User A edits b1 and adds b4 using a slightly stale order + // User B is a read-only collaborator -> PatchDocumentBlocks must fail _, _, err = store.PatchDocumentBlocks(userB.ID, doc.ID, domain.DocBlocksPatchRequest{ Upserted: []domain.DocumentBlock{ {ID: "b2", Type: "bullet", Content: "Zeile 2 von B bearbeitet", Level: 0}, @@ -560,8 +553,8 @@ func TestDatabaseFirstWorkspaceArchitecture(t *testing.T) { }, BlockOrder: []string{"b1", "b2", "b3"}, }) - if err != nil { - t.Fatalf("PatchDocumentBlocks by userB failed: %v", err) + if err == nil { + t.Fatalf("expected PatchDocumentBlocks by read-only userB to fail, but succeeded") } // User A's client only knew about b1, b2 when sending its patch (adds b4) @@ -576,9 +569,9 @@ func TestDatabaseFirstWorkspaceArchitecture(t *testing.T) { t.Fatalf("PatchDocumentBlocks by userA failed: %v", err) } - // Verify NO blocks were lost! All 4 blocks (b1, b2, b3, b4) must exist with both users' edits preserved - if len(patchedDocA.Blocks) != 4 { - t.Fatalf("expected 4 blocks after concurrent patches, got %d: %+v", len(patchedDocA.Blocks), patchedDocA.Blocks) + // Verify User A's blocks are preserved: b1, b2, b4 (3 blocks) + if len(patchedDocA.Blocks) != 3 { + t.Fatalf("expected 3 blocks after patch, got %d: %+v", len(patchedDocA.Blocks), patchedDocA.Blocks) } blockContent := make(map[string]string) for _, b := range patchedDocA.Blocks { @@ -587,12 +580,6 @@ func TestDatabaseFirstWorkspaceArchitecture(t *testing.T) { if blockContent["b1"] != "Zeile 1 von A bearbeitet" { t.Fatalf("expected b1 updated by A, got %q", blockContent["b1"]) } - if blockContent["b2"] != "Zeile 2 von B bearbeitet" { - t.Fatalf("expected b2 updated by B to be preserved, got %q", blockContent["b2"]) - } - if blockContent["b3"] != "Zeile 3 neu von B" { - t.Fatalf("expected b3 added by B to be preserved even with stale order from A, got %q", blockContent["b3"]) - } if blockContent["b4"] != "Zeile 4 neu von A" { t.Fatalf("expected b4 added by A, got %q", blockContent["b4"]) } diff --git a/internal/ws/docroom.go b/internal/ws/docroom.go deleted file mode 100644 index ed923e3..0000000 --- a/internal/ws/docroom.go +++ /dev/null @@ -1,736 +0,0 @@ -package ws - -import ( - "encoding/json" - "log/slog" - "net/http" - "sync" - "time" - - "github.com/gorilla/websocket" - "github.com/sandrodach/mindsift/internal/domain" -) - -// CursorPresence represents an active user's ephemeral cursor position in a document room. -type CursorPresence struct { - ClientID string `json:"clientId"` - UserID string `json:"userId"` - UserName string `json:"userName"` - UserColor string `json:"userColor"` - BlockID string `json:"blockId,omitempty"` - SelectionStart *int `json:"selectionStart,omitempty"` - SelectionEnd *int `json:"selectionEnd,omitempty"` - UpdatedAt int64 `json:"updatedAt"` -} - -// DocWSClient represents an active WebSocket connection to a specific document room. -type DocWSClient struct { - hub *Hub - room *DocRoom - conn *websocket.Conn - userID string - clientID string - canWrite bool - send chan []byte -} - -// DocSyncEvent is the lightweight JSON payload exchanged over /api/v1/ws/docs/{id}. -type DocSyncEvent struct { - Type string `json:"type"` - DocID string `json:"docId,omitempty"` - ClientID string `json:"clientId,omitempty"` - UserID string `json:"userId,omitempty"` - UserName string `json:"userName,omitempty"` - UserColor string `json:"userColor,omitempty"` - SelectionStart *int `json:"selectionStart,omitempty"` - SelectionEnd *int `json:"selectionEnd,omitempty"` - ActiveCursors []CursorPresence `json:"activeCursors,omitempty"` - Title string `json:"title,omitempty"` - Block *domain.DocumentBlock `json:"block,omitempty"` - BlockID string `json:"blockId,omitempty"` - AfterBlockID string `json:"afterBlockId,omitempty"` - NewIndex *int `json:"newIndex,omitempty"` - Content *string `json:"content,omitempty"` - Level *int `json:"level,omitempty"` - Checked *bool `json:"checked,omitempty"` - Collapsed *bool `json:"collapsed,omitempty"` - BlockType *string `json:"blockType,omitempty"` - Blocks []domain.DocumentBlock `json:"blocks,omitempty"` -} - -// DocRoom coordinates in-memory real-time block state and 1.5s debounced SQLite persistence for one document. -type DocRoom struct { - mu sync.Mutex - docID string - hub *Hub - title string - blocks []domain.DocumentBlock - loaded bool - wsClients map[*DocWSClient]bool - activeCursors map[string]*CursorPresence - debounceTimer *time.Timer - dirty bool -} - -// GetOrCreateDocRoom returns the active in-memory room for docID or initializes one from SQLite. -func (h *Hub) GetOrCreateDocRoom(docID string) *DocRoom { - h.mu.Lock() - if h.docRooms == nil { - h.docRooms = make(map[string]*DocRoom) - } - room, exists := h.docRooms[docID] - if !exists { - room = &DocRoom{ - docID: docID, - hub: h, - wsClients: make(map[*DocWSClient]bool), - activeCursors: make(map[string]*CursorPresence), - } - h.docRooms[docID] = room - } - store := h.docStore - h.mu.Unlock() - - room.mu.Lock() - if !room.loaded && store != nil { - title, blocks, err := store.LoadDocumentState(docID) - if err == nil { - room.title = title - room.blocks = normalizeRoomBlocks(docID, blocks) - room.loaded = true - } - } - room.mu.Unlock() - - return room -} - -// GetActiveDocState returns the live in-memory title and blocks if an active DocRoom exists in RAM. -func (h *Hub) GetActiveDocState(docID string) (string, []domain.DocumentBlock, bool) { - h.mu.RLock() - room, exists := h.docRooms[docID] - h.mu.RUnlock() - if !exists || room == nil { - return "", nil, false - } - - room.mu.Lock() - defer room.mu.Unlock() - if !room.loaded { - return "", nil, false - } - blocksCopy := make([]domain.DocumentBlock, len(room.blocks)) - copy(blocksCopy, room.blocks) - return room.title, blocksCopy, true -} - -// SyncActiveDocRoom updates an in-memory room if one is currently loaded (e.g. after REST PUT/PATCH). -func (h *Hub) SyncActiveDocRoom(docID string, title string, blocks []domain.DocumentBlock) { - h.mu.RLock() - room, exists := h.docRooms[docID] - h.mu.RUnlock() - if !exists || room == nil { - return - } - - room.mu.Lock() - if title != "" { - room.title = title - } - if blocks != nil { - room.blocks = normalizeRoomBlocks(docID, blocks) - } - room.loaded = true - room.mu.Unlock() -} - -// CloseDocRoom removes a document room from memory when a document is deleted. -func (h *Hub) CloseDocRoom(docID string) { - h.mu.Lock() - room, exists := h.docRooms[docID] - if exists { - delete(h.docRooms, docID) - } - h.mu.Unlock() - - if exists && room != nil { - room.mu.Lock() - if room.debounceTimer != nil { - room.debounceTimer.Stop() - } - room.dirty = false - for c := range room.wsClients { - _ = c.conn.Close() - } - room.wsClients = make(map[*DocWSClient]bool) - room.mu.Unlock() - } -} - -func normalizeRoomBlocks(docID string, blocks []domain.DocumentBlock) []domain.DocumentBlock { - if len(blocks) == 0 { - return []domain.DocumentBlock{ - { - ID: docID + "-b0", - DocumentID: docID, - Type: "text", - Content: "", - Level: 0, - Position: 1000, - }, - } - } - out := make([]domain.DocumentBlock, len(blocks)) - for i, b := range blocks { - if b.ID == "" { - b.ID = domain.NewID("blk") - } - b.DocumentID = docID - if b.Type == "" { - b.Type = "text" - } - if b.Level < 0 { - b.Level = 0 - } - b.Position = float64((i + 1) * 1000) - out[i] = b - } - return out -} - -// GetStateSnapshot returns a copy of the room's current live in-memory title and blocks. -func (r *DocRoom) GetStateSnapshot() (string, []domain.DocumentBlock) { - r.mu.Lock() - defer r.mu.Unlock() - blocksCopy := make([]domain.DocumentBlock, len(r.blocks)) - copy(blocksCopy, r.blocks) - return r.title, blocksCopy -} - -// HandleEvent applies a granular block or title mutation to the in-memory room, -// broadcasts it to all OTHER clients in the room (excluding senderClientID for echo protection), -// and resets the 1.5-second inactivity debounce timer for SQLite persistence. -func (r *DocRoom) HandleEvent(evt DocSyncEvent, sender *DocWSClient) { - r.mu.Lock() - - evt.DocID = r.docID - if sender != nil { - if evt.ClientID == "" { - evt.ClientID = sender.clientID - } - evt.UserID = sender.userID - } - - mutated := false - - switch evt.Type { - case "cursor_moved": - if evt.ClientID != "" { - cp := &CursorPresence{ - ClientID: evt.ClientID, - UserID: evt.UserID, - UserName: evt.UserName, - UserColor: evt.UserColor, - BlockID: evt.BlockID, - SelectionStart: evt.SelectionStart, - SelectionEnd: evt.SelectionEnd, - UpdatedAt: time.Now().UnixMilli(), - } - if r.activeCursors == nil { - r.activeCursors = make(map[string]*CursorPresence) - } - r.activeCursors[evt.ClientID] = cp - - msgBytes, err := json.Marshal(evt) - if err == nil { - for client := range r.wsClients { - if sender != nil && (client == sender || (sender.clientID != "" && client.clientID == sender.clientID)) { - continue - } - if evt.ClientID != "" && client.clientID == evt.ClientID { - continue - } - select { - case client.send <- msgBytes: - default: - } - } - } - } - r.mu.Unlock() - return - - case "title_updated": - if evt.Title != "" || r.title != evt.Title { - r.title = evt.Title - mutated = true - } - - case "block_created": - if evt.Block != nil { - newBlock := *evt.Block - if newBlock.ID == "" { - newBlock.ID = domain.NewID("blk") - } - newBlock.DocumentID = r.docID - if newBlock.Type == "" { - newBlock.Type = "text" - } - if newBlock.Level < 0 { - newBlock.Level = 0 - } - - // Avoid duplicate ID if already created - existsIdx := -1 - for i, b := range r.blocks { - if b.ID == newBlock.ID { - existsIdx = i - break - } - } - - if existsIdx >= 0 { - r.blocks[existsIdx] = newBlock - } else { - insertIdx := len(r.blocks) - if evt.AfterBlockID != "" { - for i, b := range r.blocks { - if b.ID == evt.AfterBlockID { - insertIdx = i + 1 - break - } - } - } else if evt.NewIndex != nil && *evt.NewIndex >= 0 && *evt.NewIndex <= len(r.blocks) { - insertIdx = *evt.NewIndex - } - - updated := make([]domain.DocumentBlock, 0, len(r.blocks)+1) - updated = append(updated, r.blocks[:insertIdx]...) - updated = append(updated, newBlock) - updated = append(updated, r.blocks[insertIdx:]...) - r.blocks = normalizeRoomBlocks(r.docID, updated) - } - evt.Block = &newBlock - mutated = true - } - - case "block_updated": - targetID := evt.BlockID - if targetID == "" && evt.Block != nil { - targetID = evt.Block.ID - } - if targetID != "" { - found := false - for i := range r.blocks { - if r.blocks[i].ID == targetID { - found = true - if evt.Block != nil { - r.blocks[i].Content = evt.Block.Content - r.blocks[i].Level = evt.Block.Level - if evt.Block.Type != "" { - r.blocks[i].Type = evt.Block.Type - } - r.blocks[i].Checked = evt.Block.Checked - r.blocks[i].Collapsed = evt.Block.Collapsed - r.blocks[i].Lang = evt.Block.Lang - } else { - if evt.Content != nil { - r.blocks[i].Content = *evt.Content - } - if evt.Level != nil { - lvl := *evt.Level - if lvl < 0 { - lvl = 0 - } - r.blocks[i].Level = lvl - } - if evt.Checked != nil { - r.blocks[i].Checked = *evt.Checked - } - if evt.Collapsed != nil { - r.blocks[i].Collapsed = *evt.Collapsed - } - if evt.BlockType != nil && *evt.BlockType != "" { - r.blocks[i].Type = *evt.BlockType - } - } - bCopy := r.blocks[i] - evt.Block = &bCopy - evt.BlockID = targetID - mutated = true - break - } - } - if !found && evt.Block != nil { - newBlock := *evt.Block - newBlock.DocumentID = r.docID - if newBlock.Type == "" { - newBlock.Type = "text" - } - if newBlock.Level < 0 { - newBlock.Level = 0 - } - r.blocks = append(r.blocks, newBlock) - r.blocks = normalizeRoomBlocks(r.docID, r.blocks) - bCopy := newBlock - evt.Block = &bCopy - evt.BlockID = targetID - mutated = true - } - } - - case "block_deleted": - if evt.BlockID != "" { - filtered := make([]domain.DocumentBlock, 0, len(r.blocks)) - for _, b := range r.blocks { - if b.ID != evt.BlockID { - filtered = append(filtered, b) - } - } - r.blocks = normalizeRoomBlocks(r.docID, filtered) - mutated = true - } - - case "block_moved": - if evt.BlockID != "" && evt.NewIndex != nil { - fromIdx := -1 - for i, b := range r.blocks { - if b.ID == evt.BlockID { - fromIdx = i - break - } - } - if fromIdx >= 0 { - moving := r.blocks[fromIdx] - if evt.Level != nil { - lvl := *evt.Level - if lvl < 0 { - lvl = 0 - } - moving.Level = lvl - } - without := make([]domain.DocumentBlock, 0, len(r.blocks)-1) - without = append(without, r.blocks[:fromIdx]...) - without = append(without, r.blocks[fromIdx+1:]...) - - toIdx := *evt.NewIndex - if toIdx < 0 { - toIdx = 0 - } - if toIdx > len(without) { - toIdx = len(without) - } - - reordered := make([]domain.DocumentBlock, 0, len(r.blocks)) - reordered = append(reordered, without[:toIdx]...) - reordered = append(reordered, moving) - reordered = append(reordered, without[toIdx:]...) - r.blocks = normalizeRoomBlocks(r.docID, reordered) - mutated = true - } - } - - case "blocks_replaced": - if evt.Blocks != nil { - r.blocks = normalizeRoomBlocks(r.docID, evt.Blocks) - evt.Blocks = r.blocks - mutated = true - } - } - - if !mutated { - r.mu.Unlock() - return - } - - r.dirty = true - r.loaded = true - - msgBytes, err := json.Marshal(evt) - if err == nil { - for client := range r.wsClients { - // Strict Echo Protection: never send back to the originating connection or clientId - if sender != nil && (client == sender || (sender.clientID != "" && client.clientID == sender.clientID)) { - continue - } - if evt.ClientID != "" && client.clientID == evt.ClientID { - continue - } - select { - case client.send <- msgBytes: - default: - slog.Warn("Doc room WS client send buffer full, dropping message", - "doc_id", r.docID, - "client_id", client.clientID, - "user_id", client.userID, - ) - } - } - } - - r.scheduleDebouncedFlushLocked() - r.mu.Unlock() -} - -func (r *DocRoom) scheduleDebouncedFlushLocked() { - if r.debounceTimer != nil { - r.debounceTimer.Stop() - } - r.debounceTimer = time.AfterFunc(1500*time.Millisecond, func() { - r.Flush() - }) -} - -// Flush persists the current in-memory document state (blocks, #tags in block_tags, and markdown_cache) to SQLite. -func (r *DocRoom) Flush() { - r.mu.Lock() - if !r.dirty { - r.mu.Unlock() - return - } - if r.debounceTimer != nil { - r.debounceTimer.Stop() - r.debounceTimer = nil - } - - docID := r.docID - title := r.title - blocksCopy := make([]domain.DocumentBlock, len(r.blocks)) - copy(blocksCopy, r.blocks) - r.dirty = false - r.mu.Unlock() - - tagsCount := 0 - for _, b := range blocksCopy { - tagsCount += len(domain.ExtractTags(b.Content)) - } - - start := time.Now() - r.hub.mu.RLock() - store := r.hub.docStore - r.hub.mu.RUnlock() - - if store != nil { - if err := store.FlushDocumentState(docID, title, blocksCopy); err != nil { - slog.Error("SQLite document flush failed", - "doc_id", docID, - "error", err, - ) - } else { - durationMs := float64(time.Since(start).Microseconds()) / 1000.0 - slog.Debug("SQLite document flushed", - "doc_id", docID, - "blocks_count", len(blocksCopy), - "tags_count", tagsCount, - "duration_ms", durationMs, - ) - } - } -} - -// ServeDocWS handles WebSocket connections for GET /api/v1/ws/docs/{id} -func (r *DocRoom) ServeDocWS(w http.ResponseWriter, req *http.Request, userID string, clientID string, canWrite bool) { - conn, err := upgrader.Upgrade(w, req, nil) - if err != nil { - slog.Error("WebSocket upgrade error", "doc_id", r.docID, "error", err) - return - } - - if clientID == "" { - clientID = req.URL.Query().Get("clientId") - } - if clientID == "" { - clientID = domain.NewID("cli") - } - - userName := req.URL.Query().Get("userName") - userColor := req.URL.Query().Get("userColor") - - client := &DocWSClient{ - hub: r.hub, - room: r, - conn: conn, - userID: userID, - clientID: clientID, - canWrite: canWrite, - send: make(chan []byte, 256), - } - - r.mu.Lock() - r.wsClients[client] = true - activeClients := len(r.wsClients) - if r.activeCursors == nil { - r.activeCursors = make(map[string]*CursorPresence) - } - if userName != "" { - r.activeCursors[clientID] = &CursorPresence{ - ClientID: clientID, - UserID: userID, - UserName: userName, - UserColor: userColor, - UpdatedAt: time.Now().UnixMilli(), - } - } - title := r.title - blocksCopy := make([]domain.DocumentBlock, len(r.blocks)) - copy(blocksCopy, r.blocks) - - activeList := make([]CursorPresence, 0, len(r.activeCursors)) - for _, cp := range r.activeCursors { - if cp != nil { - activeList = append(activeList, *cp) - } - } - r.mu.Unlock() - - slog.Debug("WS client joined doc room", - "doc_id", r.docID, - "client_id", clientID, - "user_id", userID, - "active_clients", activeClients, - ) - - // Immediately send live in-memory state and current cursors to the newly connected client - initEvt := DocSyncEvent{ - Type: "doc_state", - DocID: r.docID, - ClientID: clientID, - Title: title, - Blocks: blocksCopy, - ActiveCursors: activeList, - } - if initBytes, err := json.Marshal(initEvt); err == nil { - client.send <- initBytes - } - - // If userName was provided, broadcast initial presence to existing clients in room - if userName != "" { - joinEvt := DocSyncEvent{ - Type: "cursor_moved", - DocID: r.docID, - ClientID: clientID, - UserID: userID, - UserName: userName, - UserColor: userColor, - } - if joinBytes, err := json.Marshal(joinEvt); err == nil { - r.mu.Lock() - for c := range r.wsClients { - if c != client && c.clientID != clientID { - select { - case c.send <- joinBytes: - default: - } - } - } - r.mu.Unlock() - } - } - - go client.writePump() - go client.readPump() -} - -func (c *DocWSClient) readPump() { - defer func() { - c.room.mu.Lock() - delete(c.room.wsClients, c) - delete(c.room.activeCursors, c.clientID) - remaining := len(c.room.wsClients) - - // Broadcast client_left to all remaining clients in room - leftEvt := DocSyncEvent{ - Type: "client_left", - DocID: c.room.docID, - ClientID: c.clientID, - UserID: c.userID, - } - if leftBytes, err := json.Marshal(leftEvt); err == nil { - for client := range c.room.wsClients { - if client != c && client.clientID != c.clientID { - select { - case client.send <- leftBytes: - default: - } - } - } - } - c.room.mu.Unlock() - - slog.Debug("WS client left doc room", - "doc_id", c.room.docID, - "client_id", c.clientID, - "user_id", c.userID, - "active_clients", remaining, - ) - - _ = c.conn.Close() - - // Flush immediately when the last client leaves the document room - if remaining == 0 { - c.room.Flush() - } - }() - - c.conn.SetReadLimit(4 * 1024 * 1024) - _ = c.conn.SetReadDeadline(time.Now().Add(60 * time.Second)) - c.conn.SetPongHandler(func(string) error { - _ = c.conn.SetReadDeadline(time.Now().Add(60 * time.Second)) - return nil - }) - - for { - _, message, err := c.conn.ReadMessage() - if err != nil { - break - } - - var evt DocSyncEvent - if err := json.Unmarshal(message, &evt); err == nil && evt.Type != "" { - targetBlockID := evt.BlockID - if targetBlockID == "" && evt.Block != nil { - targetBlockID = evt.Block.ID - } - - slog.Debug("WS doc event received", - "type", evt.Type, - "doc_id", c.room.docID, - "block_id", targetBlockID, - "sender_client_id", c.clientID, - ) - - // Read-only clients can still move their cursor and send presence updates - if !c.canWrite && evt.Type != "cursor_moved" { - continue - } - c.room.HandleEvent(evt, c) - } - } -} - -func (c *DocWSClient) writePump() { - ticker := time.NewTicker(25 * time.Second) - defer func() { - ticker.Stop() - _ = c.conn.Close() - }() - - for { - select { - case message, ok := <-c.send: - _ = c.conn.SetWriteDeadline(time.Now().Add(10 * time.Second)) - if !ok { - _ = c.conn.WriteMessage(websocket.CloseMessage, []byte{}) - return - } - if err := c.conn.WriteMessage(websocket.TextMessage, message); err != nil { - return - } - case <-ticker.C: - _ = c.conn.SetWriteDeadline(time.Now().Add(10 * time.Second)) - if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil { - return - } - } - } -} diff --git a/internal/ws/docroom_test.go b/internal/ws/docroom_test.go deleted file mode 100644 index 7cde8df..0000000 --- a/internal/ws/docroom_test.go +++ /dev/null @@ -1,85 +0,0 @@ -package ws - -import ( - "testing" - "time" -) - -func TestDocRoomCursorPresenceAndEchoProtection(t *testing.T) { - hub := NewHub() - room := hub.GetOrCreateDocRoom("doc_test_presence") - - c1 := &DocWSClient{ - hub: hub, - room: room, - userID: "user_1", - clientID: "cli_1", - canWrite: true, - send: make(chan []byte, 10), - } - c2 := &DocWSClient{ - hub: hub, - room: room, - userID: "user_2", - clientID: "cli_2", - canWrite: false, // read-only viewer - send: make(chan []byte, 10), - } - - room.mu.Lock() - room.wsClients[c1] = true - room.wsClients[c2] = true - room.mu.Unlock() - - // 1. Send cursor_moved from c1 - selStart := 5 - selEnd := 5 - room.HandleEvent(DocSyncEvent{ - Type: "cursor_moved", - DocID: "doc_test_presence", - ClientID: "cli_1", - UserID: "user_1", - UserName: "Alice", - UserColor: "#3b82f6", - BlockID: "blk_123", - SelectionStart: &selStart, - SelectionEnd: &selEnd, - }, c1) - - // c1 should NOT have received its own echo - select { - case <-c1.send: - t.Fatal("c1 received echo of its own cursor_moved event!") - default: - } - - // c2 SHOULD receive the event - select { - case msg := <-c2.send: - if len(msg) == 0 { - t.Fatal("empty message received by c2") - } - case <-time.After(500 * time.Millisecond): - t.Fatal("timed out waiting for cursor_moved event on c2") - } - - // Room should have registered c1's presence - room.mu.Lock() - cp, exists := room.activeCursors["cli_1"] - room.mu.Unlock() - - if !exists || cp == nil { - t.Fatal("expected active cursor for cli_1 in room") - } - if cp.UserName != "Alice" || cp.BlockID != "blk_123" { - t.Fatalf("unexpected cursor data: %+v", cp) - } - - // Ephemeral cursor events must NOT mark the room dirty for disk flush - room.mu.Lock() - dirty := room.dirty - room.mu.Unlock() - if dirty { - t.Fatal("cursor_moved should not mark room dirty") - } -} diff --git a/internal/ws/hub.go b/internal/ws/hub.go index 3453469..533815d 100644 --- a/internal/ws/hub.go +++ b/internal/ws/hub.go @@ -84,37 +84,6 @@ func (c *SSEClient) trySend(data []byte) { } } -type docLockItem struct { - docID string - userID string - userName string - lockedAt time.Time - expiresAt time.Time -} - -type blockLockItem struct { - docID string - blockID string - userID string - userName string - userColor string - lockedAt time.Time - expiresAt time.Time -} - -type liveSessionItem struct { - docID string - initiatorID string - initiatorName string - startedAt time.Time - expiresAt time.Time -} - -type DocStore interface { - LoadDocumentState(docID string) (string, []domain.DocumentBlock, error) - FlushDocumentState(docID string, title string, blocks []domain.DocumentBlock) error -} - type Hub struct { mu sync.RWMutex rooms map[string]map[*Client]bool @@ -123,37 +92,18 @@ type Hub struct { sseClients map[*SSEClient]bool sseUserRooms map[string]map[*SSEClient]bool sseRooms map[string]map[*SSEClient]bool - locks map[string]*docLockItem - blockLocks map[string]*blockLockItem // key: docID + ":" + blockID - liveSessions map[string]*liveSessionItem // key: docID - docRooms map[string]*DocRoom - docStore DocStore - stopCleanup chan struct{} closed bool } func NewHub() *Hub { - h := &Hub{ + return &Hub{ rooms: make(map[string]map[*Client]bool), userRooms: make(map[string]map[*Client]bool), allClients: make(map[*Client]bool), sseClients: make(map[*SSEClient]bool), sseUserRooms: make(map[string]map[*SSEClient]bool), sseRooms: make(map[string]map[*SSEClient]bool), - locks: make(map[string]*docLockItem), - blockLocks: make(map[string]*blockLockItem), - liveSessions: make(map[string]*liveSessionItem), - docRooms: make(map[string]*DocRoom), - stopCleanup: make(chan struct{}), } - go h.cleanupLocksLoop() - return h -} - -func (h *Hub) SetDocStore(store DocStore) { - h.mu.Lock() - defer h.mu.Unlock() - h.docStore = store } func (h *Hub) Register(c *Client) { @@ -193,7 +143,6 @@ func (h *Hub) Unregister(c *Client) { } } - var docRoomsLeft []string if c.subscribedRooms != nil { for rID := range c.subscribedRooms { if room, ok := h.rooms[rID]; ok { @@ -202,52 +151,19 @@ func (h *Hub) Unregister(c *Client) { delete(h.rooms, rID) } } - if strings.HasPrefix(rID, "doc:") { - docRoomsLeft = append(docRoomsLeft, strings.TrimPrefix(rID, "doc:")) - } } } - userRemaining := 0 if c.userID != "" && c.userID != "anonymous" { if uRoom, ok := h.userRooms[c.userID]; ok { delete(uRoom, c) - userRemaining += len(uRoom) if len(uRoom) == 0 { delete(h.userRooms, c.userID) } } - if sseRoom, ok := h.sseUserRooms[c.userID]; ok { - userRemaining += len(sseRoom) - } } c.closeSend() - - // If this user has no other active client connections, release their document locks - var releasedDocIDs []string - if userRemaining == 0 && c.userID != "" { - now := time.Now() - for docID, l := range h.locks { - if l.userID == c.userID || l.expiresAt.Before(now) { - releasedDocIDs = append(releasedDocIDs, docID) - delete(h.locks, docID) - } - } - } h.mu.Unlock() - - // Broadcast unlock events for released locks - for _, docID := range releasedDocIDs { - h.BroadcastToAll("DOC_UNLOCKED", map[string]string{"docId": docID, "doc_id": docID}) - } - - // Broadcast awareness leave for doc rooms - for _, docID := range docRoomsLeft { - h.BroadcastRoom("doc:"+docID, "CRDT_AWARENESS_LEAVE", map[string]interface{}{ - "docId": docID, - "userId": c.userID, - }, nil) - } } func (h *Hub) RegisterSSE(c *SSEClient) { @@ -294,7 +210,6 @@ func (h *Hub) UnregisterSSE(c *SSEClient) { } } } - var docRoomsLeft []string if c.subscribedRooms != nil { for rID := range c.subscribedRooms { if room, ok := h.sseRooms[rID]; ok { @@ -303,48 +218,18 @@ func (h *Hub) UnregisterSSE(c *SSEClient) { delete(h.sseRooms, rID) } } - if strings.HasPrefix(rID, "doc:") { - docRoomsLeft = append(docRoomsLeft, strings.TrimPrefix(rID, "doc:")) - } } } - userRemaining := 0 if c.userID != "" && c.userID != "anonymous" { if uRoom, ok := h.sseUserRooms[c.userID]; ok { delete(uRoom, c) - userRemaining += len(uRoom) if len(uRoom) == 0 { delete(h.sseUserRooms, c.userID) } } - if wsRoom, ok := h.userRooms[c.userID]; ok { - userRemaining += len(wsRoom) - } } c.closeSend() - - var releasedDocIDs []string - if userRemaining == 0 && c.userID != "" { - now := time.Now() - for docID, l := range h.locks { - if l.userID == c.userID || l.expiresAt.Before(now) { - releasedDocIDs = append(releasedDocIDs, docID) - delete(h.locks, docID) - } - } - } h.mu.Unlock() - - for _, docID := range releasedDocIDs { - h.BroadcastToAll("DOC_UNLOCKED", map[string]string{"docId": docID, "doc_id": docID}) - } - - for _, docID := range docRoomsLeft { - h.BroadcastRoom("doc:"+docID, "CRDT_AWARENESS_LEAVE", map[string]interface{}{ - "docId": docID, - "userId": c.userID, - }, nil) - } } func (h *Hub) JoinSSERoomByID(sseID, roomID string) bool { @@ -386,354 +271,9 @@ func (h *Hub) LeaveSSERoomByID(sseID, roomID string) bool { return false } -// Document Locking Methods -func (h *Hub) AcquireDocLock(docID, userID, userName string, ttl time.Duration) (*domain.DocLockInfo, bool, *domain.DocLockInfo) { - h.mu.Lock() - defer h.mu.Unlock() - now := time.Now() - existing, exists := h.locks[docID] - if exists && existing.expiresAt.After(now) { - if existing.userID != userID { - // Locked by another user - info := &domain.DocLockInfo{ - DocID: existing.docID, - UserID: existing.userID, - UserName: existing.userName, - ExpiresAt: existing.expiresAt.Format(time.RFC3339), - } - return nil, false, info - } - // Same user extending lock - existing.expiresAt = now.Add(ttl) - if userName != "" { - existing.userName = userName - } - info := &domain.DocLockInfo{ - DocID: existing.docID, - UserID: existing.userID, - UserName: existing.userName, - ExpiresAt: existing.expiresAt.Format(time.RFC3339), - } - return info, true, nil - } - - // Create fresh lock - item := &docLockItem{ - docID: docID, - userID: userID, - userName: userName, - lockedAt: now, - expiresAt: now.Add(ttl), - } - h.locks[docID] = item - info := &domain.DocLockInfo{ - DocID: item.docID, - UserID: item.userID, - UserName: item.userName, - ExpiresAt: item.expiresAt.Format(time.RFC3339), - } - return info, true, nil -} - -func (h *Hub) HeartbeatDocLock(docID, userID string, ttl time.Duration) bool { - h.mu.Lock() - defer h.mu.Unlock() - - now := time.Now() - item, ok := h.locks[docID] - if !ok || item.userID != userID || item.expiresAt.Before(now) { - return false - } - item.expiresAt = now.Add(ttl) - return true -} - -func (h *Hub) ReleaseDocLock(docID, userID string, isAdmin bool) bool { - h.mu.Lock() - defer h.mu.Unlock() - - item, ok := h.locks[docID] - if !ok { - return false - } - if item.userID == userID || isAdmin || userID == "admin" { - delete(h.locks, docID) - return true - } - return false -} - -func (h *Hub) GetDocLock(docID string) *domain.DocLockInfo { - h.mu.RLock() - defer h.mu.RUnlock() - - item, ok := h.locks[docID] - if !ok || item.expiresAt.Before(time.Now()) { - return nil - } - return &domain.DocLockInfo{ - DocID: item.docID, - UserID: item.userID, - UserName: item.userName, - ExpiresAt: item.expiresAt.Format(time.RFC3339), - } -} - -// Block Locking Methods (Option B) - -func (h *Hub) AcquireBlockLock(docID, blockID, userID, userName, userColor string, ttl time.Duration) (*domain.BlockLockInfo, bool, *domain.BlockLockInfo) { - h.mu.Lock() - defer h.mu.Unlock() - - now := time.Now() - key := docID + ":" + blockID - existing, exists := h.blockLocks[key] - if exists && existing.expiresAt.After(now) { - if existing.userID != userID { - info := &domain.BlockLockInfo{ - DocID: existing.docID, - BlockID: existing.blockID, - UserID: existing.userID, - UserName: existing.userName, - UserColor: existing.userColor, - ExpiresAt: existing.expiresAt.Format(time.RFC3339), - } - return nil, false, info - } - // Refresh lease - existing.expiresAt = now.Add(ttl) - if userName != "" { - existing.userName = userName - } - if userColor != "" { - existing.userColor = userColor - } - info := &domain.BlockLockInfo{ - DocID: existing.docID, - BlockID: existing.blockID, - UserID: existing.userID, - UserName: existing.userName, - UserColor: existing.userColor, - ExpiresAt: existing.expiresAt.Format(time.RFC3339), - } - return info, true, nil - } - - item := &blockLockItem{ - docID: docID, - blockID: blockID, - userID: userID, - userName: userName, - userColor: userColor, - lockedAt: now, - expiresAt: now.Add(ttl), - } - h.blockLocks[key] = item - info := &domain.BlockLockInfo{ - DocID: item.docID, - BlockID: item.blockID, - UserID: item.userID, - UserName: item.userName, - UserColor: item.userColor, - ExpiresAt: item.expiresAt.Format(time.RFC3339), - } - return info, true, nil -} - -func (h *Hub) ReleaseBlockLock(docID, blockID, userID string) bool { - h.mu.Lock() - defer h.mu.Unlock() - - key := docID + ":" + blockID - existing, exists := h.blockLocks[key] - if !exists { - return false - } - if userID != "" && existing.userID != userID && userID != "admin" { - return false - } - delete(h.blockLocks, key) - return true -} - -func (h *Hub) GetDocBlockLocks(docID string) []domain.BlockLockInfo { - h.mu.RLock() - defer h.mu.RUnlock() - - now := time.Now() - var result []domain.BlockLockInfo - prefix := docID + ":" - for k, l := range h.blockLocks { - if strings.HasPrefix(k, prefix) && l.expiresAt.After(now) { - result = append(result, domain.BlockLockInfo{ - DocID: l.docID, - BlockID: l.blockID, - UserID: l.userID, - UserName: l.userName, - UserColor: l.userColor, - ExpiresAt: l.expiresAt.Format(time.RFC3339), - }) - } - } - return result -} - -// Live Session Methods (Opt-In Live Collaboration) - -func (h *Hub) StartLiveSession(docID, userID, userName string, ttl time.Duration) (*domain.LiveSessionInfo, bool, *domain.LiveSessionInfo) { - h.mu.Lock() - defer h.mu.Unlock() - - now := time.Now() - existing, exists := h.liveSessions[docID] - if exists && existing.expiresAt.After(now) { - if existing.initiatorID != userID { - info := &domain.LiveSessionInfo{ - DocID: existing.docID, - InitiatorID: existing.initiatorID, - InitiatorName: existing.initiatorName, - StartedAt: existing.startedAt.Format(time.RFC3339), - ExpiresAt: existing.expiresAt.Format(time.RFC3339), - } - return nil, false, info - } - // Refresh lease - existing.expiresAt = now.Add(ttl) - info := &domain.LiveSessionInfo{ - DocID: existing.docID, - InitiatorID: existing.initiatorID, - InitiatorName: existing.initiatorName, - StartedAt: existing.startedAt.Format(time.RFC3339), - ExpiresAt: existing.expiresAt.Format(time.RFC3339), - } - return info, true, nil - } - - item := &liveSessionItem{ - docID: docID, - initiatorID: userID, - initiatorName: userName, - startedAt: now, - expiresAt: now.Add(ttl), - } - h.liveSessions[docID] = item - info := &domain.LiveSessionInfo{ - DocID: item.docID, - InitiatorID: item.initiatorID, - InitiatorName: item.initiatorName, - StartedAt: item.startedAt.Format(time.RFC3339), - ExpiresAt: item.expiresAt.Format(time.RFC3339), - } - return info, true, nil -} - -func (h *Hub) EndLiveSession(docID, userID string) bool { - h.mu.Lock() - defer h.mu.Unlock() - - existing, exists := h.liveSessions[docID] - if !exists { - return false - } - if userID != "" && existing.initiatorID != userID && userID != "admin" { - return false - } - delete(h.liveSessions, docID) - return true -} - -func (h *Hub) ForceEndLiveSession(docID string) bool { - h.mu.Lock() - defer h.mu.Unlock() - - if _, exists := h.liveSessions[docID]; !exists { - return false - } - delete(h.liveSessions, docID) - return true -} - -func (h *Hub) GetLiveSession(docID string) *domain.LiveSessionInfo { - h.mu.RLock() - defer h.mu.RUnlock() - - now := time.Now() - existing, exists := h.liveSessions[docID] - if !exists || existing.expiresAt.Before(now) { - return nil - } - return &domain.LiveSessionInfo{ - DocID: existing.docID, - InitiatorID: existing.initiatorID, - InitiatorName: existing.initiatorName, - StartedAt: existing.startedAt.Format(time.RFC3339), - ExpiresAt: existing.expiresAt.Format(time.RFC3339), - } -} - -func (h *Hub) cleanupLocksLoop() { - ticker := time.NewTicker(2 * time.Second) - defer ticker.Stop() - - for { - select { - case <-h.stopCleanup: - return - case <-ticker.C: - h.mu.Lock() - now := time.Now() - - var expiredDocIDs []string - for docID, l := range h.locks { - if l.expiresAt.Before(now) { - expiredDocIDs = append(expiredDocIDs, docID) - delete(h.locks, docID) - } - } - - type expBlock struct { - docID string - blockID string - } - var expiredBlocks []expBlock - for k, bl := range h.blockLocks { - if bl.expiresAt.Before(now) { - expiredBlocks = append(expiredBlocks, expBlock{docID: bl.docID, blockID: bl.blockID}) - delete(h.blockLocks, k) - } - } - - var expiredLiveSessions []string - for docID, ls := range h.liveSessions { - if ls.expiresAt.Before(now) { - expiredLiveSessions = append(expiredLiveSessions, docID) - delete(h.liveSessions, docID) - } - } - h.mu.Unlock() - - for _, docID := range expiredDocIDs { - h.BroadcastToAll("DOC_UNLOCKED", map[string]string{"docId": docID, "doc_id": docID}) - } - for _, b := range expiredBlocks { - h.BroadcastRoom("doc:"+b.docID, "DOC_BLOCK_UNLOCKED", map[string]string{ - "docId": b.docID, - "blockId": b.blockID, - }, nil) - } - for _, docID := range expiredLiveSessions { - h.BroadcastRoom("doc:"+docID, "DOC_LIVE_ENDED", map[string]string{ - "docId": docID, - }, nil) - } - } - } -} - -// Close gracefully shuts down the Hub, stops background tickers, flushes dirty document rooms to DB, -// and immediately disconnects all WebSocket and SSE connections so HTTP shutdown completes in milliseconds. +// Close gracefully disconnects all WebSocket and SSE connections so HTTP shutdown completes immediately. func (h *Hub) Close() { h.mu.Lock() if h.closed { @@ -742,19 +282,6 @@ func (h *Hub) Close() { } h.closed = true - if h.stopCleanup != nil { - select { - case <-h.stopCleanup: - default: - close(h.stopCleanup) - } - } - - docRooms := make([]*DocRoom, 0, len(h.docRooms)) - for _, r := range h.docRooms { - docRooms = append(docRooms, r) - } - allWsClients := make([]*Client, 0, len(h.allClients)) for c := range h.allClients { allWsClients = append(allWsClients, c) @@ -766,28 +293,11 @@ func (h *Hub) Close() { } h.mu.Unlock() - // 1. Flush any active dirty document rooms and close doc WS connections - for _, r := range docRooms { - r.Flush() - r.mu.Lock() - if r.debounceTimer != nil { - r.debounceTimer.Stop() - r.debounceTimer = nil - } - for c := range r.wsClients { - _ = c.conn.Close() - } - r.wsClients = make(map[*DocWSClient]bool) - r.mu.Unlock() - } - - // 2. Close all WebSocket connections for _, c := range allWsClients { _ = c.conn.Close() c.closeSend() } - // 3. Close all SSE client channels so ServeSSE select loop exits immediately for _, c := range allSSEClients { c.closeSend() } @@ -1116,21 +626,6 @@ func (c *Client) readPump() { c.hub.LeaveRoom(c, room) } } - - if docID != "" { - switch incoming.Event { - case "LOCK_DOC": - if info, ok, _ := c.hub.AcquireDocLock(docID, c.userID, "", 30*time.Second); ok && info != nil { - c.hub.BroadcastDocEvent(docID, "DOC_LOCKED", info) - } - case "HEARTBEAT_DOC": - c.hub.HeartbeatDocLock(docID, c.userID, 30*time.Second) - case "UNLOCK_DOC": - if c.hub.ReleaseDocLock(docID, c.userID, false) { - c.hub.BroadcastDocEvent(docID, "DOC_UNLOCKED", map[string]string{"docId": docID, "doc_id": docID}) - } - } - } } } } -- 2.54.0 From fd89f92b0c12f850e41d3f0613a9867de785ddeb Mon Sep 17 00:00:00 2001 From: Sandro Date: Tue, 27 Oct 2026 17:31:08 +0100 Subject: [PATCH 13/30] feat(outliner): outdent empty bulletpoint on enter --- frontend/src/features/workspace/OutlinerBlock.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/frontend/src/features/workspace/OutlinerBlock.tsx b/frontend/src/features/workspace/OutlinerBlock.tsx index dd8b31e..612d85b 100644 --- a/frontend/src/features/workspace/OutlinerBlock.tsx +++ b/frontend/src/features/workspace/OutlinerBlock.tsx @@ -344,11 +344,19 @@ export const OutlinerBlock: React.FC = ({ return; } - // 2. Enter (without Shift) -> Create new block on same level + // 2. Enter (without Shift) -> If block is completely empty and level > minLevel, outdent (Shift+Tab behaviour), otherwise create new block below if (e.key === 'Enter' && !e.shiftKey && block.type !== 'code') { e.preventDefault(); e.stopPropagation(); setSlashSearch(null); + + const minLevel = levelOffset || 0; + if (localContent === '' && Math.max(0, block.level || 0) > minLevel) { + flushDebounce(localContent); + onIndentBlockRef.current(block.id, -1); + return; + } + flushDebounce(localContent); onCreateBlockBelowRef.current(block.id, localContent); return; -- 2.54.0 From 2ecf71cc42bfa7980c9f16c23233e854f068f2c7 Mon Sep 17 00:00:00 2001 From: Sandro Date: Tue, 27 Oct 2026 17:33:20 +0100 Subject: [PATCH 14/30] feat(lists): use unified modal for creating new shopping lists --- .../src/features/lists/SharedListsView.tsx | 81 ++++++++++++------- 1 file changed, 51 insertions(+), 30 deletions(-) diff --git a/frontend/src/features/lists/SharedListsView.tsx b/frontend/src/features/lists/SharedListsView.tsx index 49f9484..da5bb75 100644 --- a/frontend/src/features/lists/SharedListsView.tsx +++ b/frontend/src/features/lists/SharedListsView.tsx @@ -8,6 +8,7 @@ import { Sparkles, X, Hash, + ShoppingCart, } from 'lucide-react'; import { ListItem, SharedList, ListShare, Team, User, WSMessage } from '../../lib/types'; import { api, buildWebSocketURL, triggerHaptic, createRealtimeConnection, RealtimeTransport } from '../../lib/api'; @@ -953,41 +954,61 @@ export const SharedListsView: React.FC = ({ onRefreshGloba
{showNewListModal && ( -
-
- {['🛒', '🏔️', '🍕', '🎉', '🧰', '🎁'].map((ic) => ( +
+
+
+

+ + Neue Einkaufsliste erstellen +

- ))} +
+ + +
+ +
+ setNewListIcon(e.target.value)} + maxLength={2} + className="w-10 h-8 text-center input-field text-sm" + title="Icon (Emoji)" + /> + setNewListTitle(e.target.value)} + placeholder="z.B. Supermarkt, Baumarkt, Drogerie" + autoFocus + required + className="flex-1 h-8 input-field px-2.5 text-xs" + /> +
+
+ +
+ + +
+
- setNewListTitle(e.target.value)} - placeholder="Titel der Liste (z. B. Baumarkt, Drogerie, Camping)…" - className="flex-1 h-9 px-3 input-field text-sm" - autoFocus - /> - - +
)} {/* Active Shared List View */} -- 2.54.0 From e7eb94be4506fad092ae5e30f0c40b3e4f308a06 Mon Sep 17 00:00:00 2001 From: Sandro Date: Tue, 27 Oct 2026 17:35:47 +0100 Subject: [PATCH 15/30] fix(tasks): allow collaborative editor sharing in task lists --- frontend/src/components/UnifiedShareModal.tsx | 33 +++++++++++++++---- frontend/src/features/triage/TasksView.tsx | 1 + 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/frontend/src/components/UnifiedShareModal.tsx b/frontend/src/components/UnifiedShareModal.tsx index 009f00e..cef88c8 100644 --- a/frontend/src/components/UnifiedShareModal.tsx +++ b/frontend/src/components/UnifiedShareModal.tsx @@ -24,6 +24,7 @@ export interface UnifiedShareModalProps { teams: Team[]; collaborators: User[]; allowRoleSelection?: boolean; // Default true (editor vs viewer) + fixedPermission?: 'editor' | 'viewer'; // When allowRoleSelection is false, force fixedPermission (default 'viewer' for documents, 'editor' for tasks/lists) onAddShare: (targetType: 'team' | 'user', targetId: string, permission: 'editor' | 'viewer') => Promise; onRemoveShare: (shareId: string) => Promise; } @@ -40,14 +41,16 @@ export const UnifiedShareModal: React.FC = ({ teams, collaborators, allowRoleSelection = true, + fixedPermission, onAddShare, onRemoveShare, }) => { + const effectiveFixedPerm = fixedPermission || 'viewer'; const [shareType, setShareType] = useState<'team' | 'user'>('team'); const [selectedTeamId, setSelectedTeamId] = useState(''); const [selectedUser, setSelectedUser] = useState(null); const [selectedPermission, setSelectedPermission] = useState<'editor' | 'viewer'>( - allowRoleSelection ? 'editor' : 'viewer' + allowRoleSelection ? 'editor' : effectiveFixedPerm ); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); @@ -65,7 +68,7 @@ export const UnifiedShareModal: React.FC = ({ setLoading(true); try { - const permToUse = allowRoleSelection ? selectedPermission : 'viewer'; + const permToUse = allowRoleSelection ? selectedPermission : effectiveFixedPerm; await onAddShare(shareType, targetId, permToUse); setSelectedTeamId(''); setSelectedUser(null); @@ -211,8 +214,17 @@ export const UnifiedShareModal: React.FC = ({ ) : ( - - Nur Lesen + {effectiveFixedPerm === 'editor' ? ( + <> + + Bearbeiten + + ) : ( + <> + + Nur Lesen + + )} )}
@@ -240,8 +252,17 @@ export const UnifiedShareModal: React.FC = ({ ) : ( - - Nur Lesen + {effectiveFixedPerm === 'editor' ? ( + <> + + Bearbeiten + + ) : ( + <> + + Nur Lesen + + )} )}
diff --git a/frontend/src/features/triage/TasksView.tsx b/frontend/src/features/triage/TasksView.tsx index 3c057e4..99473f9 100644 --- a/frontend/src/features/triage/TasksView.tsx +++ b/frontend/src/features/triage/TasksView.tsx @@ -1978,6 +1978,7 @@ export const TasksView: React.FC = ({ teams={safeTeams} collaborators={safeCollaborators} allowRoleSelection={false} + fixedPermission="editor" onAddShare={async (targetType, targetId) => { await api.addTaskListShare(sharingTaskList.id, { targetType, -- 2.54.0 From 122ebccf8d310ae0ae677db8c5a9919fb1bac49b Mon Sep 17 00:00:00 2001 From: Sandro Date: Tue, 27 Oct 2026 17:41:25 +0100 Subject: [PATCH 16/30] refactor(outliner): make bullet point the drag handle and remove separate grip icon --- .../src/features/workspace/OutlinerBlock.tsx | 27 ++++++++----------- 1 file changed, 11 insertions(+), 16 deletions(-) diff --git a/frontend/src/features/workspace/OutlinerBlock.tsx b/frontend/src/features/workspace/OutlinerBlock.tsx index 612d85b..928d0c8 100644 --- a/frontend/src/features/workspace/OutlinerBlock.tsx +++ b/frontend/src/features/workspace/OutlinerBlock.tsx @@ -4,7 +4,6 @@ import { ChevronDown, Check, X, - GripVertical, Code, Heading1, Heading2, @@ -444,20 +443,8 @@ export const OutlinerBlock: React.FC = ({ /> ))} - {/* Bullet Controls: Drag Handle, Collapse Toggle, Bullet / Checkbox */} + {/* Bullet Controls: Collapse Toggle & Bullet (Drag-Handle & Zoom) / Checkbox */}
- {canEdit && ( - - )} - {hasChildren ? ( )} {cat && !group.label && ( @@ -1274,8 +1331,45 @@ export const SharedListsView: React.FC = ({ onRefreshGloba {displayContent} - {item.quantity && ( - ({item.quantity}) + {editingQtyItemId === item.id ? ( +
e.stopPropagation()}> + setEditingQtyValue(e.target.value.replace(/[^0-9.,xX]/g, ''))} + onKeyDown={async (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + await handleSaveQuantity(item.id, editingQtyValue); + } else if (e.key === 'Escape') { + setEditingQtyItemId(null); + } + }} + onBlur={async () => { + await handleSaveQuantity(item.id, editingQtyValue); + }} + className="w-16 h-6 px-1.5 text-xs input-field font-semibold text-accent text-center" + placeholder="Menge" + /> +
+ ) : ( + )}
diff --git a/frontend/src/features/triage/TaskDetailModal.tsx b/frontend/src/features/triage/TaskDetailModal.tsx index 7e1b3ef..b176250 100644 --- a/frontend/src/features/triage/TaskDetailModal.tsx +++ b/frontend/src/features/triage/TaskDetailModal.tsx @@ -18,6 +18,7 @@ import { } from 'lucide-react'; import { Thought, SubTask, TaskList, KanbanColumn } from '../../lib/types'; import { triggerHaptic } from '../../lib/api'; +import { MarkdownViewer } from '../../lib/markdown'; interface TaskDetailModalProps { task: Thought | null; @@ -36,6 +37,7 @@ interface TaskDetailModalProps { onAddSubtask: (task: Thought, title: string) => Promise; onDeleteSubtask: (task: Thought, subtaskId: string) => Promise; onConvertToDoc: (task: Thought) => void; + onOpenDocument?: (docId: string, blockId?: string) => void; onDeleteTask: (taskId: string) => void; } @@ -56,6 +58,7 @@ export const TaskDetailModal: React.FC = ({ onAddSubtask, onDeleteSubtask, onConvertToDoc, + onOpenDocument, onDeleteTask, }) => { if (!isOpen || !task) return null; @@ -202,18 +205,35 @@ export const TaskDetailModal: React.FC = ({
- + {(task.convertedType === 'document_block' || task.docId) ? ( + + ) : ( + + )}
) : ( + )} + {/* Kanban status selector pill (Desktop only - on mobile done via TaskDetailModal) */} setEmojiSearch(e.target.value)} + placeholder="Icon suchen oder Emoji eingeben…" + className="input-field w-full pl-7 pr-2 py-1 text-xs" + /> +
+ {emojiSearch.trim() && ( - ))} + )} +
+ {filteredEmojis.map((item) => ( + + ))} + {filteredEmojis.length === 0 && ( +
+ Kein passendes Standard-Icon +
+ )} +
)}
- { + titleInputRef.current = el; + if (el) { + el.style.height = 'auto'; + el.style.height = `${el.scrollHeight}px`; + } + }} + rows={1} value={title} readOnly={!canEdit} autoFocus={autoFocusTitle} - onChange={handleTitleChange} + onChange={(e) => { + handleTitleChange(e); + e.target.style.height = 'auto'; + e.target.style.height = `${e.target.scrollHeight}px`; + }} onBlur={handleTitleBlur} placeholder="Titel der Notiz…" - className="flex-1 min-w-0 text-xl sm:text-2xl font-bold text-main bg-transparent border-0 focus:outline-none focus:ring-0 tracking-tight" + className="flex-1 min-w-0 text-xl sm:text-2xl font-bold text-main bg-transparent border-0 focus:outline-none focus:ring-0 tracking-tight resize-none leading-snug whitespace-pre-wrap break-words [overflow-wrap:anywhere]" />
diff --git a/frontend/src/features/workspace/OutlinerBlock.tsx b/frontend/src/features/workspace/OutlinerBlock.tsx index 4351002..160c256 100644 --- a/frontend/src/features/workspace/OutlinerBlock.tsx +++ b/frontend/src/features/workspace/OutlinerBlock.tsx @@ -3,7 +3,10 @@ import { ChevronRight, ChevronDown, Check, + CheckSquare, X, + Trash2, + MoreHorizontal, Code, Heading1, Heading2, @@ -19,6 +22,8 @@ import { } from 'lucide-react'; import { DocumentBlock, BlockType } from '../../lib/types'; import { LinkModal } from './LinkModal'; +import { renderInlineMarkdown } from '../../lib/markdown'; +import { api } from '../../lib/api'; export interface SlashCommand { type: BlockType | 'link'; @@ -50,7 +55,7 @@ export interface OutlinerBlockProps { onZoomBlock?: (blockId: string) => void; onUpdateBlock: (updated: DocumentBlock, immediate?: boolean) => void; onCreateBlockBelow: (afterBlockId: string, currentContent: string) => void; - onDeleteBlock: (blockId: string) => void; + onDeleteBlock: (blockId: string, deleteSubtree?: boolean) => void; onIndentBlock: (blockId: string, delta: 1 | -1) => void; onNavigateFocus: (blockId: string, direction: 'up' | 'down') => void; onTagClick?: (tag: string) => void; @@ -91,6 +96,7 @@ export const OutlinerBlock: React.FC = ({ const [slashSearch, setSlashSearch] = useState(null); const [slashIndex, setSlashIndex] = useState(0); const [showLinkModal, setShowLinkModal] = useState(false); + const [showTodoMenu, setShowTodoMenu] = useState(false); const textareaRef = useRef(null); const debounceTimerRef = useRef(null); @@ -475,7 +481,34 @@ export const OutlinerBlock: React.FC = ({ )} - {block.type === 'todo' ? ( + {/* Bullet handle for dragging and zoom focus */} + + + {/* If To-Do block: checkbox alongside bullet */} + {block.type === 'todo' && ( - ) : ( - + + Erweitert + )}
@@ -571,6 +589,23 @@ export const OutlinerBlock: React.FC = ({ ) : (
+ {!isFocused && localContent && ( +
+ {renderInlineMarkdown(localContent)} +
+ )}