Files
MindSift/internal/api/handlers_integrations.go
T
sandrodachandClaude Opus 5.5 b883315914
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m16s
feat(api): one API for curl and MCP, REST reads with keys, origin badge with key name
API keys reach tasks and lists over plain REST too (GET /task-lists, GET /tasks with
status/due filters, PATCH/DELETE /thoughts/{id}, GET /lists, list items), guarded by a route
table; notes and settings stay closed. Rights hold for every access alike (webhook:task also
allows create_task over MCP, tasks:write also task delivery).

Tasks, thoughts and list items record which key created them and which one changed them last
(migration 13 replaces agent_mark; origin stays after the user's own edits and survives the
triage into a list item). OriginBadge shows the key name with a webhook or robot icon, details
on hover and tap, and replaces the Webhook and KI badges everywhere, now also on list items.

Settings: category "API" with rights grouped by tasks, lists and inbox and two tabs
(curl / smart home with a Home Assistant sensor, AI agents over MCP). Docs, help, README and
changelog updated; v0.30.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 10:26:44 +01:00

263 lines
8.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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})
}