package api import ( "encoding/json" "errors" "io/fs" "log/slog" "net/http" "strings" "time" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" "github.com/go-chi/cors" "github.com/sandrodach/mindsift/internal/auth" "github.com/sandrodach/mindsift/internal/collab" "github.com/sandrodach/mindsift/internal/config" "github.com/sandrodach/mindsift/internal/domain" "github.com/sandrodach/mindsift/internal/storage" "github.com/sandrodach/mindsift/internal/ws" ) type Server struct { cfg *config.Config store *storage.Store wsHub *ws.Hub staticFS fs.FS leases *collab.LeaseManager // block leases and open sessions of notes notifier *collab.Notifier // wakes long-poll requests on note changes demo demoState // periodic reset in demo mode (demo.go) } func NewServer(cfg *config.Config, store *storage.Store, wsHub *ws.Hub, staticFS fs.FS) *Server { srv := &Server{ cfg: cfg, store: store, wsHub: wsHub, staticFS: staticFS, leases: collab.NewLeaseManager(nil), notifier: collab.NewNotifier(), } wsHub.SetRoomAuthorizer(srv.canJoinRoom) // Next occurrences of repeating tasks appear live on every connected client store.OnTaskCreated = func(th *domain.Thought) { srv.wsHub.BroadcastToUsers(srv.taskAudience(th), "TASK_CREATED", th) } go srv.startAlarmTicker() go srv.startCompaction() if cfg.DemoMode && cfg.DemoResetMinutes > 0 { go srv.startDemoResetTicker() } return srv } // canJoinRoom allows realtime rooms only to users with read access: "doc:" for notes, otherwise a shared list ID. func (s *Server) canJoinRoom(userID, room string) bool { if docID, ok := strings.CutPrefix(room, "doc:"); ok { return s.store.DocRole(userID, docID) != "" } _, err := s.store.GetListByID(userID, room) return err == nil } // taskAudience returns everyone who can see a task: its owner plus all users of its task list. func (s *Server) taskAudience(th *domain.Thought) []string { if th == nil { return nil } return append(s.store.GetTaskListUserIDs(th.TaskListID), th.UserID) } func (s *Server) Router() http.Handler { r := chi.NewRouter() r.Use(middleware.RealIP) r.Use(s.demoResetGate) r.Use(middleware.Recoverer) r.Use(s.apiLoggingMiddleware) r.Use(cors.Handler(cors.Options{ AllowedOrigins: []string{"*"}, AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"}, AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-API-Key"}, ExposedHeaders: []string{"Link"}, AllowCredentials: true, MaxAge: 300, })) // Public Health, Setup & Auth Routes r.Get("/api/v1/health", s.handleHealth) r.Get("/api/v1/auth/status", s.handleAuthStatus) r.Get("/api/v1/legal/{type}", s.handleLegalText) r.Post("/api/v1/auth/setup", s.handleFirstBootSetup) r.Post("/api/v1/auth/register", s.handleRegister) r.Post("/api/v1/auth/login", s.handleLogin) r.Post("/api/v1/auth/quickstart", s.handleQuickStart) r.Post("/api/v1/auth/forgot-password", s.handleForgotPassword) r.Post("/api/v1/auth/reset-password", s.handleResetPassword) r.Post("/api/v1/webhooks/ingest", s.handleWebhookIngest) 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/events", s.handleSSE) // Authenticated API Routes r.Group(func(r chi.Router) { r.Use(s.authMiddleware) r.Get("/api/v1/auth/me", s.handleMe) r.Post("/api/v1/events/rooms", s.handleSSERooms) r.Post("/api/v1/auth/logout", s.handleLogout) r.Post("/api/v1/auth/revoke-all", s.handleRevokeAllSessions) r.Patch("/api/v1/auth/settings", s.handleUpdateUserSettings) r.Post("/api/v1/auth/alarms/test", s.handleTestUserAlarm) r.Get("/api/v1/users/directory", s.handleGetUsersDirectory) r.Get("/api/v1/collaborators", s.handleGetCollaborators) r.Get("/api/v1/stats", s.handleStats) r.Get("/api/v1/search", s.handleSearch) r.Get("/api/v1/export", s.handleExport) r.Get("/api/v1/export/markdown", s.handleExportMarkdown) r.Post("/api/v1/import/mindsift", s.handleImportMindSift) r.Delete("/api/v1/user/data", s.handlePurgeOwnData) r.Delete("/api/v1/user/account", s.handleDeleteOwnAccount) r.Post("/api/v1/import/obsidian", s.handleImportObsidian) r.Get("/api/v1/themes", s.handleGetThemes) r.Post("/api/v1/themes", s.handleSaveTheme) r.Delete("/api/v1/themes/{id}", s.handleDeleteTheme) // Instant Capture & Thoughts r.Post("/api/v1/inbox", s.handleCaptureInbox) r.Get("/api/v1/thoughts", s.handleGetThoughts) r.Get("/api/v1/thoughts/{id}", s.handleGetThought) r.Patch("/api/v1/thoughts/{id}", s.handleUpdateThought) r.Delete("/api/v1/thoughts/{id}", s.handleDeleteThought) r.Delete("/api/v1/thoughts/discarded/purge", s.handlePurgeDiscarded) // Tinder-Style Triage & Tasks r.Get("/api/v1/triage/deck", s.handleGetTriageDeck) r.Patch("/api/v1/triage/{id}", s.handleTriageAction) r.Get("/api/v1/tasks", s.handleGetTasks) r.Post("/api/v1/tasks/{id}/unlink", s.handleUnlinkEnhancedTask) r.Post("/api/v1/tasks/reorder", s.handleReorderTasks) r.Post("/api/v1/task-lists/{id}/tasks/reorder", s.handleReorderTasks) // Task Lists & Shares r.Get("/api/v1/task-lists", s.handleGetTaskLists) r.Post("/api/v1/task-lists", s.handleCreateTaskList) r.Put("/api/v1/task-lists/{id}", s.handleUpdateTaskList) r.Delete("/api/v1/task-lists/{id}", s.handleDeleteTaskList) r.Get("/api/v1/task-lists/{id}/shares", s.handleGetTaskListShares) r.Post("/api/v1/task-lists/{id}/shares", s.handleCreateTaskListShare) r.Delete("/api/v1/task-lists/{id}/shares/{shareId}", s.handleDeleteTaskListShare) // Workspace Documents & Folders r.Get("/api/v1/folders", s.handleGetFolders) r.Post("/api/v1/folders", s.handleCreateFolder) r.Post("/api/v1/folders/move", s.handleMoveFolder) r.Delete("/api/v1/folders", s.handleDeleteFolder) r.Get("/api/v1/workspace/folders/shared", s.handleGetSharedFolders) r.Post("/api/v1/workspace/folders/shared", s.handleCreateSharedFolder) r.Get("/api/v1/workspace/folders/shared/{id}", s.handleGetSharedFolderByID) r.Delete("/api/v1/workspace/folders/shared/{id}", s.handleDeleteSharedFolder) r.Put("/api/v1/workspace/folders/shared/{id}/mount", s.handleUpdateFolderMount) r.Get("/api/v1/workspace/folders/shared/{id}/shares", s.handleGetFolderShares) r.Post("/api/v1/workspace/folders/shared/{id}/shares", s.handleCreateFolderShare) r.Delete("/api/v1/workspace/folders/shared/{id}/shares/{shareId}", s.handleDeleteFolderShare) // Workspace Tags & Pages r.Get("/api/v1/workspace/tags", s.handleGetWorkspaceTags) r.Get("/api/v1/workspace/tags/{tag}", s.handleGetTagDetails) r.Put("/api/v1/workspace/tags/{tag}/note", s.handleUpdateTagPageNote) r.Get("/api/v1/documents", s.handleGetDocuments) r.Post("/api/v1/documents", s.handleCreateDocument) r.Get("/api/v1/documents/{id}", s.handleGetDocument) r.Put("/api/v1/documents/{id}", s.handleUpdateDocument) r.Put("/api/v1/documents/{id}/move", s.handleMoveDocument) r.Delete("/api/v1/documents/{id}", s.handleDeleteDocument) r.Get("/api/v1/documents/trash", s.handleGetTrash) r.Delete("/api/v1/documents/trash", s.handleEmptyTrash) r.Post("/api/v1/documents/{id}/restore", s.handleRestoreDocument) r.Get("/api/v1/documents/{id}/backlinks", s.handleGetBacklinks) r.Delete("/api/v1/documents/{id}/permanent", s.handlePurgeDocument) r.Post("/api/v1/documents/{id}/blocks/{blockId}/enhanced-task", s.handleCreateEnhancedTask) // Collaborative note editing (operation log) r.Get("/api/v1/docs/{id}/snapshot", s.handleDocSnapshot) r.Post("/api/v1/docs/{id}/sync", s.handleDocSync) r.Get("/api/v1/docs/{id}/changes", s.handleDocChanges) r.Post("/api/v1/docs/{id}/leave", s.handleDocLeave) // Document Sharing & Collaboration r.Get("/api/v1/documents/{id}/shares", s.handleGetDocShares) r.Post("/api/v1/documents/{id}/shares", s.handleCreateDocShare) r.Delete("/api/v1/documents/{id}/shares/{userId}", s.handleDeleteDocShare) // Shared Lists & Realtime Collaboration r.Get("/api/v1/lists", s.handleGetAllLists) r.Post("/api/v1/lists", s.handleCreateList) r.Get("/api/v1/lists/{id}", s.handleGetListByID) r.Delete("/api/v1/lists/{id}", s.handleDeleteList) r.Get("/api/v1/lists/{id}/shares", s.handleGetListShares) r.Post("/api/v1/lists/{id}/shares", s.handleCreateListShare) r.Delete("/api/v1/lists/{id}/shares/{shareId}", s.handleDeleteListShare) r.Post("/api/v1/lists/{id}/items", s.handleCreateListItem) r.Patch("/api/v1/lists/{id}/items/{itemId}", s.handleToggleListItem) r.Put("/api/v1/lists/{id}/items/{itemId}", s.handleUpdateListItem) r.Delete("/api/v1/lists/{id}/items/{itemId}", s.handleDeleteListItem) r.Post("/api/v1/lists/{id}/items/reorder", s.handleReorderListItems) r.Put("/api/v1/lists/{id}/items/reorder", s.handleReorderListItems) r.Post("/api/v1/lists/{id}/clear-completed", s.handleClearCompletedItems) // Groups (user-managed; stored as teams) r.Get("/api/v1/teams", s.handleGetUserTeams) r.Post("/api/v1/teams", s.handleCreateTeamDirect) r.Put("/api/v1/teams/{id}", s.handleUpdateTeam) r.Delete("/api/v1/teams/{id}", s.handleDeleteTeam) r.Post("/api/v1/teams/{id}/members", s.handleAddTeamMember) r.Put("/api/v1/teams/{id}/members/{userId}", s.handleUpdateTeamMemberRole) r.Delete("/api/v1/teams/{id}/members/{userId}", s.handleRemoveTeamMember) // API Keys Management r.Get("/api/v1/keys", s.handleGetAPIKeys) r.Post("/api/v1/keys", s.handleCreateAPIKey) r.Delete("/api/v1/keys/{id}", s.handleDeleteAPIKey) // Staff Routes (Server-Admin) r.Group(func(r chi.Router) { r.Use(s.staffMiddleware) r.Get("/api/v1/admin/users", s.handleAdminGetUsers) r.Post("/api/v1/admin/users", s.handleAdminCreateUser) r.Patch("/api/v1/admin/users/{id}", s.handleAdminUpdateUser) r.Put("/api/v1/admin/users/{id}/password", s.handleAdminResetPassword) r.Delete("/api/v1/admin/users/{id}", s.handleAdminDeleteUser) r.Get("/api/v1/admin/invites", s.handleAdminGetInvites) r.Post("/api/v1/admin/invites", s.handleAdminCreateInvite) r.Delete("/api/v1/admin/invites/{id}", s.handleAdminDeleteInvite) }) // Server-Admin Strictly Protected Routes r.Group(func(r chi.Router) { r.Use(s.adminMiddleware) r.Get("/api/v1/admin/settings", s.handleAdminGetSettings) r.Put("/api/v1/admin/settings", s.handleAdminUpdateSettings) r.Get("/api/v1/admin/settings/sharing-impact", s.handleAdminSharingImpact) // Organizations decide who can see whom; only server admins manage them r.Get("/api/v1/admin/organizations", s.handleAdminGetOrganizations) r.Post("/api/v1/admin/organizations", s.handleAdminCreateOrganization) r.Put("/api/v1/admin/organizations/{id}", s.handleAdminRenameOrganization) r.Delete("/api/v1/admin/organizations/{id}", s.handleAdminDeleteOrganization) r.Put("/api/v1/admin/users/{id}/organization", s.handleAdminSetUserOrganization) r.Post("/api/v1/admin/smtp/test", s.handleAdminTestSMTP) }) }) // Embedded Frontend SPA Handler if s.staticFS != nil { fileServer := http.FileServer(http.FS(s.staticFS)) r.Get("/*", func(w http.ResponseWriter, req *http.Request) { path := strings.TrimPrefix(req.URL.Path, "/") if path == "" { path = "index.html" } if _, err := fs.Stat(s.staticFS, path); err != nil { if strings.HasPrefix(path, "assets/") || strings.HasSuffix(path, ".js") || strings.HasSuffix(path, ".css") { http.NotFound(w, req) return } req.URL.Path = "/" path = "index.html" } if path == "index.html" || path == "sw.js" || path == "manifest.json" { w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate") w.Header().Set("Pragma", "no-cache") w.Header().Set("Expires", "0") } if path == "index.html" { if data, err := fs.ReadFile(s.staticFS, "index.html"); err == nil { scheme := "http" if req.TLS != nil || req.Header.Get("X-Forwarded-Proto") == "https" { scheme = "https" } host := req.Header.Get("X-Forwarded-Host") if host == "" { host = req.Host } if host != "" { origin := scheme + "://" + host htmlStr := string(data) htmlStr = strings.ReplaceAll(htmlStr, `content="/og-image.png"`, `content="`+origin+`/og-image.png"`) htmlStr = strings.ReplaceAll(htmlStr, `property="og:url" content="/"`, `property="og:url" content="`+origin+`/"`) data = []byte(htmlStr) } w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(http.StatusOK) _, _ = w.Write(data) return } } fileServer.ServeHTTP(w, req) }) } return r } // --- HELPERS & MIDDLEWARE --- func writeJSON(w http.ResponseWriter, status int, data interface{}) { w.Header().Set("Content-Type", "application/json; charset=utf-8") w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(data) } func writeError(w http.ResponseWriter, status int, message string) { writeJSON(w, status, map[string]string{"error": message}) } // writeStoreError maps read-only task list violations to 403 and everything else to the given fallback status. func writeStoreError(w http.ResponseWriter, fallbackStatus int, err error) { if errors.Is(err, storage.ErrTaskListReadOnly) || errors.Is(err, storage.ErrAssigneeNoAccess) { writeError(w, http.StatusForbidden, err.Error()) return } writeError(w, fallbackStatus, err.Error()) } func (s *Server) apiLoggingMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Only log API requests (/api/...) if !strings.HasPrefix(r.URL.Path, "/api") { next.ServeHTTP(w, r) return } start := time.Now() ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor) next.ServeHTTP(ww, r) duration := time.Since(start) durationMs := float64(duration.Microseconds()) / 1000.0 status := ww.Status() if status == 0 { status = http.StatusOK } method := r.Method path := r.URL.Path if status >= 500 { slog.Error("HTTP request failed", "method", method, "path", path, "status", status, "duration_ms", durationMs, ) } else if status >= 400 { slog.Warn("HTTP request warning", "method", method, "path", path, "status", status, "duration_ms", durationMs, ) } else { slog.Debug("HTTP request completed", "method", method, "path", path, "status", status, "duration_ms", durationMs, ) } }) } // authenticateToken resolves a session JWT or an API key (mindsift_sec_…) to an active user. JWTs are // checked against the revocation list and token_version; viaAPIKey tells the caller which it was. func (s *Server) authenticateToken(token string) (user *domain.User, viaAPIKey bool) { token = strings.TrimSpace(token) if token == "" { return nil, false } if strings.HasPrefix(token, "mindsift_sec_") { u, err := s.store.ValidateAPIKeyAndGetUser(token) if err != nil || u == nil || !u.IsActive { return nil, true } return u, true } claims, err := auth.ValidateToken(s.cfg.JWTSecret, token) if err != nil || claims == nil || s.store.IsTokenRevoked(token) { return nil, false } u, err := s.store.GetUserByID(claims.UserID) if err != nil || u == nil || !u.IsActive { return nil, false } if claims.TokenVersion != 0 && claims.TokenVersion != u.TokenVersion { return nil, false } return u, false } // apiKeyAllowed: until API keys get scopes, a key has exactly one right – delivering data into MindSift // (POST /api/v1/inbox; /api/v1/webhooks/ingest authenticates on its own). Everything else needs a login. func apiKeyAllowed(r *http.Request) bool { return r.Method == http.MethodPost && r.URL.Path == "/api/v1/inbox" } func (s *Server) authMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { token := "" if authHeader := r.Header.Get("Authorization"); strings.HasPrefix(authHeader, "Bearer ") { token = strings.TrimPrefix(authHeader, "Bearer ") } else { token = r.Header.Get("X-API-Key") } user, viaAPIKey := s.authenticateToken(token) if user == nil { writeError(w, http.StatusUnauthorized, "Nicht autorisiert – bitte anmelden") return } if viaAPIKey && !apiKeyAllowed(r) { writeError(w, http.StatusForbidden, "API-Keys dürfen nur Daten einliefern (POST /api/v1/inbox oder /api/v1/webhooks/ingest)") return } next.ServeHTTP(w, r.WithContext(auth.WithUserID(r.Context(), user.ID))) }) } func (s *Server) staffMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { userID := auth.GetUserID(r.Context()) if userID == "" { writeError(w, http.StatusUnauthorized, "Nicht autorisiert") return } user, err := s.store.GetUserByID(userID) if err != nil || user == nil || user.Role != "admin" { writeError(w, http.StatusForbidden, "Admin-Berechtigung erforderlich") return } next.ServeHTTP(w, r) }) } func (s *Server) adminMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { userID := auth.GetUserID(r.Context()) if userID == "" { writeError(w, http.StatusUnauthorized, "Nicht autorisiert") return } user, err := s.store.GetUserByID(userID) if err != nil || user == nil || user.Role != "admin" { writeError(w, http.StatusForbidden, "Server-Administrator-Rechte erforderlich") return } next.ServeHTTP(w, r) }) }