package api import ( "encoding/json" "fmt" "io/fs" "net/http" "strings" "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/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 } func NewServer(cfg *config.Config, store *storage.Store, wsHub *ws.Hub, staticFS fs.FS) *Server { return &Server{ cfg: cfg, store: store, wsHub: wsHub, staticFS: staticFS, } } func (s *Server) Router() http.Handler { r := chi.NewRouter() r.Use(middleware.RealIP) r.Use(middleware.Recoverer) 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 & Webhook Routes r.Get("/api/v1/health", s.handleHealth) 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/webhooks/ingest", s.handleWebhookIngest) r.Get("/api/v1/ws/spaces/{spaceId}", s.handleWebSocket) // Authenticated API Routes r.Group(func(r chi.Router) { r.Use(s.authMiddleware) r.Get("/api/v1/auth/me", s.handleMe) r.Get("/api/v1/stats", s.handleStats) r.Get("/api/v1/search", s.handleSearch) r.Get("/api/v1/export", s.handleExport) // Instant Capture & Thoughts r.Post("/api/v1/inbox", s.handleCaptureInbox) r.Get("/api/v1/thoughts", s.handleGetThoughts) 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) // Workspace Documents 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.Delete("/api/v1/documents/{id}", s.handleDeleteDocument) // Shared Spaces & Realtime Lists r.Get("/api/v1/spaces", s.handleGetSpaces) r.Post("/api/v1/spaces", s.handleCreateSpace) r.Post("/api/v1/spaces/join", s.handleJoinSpace) r.Get("/api/v1/spaces/{id}/lists", s.handleGetSpaceLists) r.Post("/api/v1/spaces/{id}/lists", s.handleCreateList) r.Get("/api/v1/lists", s.handleGetAllLists) r.Delete("/api/v1/lists/{id}", s.handleDeleteList) r.Post("/api/v1/lists/{id}/items", s.handleCreateListItem) r.Patch("/api/v1/lists/{id}/items/{itemId}", s.handleToggleListItem) r.Delete("/api/v1/lists/{id}/items/{itemId}", s.handleDeleteListItem) r.Post("/api/v1/lists/{id}/clear-completed", s.handleClearCompletedItems) // 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) }) // 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 { // SPA client-side routing fallback to index.html req.URL.Path = "/" } 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.WriteHeader(status) _ = json.NewEncoder(w).Encode(data) } func writeError(w http.ResponseWriter, status int, message string) { writeJSON(w, status, map[string]string{"error": message}) } func (s *Server) authMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // 1. Check Authorization Bearer Token authHeader := r.Header.Get("Authorization") if strings.HasPrefix(authHeader, "Bearer ") { tokenStr := strings.TrimPrefix(authHeader, "Bearer ") if strings.HasPrefix(tokenStr, "mindsift_sec_") { user, err := s.store.ValidateAPIKeyAndGetUser(tokenStr) if err == nil && user != nil { next.ServeHTTP(w, r.WithContext(auth.WithUserID(r.Context(), user.ID))) return } } claims, err := auth.ValidateToken(s.cfg.JWTSecret, tokenStr) if err == nil && claims != nil { userID := claims.UserID if _, userErr := s.store.GetUserByID(userID); userErr != nil { if defUser, defErr := s.store.EnsureDefaultUser(); defErr == nil && defUser != nil { userID = defUser.ID } } next.ServeHTTP(w, r.WithContext(auth.WithUserID(r.Context(), userID))) return } } // 2. Check X-API-Key Header apiKey := r.Header.Get("X-API-Key") if apiKey != "" { user, err := s.store.ValidateAPIKeyAndGetUser(apiKey) if err == nil && user != nil { next.ServeHTTP(w, r.WithContext(auth.WithUserID(r.Context(), user.ID))) return } } writeError(w, http.StatusUnauthorized, "Nicht autorisiert – bitte einloggen") }) } // --- HANDLERS --- func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]interface{}{ "status": "ok", "service": "mindsift", "time": domain.NowISO(), }) } func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) { if !s.cfg.AllowRegistration { count, _ := s.store.CountUsers() if count > 0 { writeError(w, http.StatusForbidden, "Registrierung ist auf dieser Instanz deaktiviert") return } } var req struct { Email string `json:"email"` Password string `json:"password"` DisplayName string `json:"displayName"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "Ungültige Anfrage") return } if strings.TrimSpace(req.Email) == "" || len(req.Password) < 4 { writeError(w, http.StatusBadRequest, "E-Mail und Passwort (mind. 4 Zeichen) erforderlich") return } if strings.TrimSpace(req.DisplayName) == "" { req.DisplayName = strings.Split(req.Email, "@")[0] } hash, err := auth.HashPassword(req.Password) if err != nil { writeError(w, http.StatusInternalServerError, "Fehler beim Passwort-Hashing") return } user, err := s.store.CreateUser(req.Email, hash, req.DisplayName) if err != nil { writeError(w, http.StatusConflict, "E-Mail-Adresse existiert bereits") return } _ = s.store.SeedStarterContent(user) token, err := auth.GenerateToken(s.cfg.JWTSecret, user.ID, user.Email, user.DisplayName) if err != nil { writeError(w, http.StatusInternalServerError, "Token konnte nicht erstellt werden") return } writeJSON(w, http.StatusCreated, map[string]interface{}{ "token": token, "user": user, }) } func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { var req struct { Email string `json:"email"` Password string `json:"password"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "Ungültige Anfrage") return } user, err := s.store.GetUserByEmail(req.Email) if err != nil || !auth.CheckPassword(req.Password, user.PasswordHash) { writeError(w, http.StatusUnauthorized, "E-Mail oder Passwort ungültig") return } token, err := auth.GenerateToken(s.cfg.JWTSecret, user.ID, user.Email, user.DisplayName) if err != nil { writeError(w, http.StatusInternalServerError, "Token konnte nicht erstellt werden") return } writeJSON(w, http.StatusOK, map[string]interface{}{ "token": token, "user": user, }) } func (s *Server) handleQuickStart(w http.ResponseWriter, r *http.Request) { user, err := s.store.EnsureDefaultUser() if err != nil { writeError(w, http.StatusInternalServerError, "Standard-Profil konnte nicht geladen werden") return } token, err := auth.GenerateToken(s.cfg.JWTSecret, user.ID, user.Email, user.DisplayName) if err != nil { writeError(w, http.StatusInternalServerError, "Token-Fehler") return } writeJSON(w, http.StatusOK, map[string]interface{}{ "token": token, "user": user, }) } func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) { userID := auth.GetUserID(r.Context()) user, err := s.store.GetUserByID(userID) if err != nil { writeError(w, http.StatusNotFound, "Benutzer nicht gefunden") return } stats, _ := s.store.GetDashboardStats(userID) writeJSON(w, http.StatusOK, map[string]interface{}{ "user": user, "stats": stats, }) } func (s *Server) handleStats(w http.ResponseWriter, r *http.Request) { userID := auth.GetUserID(r.Context()) stats, err := s.store.GetDashboardStats(userID) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } writeJSON(w, http.StatusOK, stats) } func (s *Server) handleCaptureInbox(w http.ResponseWriter, r *http.Request) { userID := auth.GetUserID(r.Context()) var req struct { Content string `json:"content"` Priority string `json:"priority"` Source string `json:"source"` DueDate string `json:"dueDate"` DueDate2 string `json:"due_date"` // Compatibility with snake_case README example Target string `json:"target"` // inbox | task | list_item | document TargetListID string `json:"targetListId"` TargetList2 string `json:"target_list_id"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "Ungültiges JSON") return } dueDate := req.DueDate if dueDate == "" { dueDate = req.DueDate2 } targetListID := req.TargetListID if targetListID == "" { targetListID = req.TargetList2 } thought, err := s.store.CreateThought(userID, req.Content, req.Priority, req.Source, dueDate) if err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } target := strings.TrimSpace(req.Target) if target == "task" || target == "document" || (target == "list_item" && targetListID != "") { if converted, convErr := s.store.UpdateThoughtTriage(userID, thought.ID, "keep", target, targetListID, dueDate); convErr == nil && converted != nil { thought = converted if target == "list_item" && targetListID != "" { if lst, lErr := s.store.GetListByID(targetListID); lErr == nil && lst != nil { s.wsHub.Broadcast(lst.SpaceID, lst.ID, "ITEM_CREATED", lst) } } } } writeJSON(w, http.StatusCreated, thought) } func (s *Server) handleGetThoughts(w http.ResponseWriter, r *http.Request) { userID := auth.GetUserID(r.Context()) status := r.URL.Query().Get("status") thoughts, err := s.store.GetThoughts(userID, status, 250) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } writeJSON(w, http.StatusOK, thoughts) } func (s *Server) handleGetTriageDeck(w http.ResponseWriter, r *http.Request) { userID := auth.GetUserID(r.Context()) thoughts, err := s.store.GetThoughts(userID, "inbox", 100) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } writeJSON(w, http.StatusOK, thoughts) } func (s *Server) handleTriageAction(w http.ResponseWriter, r *http.Request) { userID := auth.GetUserID(r.Context()) thoughtID := chi.URLParam(r, "id") var req struct { Action string `json:"action"` // keep | discard | restore ConvertTo string `json:"convertTo"` ConvertTo2 string `json:"convert_to"` TargetListID string `json:"targetListId"` TargetList2 string `json:"target_list_id"` DueDate string `json:"dueDate"` DueDate2 string `json:"due_date"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "Ungültiges JSON") return } convertTo := req.ConvertTo if convertTo == "" { convertTo = req.ConvertTo2 } targetListID := req.TargetListID if targetListID == "" { targetListID = req.TargetList2 } dueDate := req.DueDate if dueDate == "" { dueDate = req.DueDate2 } updated, err := s.store.UpdateThoughtTriage(userID, thoughtID, req.Action, convertTo, targetListID, dueDate) if err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } // If converted to a list item, broadcast via WebSocket to the space if convertTo == "list_item" && targetListID != "" { if lst, err := s.store.GetListByID(targetListID); err == nil && lst != nil { s.wsHub.Broadcast(lst.SpaceID, lst.ID, "ITEM_CREATED", lst) } } writeJSON(w, http.StatusOK, updated) } func (s *Server) handleGetTasks(w http.ResponseWriter, r *http.Request) { userID := auth.GetUserID(r.Context()) tasks, err := s.store.GetTasks(userID) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } writeJSON(w, http.StatusOK, tasks) } func (s *Server) handleUpdateThought(w http.ResponseWriter, r *http.Request) { userID := auth.GetUserID(r.Context()) thoughtID := chi.URLParam(r, "id") var req struct { Content *string `json:"content"` IsCompleted *bool `json:"isCompleted"` DueDate *string `json:"dueDate"` Priority *string `json:"priority"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "Ungültiges JSON") return } updated, err := s.store.UpdateThoughtDetails(userID, thoughtID, req.Content, req.IsCompleted, req.DueDate, req.Priority) if err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } writeJSON(w, http.StatusOK, updated) } func (s *Server) handleDeleteThought(w http.ResponseWriter, r *http.Request) { userID := auth.GetUserID(r.Context()) thoughtID := chi.URLParam(r, "id") if err := s.store.DeleteThought(userID, thoughtID); err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } writeJSON(w, http.StatusOK, map[string]bool{"deleted": true}) } func (s *Server) handlePurgeDiscarded(w http.ResponseWriter, r *http.Request) { userID := auth.GetUserID(r.Context()) count, err := s.store.PurgeDiscardedThoughts(userID) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } writeJSON(w, http.StatusOK, map[string]int64{"purged": count}) } // --- DOCUMENTS --- func (s *Server) handleGetDocuments(w http.ResponseWriter, r *http.Request) { userID := auth.GetUserID(r.Context()) docs, err := s.store.GetDocuments(userID) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } writeJSON(w, http.StatusOK, docs) } func (s *Server) handleCreateDocument(w http.ResponseWriter, r *http.Request) { userID := auth.GetUserID(r.Context()) var req struct { SpaceID string `json:"spaceId"` Title string `json:"title"` Icon string `json:"icon"` Blocks []domain.DocumentBlock `json:"blocks"` Tags []string `json:"tags"` IsPinned bool `json:"isPinned"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "Ungültiges JSON") return } doc, err := s.store.CreateDocument(userID, req.SpaceID, req.Title, req.Icon, req.Blocks, req.Tags, req.IsPinned) if err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } writeJSON(w, http.StatusCreated, doc) } func (s *Server) handleGetDocument(w http.ResponseWriter, r *http.Request) { userID := auth.GetUserID(r.Context()) docID := chi.URLParam(r, "id") doc, err := s.store.GetDocumentByID(userID, docID) if err != nil { writeError(w, http.StatusNotFound, "Dokument nicht gefunden") return } writeJSON(w, http.StatusOK, doc) } func (s *Server) handleUpdateDocument(w http.ResponseWriter, r *http.Request) { userID := auth.GetUserID(r.Context()) docID := chi.URLParam(r, "id") var req struct { Title *string `json:"title"` Icon *string `json:"icon"` Blocks *[]domain.DocumentBlock `json:"blocks"` Tags *[]string `json:"tags"` IsPinned *bool `json:"isPinned"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "Ungültiges JSON") return } doc, err := s.store.UpdateDocument(userID, docID, req.Title, req.Icon, req.Blocks, req.Tags, req.IsPinned) if err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } writeJSON(w, http.StatusOK, doc) } func (s *Server) handleDeleteDocument(w http.ResponseWriter, r *http.Request) { userID := auth.GetUserID(r.Context()) docID := chi.URLParam(r, "id") if err := s.store.DeleteDocument(userID, docID); err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } writeJSON(w, http.StatusOK, map[string]bool{"deleted": true}) } // --- SPACES & REALTIME LISTS --- func (s *Server) handleGetSpaces(w http.ResponseWriter, r *http.Request) { userID := auth.GetUserID(r.Context()) spaces, err := s.store.GetSpacesForUser(userID) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } writeJSON(w, http.StatusOK, spaces) } func (s *Server) handleCreateSpace(w http.ResponseWriter, r *http.Request) { userID := auth.GetUserID(r.Context()) var req struct { Name string `json:"name"` Icon string `json:"icon"` Description string `json:"description"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "Ungültiges JSON") return } sp, err := s.store.CreateSpace(userID, req.Name, req.Icon, req.Description, false) if err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } writeJSON(w, http.StatusCreated, sp) } func (s *Server) handleJoinSpace(w http.ResponseWriter, r *http.Request) { userID := auth.GetUserID(r.Context()) var req struct { InviteCode string `json:"inviteCode"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "Ungültiges JSON") return } sp, err := s.store.JoinSpaceByInviteCode(userID, req.InviteCode) if err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } writeJSON(w, http.StatusOK, sp) } func (s *Server) handleGetSpaceLists(w http.ResponseWriter, r *http.Request) { spaceID := chi.URLParam(r, "id") lists, err := s.store.GetListsForSpace(spaceID) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } writeJSON(w, http.StatusOK, lists) } func (s *Server) handleGetAllLists(w http.ResponseWriter, r *http.Request) { userID := auth.GetUserID(r.Context()) lists, err := s.store.GetAllListsForUser(userID) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } writeJSON(w, http.StatusOK, lists) } func (s *Server) handleCreateList(w http.ResponseWriter, r *http.Request) { spaceID := chi.URLParam(r, "id") var req struct { Title string `json:"title"` Icon string `json:"icon"` ListType string `json:"listType"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "Ungültiges JSON") return } lst, err := s.store.CreateList(spaceID, req.Title, req.Icon, req.ListType) if err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } s.wsHub.Broadcast(spaceID, lst.ID, "LIST_CREATED", lst) writeJSON(w, http.StatusCreated, lst) } func (s *Server) handleDeleteList(w http.ResponseWriter, r *http.Request) { listID := chi.URLParam(r, "id") lst, _ := s.store.GetListByID(listID) if err := s.store.DeleteList(listID); err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } if lst != nil { s.wsHub.Broadcast(lst.SpaceID, listID, "LIST_DELETED", map[string]string{"listId": listID}) } writeJSON(w, http.StatusOK, map[string]bool{"deleted": true}) } func (s *Server) handleCreateListItem(w http.ResponseWriter, r *http.Request) { listID := chi.URLParam(r, "id") var req struct { Content string `json:"content"` Category string `json:"category"` Quantity string `json:"quantity"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "Ungültiges JSON") return } item, err := s.store.CreateListItem(listID, req.Content, req.Category, req.Quantity, 0) if err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } if lst, err := s.store.GetListByID(listID); err == nil && lst != nil { s.wsHub.Broadcast(lst.SpaceID, listID, "ITEM_CREATED", item) } writeJSON(w, http.StatusCreated, item) } func (s *Server) handleToggleListItem(w http.ResponseWriter, r *http.Request) { userID := auth.GetUserID(r.Context()) listID := chi.URLParam(r, "id") itemID := chi.URLParam(r, "itemId") var req struct { IsCompleted bool `json:"isCompleted"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "Ungültiges JSON") return } user, _ := s.store.GetUserByID(userID) userName := "Familie" if user != nil { userName = user.DisplayName } item, err := s.store.ToggleListItem(itemID, req.IsCompleted, userID, userName) if err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } if lst, err := s.store.GetListByID(listID); err == nil && lst != nil { s.wsHub.Broadcast(lst.SpaceID, listID, "ITEM_TOGGLED", item) } writeJSON(w, http.StatusOK, item) } func (s *Server) handleDeleteListItem(w http.ResponseWriter, r *http.Request) { listID := chi.URLParam(r, "id") itemID := chi.URLParam(r, "itemId") _, err := s.store.DeleteListItem(itemID) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } if lst, err := s.store.GetListByID(listID); err == nil && lst != nil { s.wsHub.Broadcast(lst.SpaceID, listID, "ITEM_DELETED", map[string]string{ "itemId": itemID, "listId": listID, }) } writeJSON(w, http.StatusOK, map[string]bool{"deleted": true}) } func (s *Server) handleClearCompletedItems(w http.ResponseWriter, r *http.Request) { listID := chi.URLParam(r, "id") removed, err := s.store.ClearCompletedListItems(listID) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } if lst, err := s.store.GetListByID(listID); err == nil && lst != nil { s.wsHub.Broadcast(lst.SpaceID, listID, "LIST_CLEARED", lst) } writeJSON(w, http.StatusOK, map[string]int64{"removed": removed}) } func (s *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) { spaceID := chi.URLParam(r, "spaceId") token := r.URL.Query().Get("token") userID := "anonymous" if token != "" { if claims, err := auth.ValidateToken(s.cfg.JWTSecret, token); err == nil { userID = claims.UserID } } s.wsHub.ServeWS(w, r, spaceID, userID) } // --- API KEYS & WEBHOOK INGESTION --- func (s *Server) handleGetAPIKeys(w http.ResponseWriter, r *http.Request) { userID := auth.GetUserID(r.Context()) keys, err := s.store.GetAPIKeys(userID) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } writeJSON(w, http.StatusOK, keys) } func (s *Server) handleCreateAPIKey(w http.ResponseWriter, r *http.Request) { userID := auth.GetUserID(r.Context()) var req struct { Name string `json:"name"` } _ = json.NewDecoder(r.Body).Decode(&req) key, err := s.store.CreateAPIKey(userID, req.Name) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } writeJSON(w, http.StatusCreated, key) } func (s *Server) handleDeleteAPIKey(w http.ResponseWriter, r *http.Request) { userID := auth.GetUserID(r.Context()) keyID := chi.URLParam(r, "id") if err := s.store.DeleteAPIKey(userID, keyID); err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } writeJSON(w, http.StatusOK, map[string]bool{"deleted": true}) } // handleWebhookIngest accepts Smarthome / External automation webhooks via ?token=... or X-API-Key or Bearer token func (s *Server) handleWebhookIngest(w http.ResponseWriter, r *http.Request) { token := r.URL.Query().Get("token") if token == "" { token = r.Header.Get("X-API-Key") } if token == "" && strings.HasPrefix(r.Header.Get("Authorization"), "Bearer ") { token = strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") } var user *domain.User var err error if strings.HasPrefix(token, "mindsift_sec_") { user, err = s.store.ValidateAPIKeyAndGetUser(token) } else if token != "" { if claims, jwtErr := auth.ValidateToken(s.cfg.JWTSecret, token); jwtErr == nil { user, err = s.store.GetUserByID(claims.UserID) } } if err != nil || user == nil { writeError(w, http.StatusUnauthorized, "Ungültiger Webhook-Token oder API-Key") return } var req struct { Title string `json:"title"` Content string `json:"content"` Priority string `json:"priority"` Tags []string `json:"tags"` DueDate string `json:"dueDate"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "Ungültiges JSON-Payload") return } bodyText := strings.TrimSpace(req.Title) if req.Content != "" { if bodyText != "" { bodyText = bodyText + " — " + strings.TrimSpace(req.Content) } else { bodyText = strings.TrimSpace(req.Content) } } if bodyText == "" { writeError(w, http.StatusBadRequest, "Feld 'title' oder 'content' ist erforderlich") return } for _, tag := range req.Tags { cleanTag := strings.TrimPrefix(strings.TrimSpace(tag), "#") if cleanTag != "" && !strings.Contains(strings.ToLower(bodyText), "#"+strings.ToLower(cleanTag)) { bodyText += fmt.Sprintf(" #%s", cleanTag) } } if req.Priority == "" { req.Priority = "high" } thought, err := s.store.CreateThought(user.ID, bodyText, req.Priority, "webhook", req.DueDate) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } writeJSON(w, http.StatusCreated, map[string]interface{}{ "status": "ingested", "thought": thought, }) } // --- SEARCH & EXPORT --- func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) { userID := auth.GetUserID(r.Context()) q := r.URL.Query().Get("q") thoughts, docs, err := s.store.SearchAll(userID, q) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } writeJSON(w, http.StatusOK, map[string]interface{}{ "thoughts": thoughts, "documents": docs, }) } func (s *Server) handleExport(w http.ResponseWriter, r *http.Request) { userID := auth.GetUserID(r.Context()) exportData, err := s.store.ExportUserData(userID) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } w.Header().Set("Content-Disposition", "attachment; filename=mindsift-export.json") writeJSON(w, http.StatusOK, exportData) }