package api import ( "context" "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/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, keyScopes := s.authenticateToken(token) if user == nil { writeError(w, http.StatusUnauthorized, "Ungültiger Webhook-Token oder API-Key") return } if keyScopes != nil { r = r.WithContext(context.WithValue(r.Context(), apiKeyScopesKey{}, keyScopes)) } 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) return } if target == "task" { s.ingestTask(w, user.ID, bodyText, req.Priority, source, dueDate, req.TaskListID) 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.wsHub.BroadcastToUsers(s.taskAudience(thought), "TASK_CREATED", thought) writeJSON(w, http.StatusCreated, map[string]interface{}{ "status": "ingested", "thought": thought, }) } // ingestTask creates a task directly (scope webhook:task), optionally in a task list the owner of // the key may write to. func (s *Server) ingestTask(w http.ResponseWriter, userID, content, priority, source, dueDate, taskListID string) { if priority == "" { priority = "normal" } if taskListID != "" && !s.store.CanEditTaskList(userID, taskListID) { writeError(w, http.StatusForbidden, storage.ErrTaskListReadOnly.Error()) return } thought, err := s.store.CreateThoughtWithDetails(userID, content, priority, source, dueDate, taskListID, "todo", "") if err != nil { writeStoreError(w, http.StatusBadRequest, err) return } task, err := s.store.UpdateThoughtTriage(userID, thought.ID, "keep", "task", "", dueDate, taskListID) if err != nil { writeStoreError(w, http.StatusBadRequest, err) return } s.wsHub.BroadcastToUsers(s.taskAudience(task), "TASK_CREATED", task) writeJSON(w, http.StatusCreated, map[string]interface{}{"status": "ingested", "task": task}) } // ingestListItem adds an item to a list (scope webhook:list) the owner of the key may write to. func (s *Server) ingestListItem(w http.ResponseWriter, userID, listID, content, category, quantity string) { if strings.TrimSpace(listID) == "" { writeError(w, http.StatusBadRequest, "Feld 'listId' ist für Listeneinträge erforderlich") return } if err := s.store.EnsureListItemsWritable(userID, listID, ""); err != nil { status := http.StatusNotFound if errors.Is(err, storage.ErrListReadOnly) { status = http.StatusForbidden } writeError(w, status, err.Error()) return } item, err := s.store.CreateListItem(listID, content, category, quantity, 0, userID) if err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } s.wsHub.BroadcastListEvent(listID, "ITEM_CREATED", item) writeJSON(w, http.StatusCreated, map[string]interface{}{"status": "ingested", "item": item}) }