package api import ( "encoding/json" "errors" "fmt" "io" "net/http" "strings" "github.com/go-chi/chi/v5" "github.com/sandrodach/mindsift/internal/auth" "github.com/sandrodach/mindsift/internal/domain" "github.com/sandrodach/mindsift/internal/storage" ) // --- 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"` Scopes []string `json:"scopes"` } _ = json.NewDecoder(r.Body).Decode(&req) key, err := s.store.CreateAPIKey(userID, req.Name, req.Scopes) 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) { // In the URL only API keys are taken (devices that cannot set headers); a session token there would // end up in proxy logs as a full login (FLAW-35) token := r.URL.Query().Get("token") if token != "" && !strings.HasPrefix(token, "mindsift_sec_") { writeError(w, http.StatusUnauthorized, "In der URL sind nur API-Keys erlaubt; Sitzungs-Tokens gehören in den Authorization-Header") return } 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 ") } // Same checks as every other request (revoked/outdated sessions, inactive accounts) user, key := s.authenticateToken(token) if user == nil { writeError(w, http.StatusUnauthorized, "Ungültiger Webhook-Token oder API-Key") return } if key != nil { r = r.WithContext(withAPIKey(r.Context(), key, storage.OriginAPI)) } actor := actorFrom(r.Context()) var req struct { Title string `json:"title"` Content string `json:"content"` Text string `json:"text"` Message string `json:"message"` Priority string `json:"priority"` Source string `json:"source"` Tags []string `json:"tags"` DueDate string `json:"dueDate"` DueDate2 string `json:"due_date"` // Where it goes: "triage" (default, inbox), "task" or "list_item"; "type" is accepted as well Target string `json:"target"` Type string `json:"type"` TaskListID string `json:"taskListId"` ListID string `json:"listId"` Category string `json:"category"` Quantity string `json:"quantity"` } bodyBytes, _ := io.ReadAll(r.Body) if len(bodyBytes) > 0 { if jsonErr := json.Unmarshal(bodyBytes, &req); jsonErr != nil { plain := strings.TrimSpace(string(bodyBytes)) if plain != "" { req.Content = plain } } } if req.Content == "" { if req.Text != "" { req.Content = req.Text } else if req.Message != "" { req.Content = req.Message } } dueDate := req.DueDate if dueDate == "" { dueDate = req.DueDate2 } 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', 'content', 'text' oder 'message' 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) } } target := strings.ToLower(strings.TrimSpace(req.Target)) if target == "" { target = strings.ToLower(strings.TrimSpace(req.Type)) } switch target { case "", "triage", "inbox": target = "triage" case "task", "list_item": default: writeError(w, http.StatusBadRequest, "Unbekanntes Ziel – erlaubt: triage, task, list_item") return } if !requireDeliveryScope(w, r, target) { return } source := strings.ToLower(strings.TrimSpace(req.Source)) if source == "" { source = "webhook" } if target == "list_item" { s.ingestListItem(w, user.ID, req.ListID, bodyText, req.Category, req.Quantity, actor) return } if target == "task" { s.ingestTask(w, user.ID, bodyText, req.Priority, source, dueDate, req.TaskListID, actor) return } if req.Priority == "" { req.Priority = "high" } thought, err := s.store.CreateThought(user.ID, bodyText, req.Priority, source, dueDate) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } s.recordThoughtOrigin(thought, actor, true) s.wsHub.BroadcastToUsers(s.taskAudience(thought), "TASK_CREATED", thought) writeJSON(w, http.StatusCreated, map[string]interface{}{ "status": "ingested", "thought": thought, }) } // createTask creates a task directly (POST /webhooks/ingest, MCP tool create_task), optionally in a // task list the user may write to (otherwise storage.ErrTaskListReadOnly). actor: the API key, if any. func (s *Server) createTask(userID, content, priority, source, dueDate, taskListID, notes string, actor *apiActor) (*domain.Thought, error) { if priority == "" { priority = "normal" } if taskListID != "" && !s.store.CanEditTaskList(userID, taskListID) { return nil, storage.ErrTaskListReadOnly } thought, err := s.store.CreateThoughtWithDetails(userID, content, priority, source, dueDate, taskListID, "todo", notes) if err != nil { return nil, err } task, err := s.store.UpdateThoughtTriage(userID, thought.ID, "keep", "task", "", dueDate, taskListID) if err != nil { return nil, err } s.recordThoughtOrigin(task, actor, true) s.wsHub.BroadcastToUsers(s.taskAudience(task), "TASK_CREATED", task) return task, nil } func (s *Server) ingestTask(w http.ResponseWriter, userID, content, priority, source, dueDate, taskListID string, actor *apiActor) { task, err := s.createTask(userID, content, priority, source, dueDate, taskListID, "", actor) if errors.Is(err, storage.ErrTaskListReadOnly) { writeError(w, http.StatusForbidden, err.Error()) return } if err != nil { writeStoreError(w, http.StatusBadRequest, err) return } writeJSON(w, http.StatusCreated, map[string]interface{}{"status": "ingested", "task": task}) } var errListIDRequired = errors.New("Feld 'listId' ist für Listeneinträge erforderlich") // addListItem appends an item to a list the user may write to; the error is storage.ErrListReadOnly for // viewers and "not found" for lists the user cannot see. func (s *Server) addListItem(userID, listID, content, category, quantity string, actor *apiActor) (*domain.ListItem, error) { if strings.TrimSpace(listID) == "" { return nil, errListIDRequired } if err := s.store.EnsureListItemsWritable(userID, listID, ""); err != nil { return nil, err } item, err := s.store.CreateListItem(listID, content, category, quantity, 0, userID) if err != nil { return nil, err } s.recordListItemOrigin(item, actor, true) s.wsHub.BroadcastListEvent(listID, "ITEM_CREATED", item) return item, nil } // ingestListItem adds an item to a list the owner of the key may write to. func (s *Server) ingestListItem(w http.ResponseWriter, userID, listID, content, category, quantity string, actor *apiActor) { item, err := s.addListItem(userID, listID, content, category, quantity, actor) if err != nil { status := http.StatusNotFound switch { case errors.Is(err, storage.ErrListReadOnly): status = http.StatusForbidden case errors.Is(err, errListIDRequired): status = http.StatusBadRequest } writeError(w, status, err.Error()) return } writeJSON(w, http.StatusCreated, map[string]interface{}{"status": "ingested", "item": item}) }