First draft
MindSift Multi-Arch Docker Build / Lint, Build & Unit Tests (push) Successful in 2m7s
MindSift Multi-Arch Docker Build / Build & Push Docker Image (push) Failing after 29s

This commit is contained in:
sandrodach committed 2026-10-24 16:56:44 +02:00
1 parent aba52f2b3c
commit 84bc6884b0
37 files changed
+6697 -27

No files matched your search

+871
View File
@@ -0,0 +1,871 @@
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 {
next.ServeHTTP(w, r.WithContext(auth.WithUserID(r.Context(), claims.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
}
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
}
thought, err := s.store.CreateThought(userID, req.Content, req.Priority, req.Source, dueDate)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
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)
}
+94
View File
@@ -0,0 +1,94 @@
package auth
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"time"
"github.com/golang-jwt/jwt/v5"
"golang.org/x/crypto/bcrypt"
)
type contextKey string
const UserIDContextKey contextKey = "mindsift_user_id"
type Claims struct {
UserID string `json:"userId"`
Email string `json:"email"`
DisplayName string `json:"displayName"`
jwt.RegisteredClaims
}
func HashPassword(password string) (string, error) {
bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
return string(bytes), err
}
func CheckPassword(password, hash string) bool {
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
return err == nil
}
func GenerateToken(secret, userID, email, displayName string) (string, error) {
claims := &Claims{
UserID: userID,
Email: email,
DisplayName: displayName,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(30 * 24 * time.Hour)),
IssuedAt: jwt.NewNumericDate(time.Now()),
Issuer: "mindsift",
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString([]byte(secret))
}
func ValidateToken(secret, tokenStr string) (*Claims, error) {
token, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return []byte(secret), nil
})
if err != nil {
return nil, err
}
claims, ok := token.Claims.(*Claims)
if !ok || !token.Valid {
return nil, errors.New("invalid token claims")
}
return claims, nil
}
// GenerateAPIKey creates a new webhook/ingestion API key (e.g. mindsift_sec_...) and returns rawKey, prefix, sha256Hash
func GenerateAPIKey() (string, string, string) {
b := make([]byte, 18)
_, _ = rand.Read(b)
raw := "mindsift_sec_" + hex.EncodeToString(b)
prefix := raw[:20] + "..."
return raw, prefix, HashAPIKey(raw)
}
func HashAPIKey(rawKey string) string {
sum := sha256.Sum256([]byte(rawKey))
return hex.EncodeToString(sum[:])
}
func WithUserID(ctx context.Context, userID string) context.Context {
return context.WithValue(ctx, UserIDContextKey, userID)
}
func GetUserID(ctx context.Context) string {
if val, ok := ctx.Value(UserIDContextKey).(string); ok {
return val
}
return ""
}
+52
View File
@@ -0,0 +1,52 @@
package config
import (
"os"
"path/filepath"
"strings"
)
type Config struct {
Port string
DataDir string
DatabasePath string
JWTSecret string
BaseURL string
AllowRegistration bool
LogLevel string
}
func Load() *Config {
port := getEnv("PORT", "8080")
dataDir := getEnv("DATA_DIR", "./data")
dbURL := getEnv("DATABASE_URL", "")
dbPath := filepath.Join(dataDir, "mindsift.db")
if strings.HasPrefix(dbURL, "sqlite://") {
dbPath = strings.TrimPrefix(dbURL, "sqlite://")
} else if dbURL != "" && !strings.HasPrefix(dbURL, "postgres") {
dbPath = dbURL
}
jwtSecret := getEnv("JWT_SECRET", "mindsift-default-selfhosted-jwt-secret-key-2026")
baseURL := getEnv("BASE_URL", "http://localhost:"+port)
allowReg := strings.ToLower(getEnv("ALLOW_REGISTRATION", "true")) != "false"
logLevel := getEnv("LOG_LEVEL", "info")
return &Config{
Port: port,
DataDir: dataDir,
DatabasePath: dbPath,
JWTSecret: jwtSecret,
BaseURL: baseURL,
AllowRegistration: allowReg,
LogLevel: logLevel,
}
}
func getEnv(key, fallback string) string {
if val, ok := os.LookupEnv(key); ok && strings.TrimSpace(val) != "" {
return strings.TrimSpace(val)
}
return fallback
}
+183
View File
@@ -0,0 +1,183 @@
package domain
import (
"crypto/rand"
"encoding/hex"
"fmt"
"regexp"
"strings"
"time"
)
// NewID generates a readable prefixed ID as specified in BRAIN.md (e.g. th_4x8z19a0b1)
func NewID(prefix string) string {
b := make([]byte, 5)
_, _ = rand.Read(b)
return fmt.Sprintf("%s_%s", prefix, hex.EncodeToString(b))
}
// NowISO returns the current UTC timestamp in ISO-8601 format
func NowISO() string {
return time.Now().UTC().Format(time.RFC3339)
}
var hashtagRegex = regexp.MustCompile(`#([a-zA-Z0-9äöüÄÖÜß_-]+)`)
// ExtractTags extracts #hashtags from content
func ExtractTags(content string) []string {
matches := hashtagRegex.FindAllStringSubmatch(content, -1)
seen := make(map[string]bool)
tags := make([]string, 0, len(matches))
for _, m := range matches {
if len(m) > 1 {
tag := strings.ToLower(m[1])
if !seen[tag] {
seen[tag] = true
tags = append(tags, tag)
}
}
}
return tags
}
type User struct {
ID string `json:"id"`
Email string `json:"email"`
PasswordHash string `json:"-"`
DisplayName string `json:"displayName"`
CreatedAt string `json:"createdAt"`
}
type ThoughtStatus string
const (
StatusInbox ThoughtStatus = "inbox"
StatusKept ThoughtStatus = "kept"
StatusDiscarded ThoughtStatus = "discarded"
StatusConverted ThoughtStatus = "converted"
)
type Thought struct {
ID string `json:"id"`
UserID string `json:"userId"`
Content string `json:"content"`
Tags []string `json:"tags"`
Priority string `json:"priority"` // low | normal | high | urgent
Source string `json:"source"` // manual | voice | webhook
Status ThoughtStatus `json:"status"`
IsCompleted bool `json:"isCompleted"`
ConvertedType string `json:"convertedType,omitempty"` // task | document | list_item
ConvertedRefID string `json:"convertedRefId,omitempty"` // referenced entity ID
DueDate string `json:"dueDate,omitempty"`
CreatedAt string `json:"createdAt"`
TriagedAt string `json:"triagedAt,omitempty"`
}
type DocumentBlock struct {
ID string `json:"id"`
Type string `json:"type"` // h1 | h2 | h3 | paragraph | todo | bullet | code | callout | divider
Content string `json:"content"`
Checked bool `json:"checked,omitempty"`
Lang string `json:"lang,omitempty"`
}
type Document struct {
ID string `json:"id"`
UserID string `json:"userId"`
SpaceID string `json:"spaceId,omitempty"`
Title string `json:"title"`
Icon string `json:"icon"`
Content string `json:"content"`
Blocks []DocumentBlock `json:"blocks"`
Tags []string `json:"tags"`
IsPinned bool `json:"isPinned"`
IsArchived bool `json:"isArchived"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
}
type SpaceMember struct {
SpaceID string `json:"spaceId"`
UserID string `json:"userId"`
DisplayName string `json:"displayName"`
Email string `json:"email"`
Role string `json:"role"` // owner | editor | viewer
JoinedAt string `json:"joinedAt"`
}
type Space struct {
ID string `json:"id"`
Name string `json:"name"`
Icon string `json:"icon"`
Description string `json:"description"`
InviteCode string `json:"inviteCode"`
IsPersonal bool `json:"isPersonal"`
OwnerID string `json:"ownerId"`
Members []SpaceMember `json:"members,omitempty"`
ListsCount int `json:"listsCount"`
CreatedAt string `json:"createdAt"`
}
type ListItem struct {
ID string `json:"id"`
ListID string `json:"listId"`
Content string `json:"content"`
Category string `json:"category,omitempty"`
Quantity string `json:"quantity,omitempty"`
IsCompleted bool `json:"isCompleted"`
CompletedBy string `json:"completedBy,omitempty"`
CompletedByName string `json:"completedByName,omitempty"`
SortOrder int `json:"sortOrder"`
CreatedAt string `json:"createdAt"`
CompletedAt string `json:"completedAt,omitempty"`
}
type SharedList struct {
ID string `json:"id"`
SpaceID string `json:"spaceId"`
Title string `json:"title"`
Icon string `json:"icon"`
ListType string `json:"listType"` // shopping | checklist | vacation
Items []ListItem `json:"items"`
UpdatedAt string `json:"updatedAt"`
CreatedAt string `json:"createdAt"`
}
type APIKey struct {
ID string `json:"id"`
UserID string `json:"userId"`
Name string `json:"name"`
KeyPrefix string `json:"keyPrefix"`
KeyHash string `json:"-"`
RawKey string `json:"rawKey,omitempty"` // Only returned once on creation
Scopes string `json:"scopes"`
LastUsedAt string `json:"lastUsedAt,omitempty"`
CreatedAt string `json:"createdAt"`
}
type WSMessage struct {
Event string `json:"event"` // ITEM_CREATED | ITEM_UPDATED | ITEM_TOGGLED | ITEM_DELETED | LIST_CLEARED | USER_PRESENCE
SpaceID string `json:"space_id"`
ListID string `json:"list_id,omitempty"`
Payload interface{} `json:"payload"`
Timestamp string `json:"timestamp"`
}
type DashboardStats struct {
InboxCount int `json:"inboxCount"`
TasksOpenCount int `json:"tasksOpenCount"`
TasksDoneCount int `json:"tasksDoneCount"`
DocsCount int `json:"docsCount"`
ListsCount int `json:"listsCount"`
DiscardedCount int `json:"discardedCount"`
}
type FullExport struct {
ExportedAt string `json:"exportedAt"`
Version string `json:"version"`
User User `json:"user"`
Thoughts []Thought `json:"thoughts"`
Documents []Document `json:"documents"`
Spaces []Space `json:"spaces"`
Lists []SharedList `json:"lists"`
}
File diff suppressed because it is too large. Load diff
+76
View File
@@ -0,0 +1,76 @@
package storage
import (
"path/filepath"
"testing"
"github.com/sandrodach/mindsift/internal/domain"
)
func TestStoreLifecycle(t *testing.T) {
tmpDir := t.TempDir()
dbPath := filepath.Join(tmpDir, "test_mindsift.db")
store, err := NewStore(dbPath)
if err != nil {
t.Fatalf("NewStore failed: %v", err)
}
defer store.Close()
user, err := store.EnsureDefaultUser()
if err != nil {
t.Fatalf("EnsureDefaultUser failed: %v", err)
}
if user.ID == "" {
t.Fatal("expected valid user ID")
}
// 1. Test Instant Capture
thought, err := store.CreateThought(user.ID, "Milch und Brot kaufen #einkauf #dringend", "high", "manual", "")
if err != nil {
t.Fatalf("CreateThought failed: %v", err)
}
if thought.Status != domain.StatusInbox {
t.Fatalf("expected status inbox, got %s", thought.Status)
}
if len(thought.Tags) != 2 {
t.Fatalf("expected 2 extracted hashtags, got %d", len(thought.Tags))
}
// 2. Test Tinder-Style Triage -> Convert to Document
triaged, err := store.UpdateThoughtTriage(user.ID, thought.ID, "keep", "document", "", "")
if err != nil {
t.Fatalf("UpdateThoughtTriage failed: %v", err)
}
if triaged.Status != domain.StatusConverted || triaged.ConvertedType != "document" || triaged.ConvertedRefID == "" {
t.Fatalf("expected converted document, got status=%s type=%s ref=%s", triaged.Status, triaged.ConvertedType, triaged.ConvertedRefID)
}
// 3. Test Shared Space & Shopping List
sp, err := store.CreateSpace(user.ID, "Test WG", "🍕", "Test Space", false)
if err != nil {
t.Fatalf("CreateSpace failed: %v", err)
}
lst, err := store.CreateList(sp.ID, "REWE Einkauf", "🛒", "shopping")
if err != nil {
t.Fatalf("CreateList failed: %v", err)
}
item, err := store.CreateListItem(lst.ID, "Hafermilch", "Kühlregal", "2L", 0)
if err != nil {
t.Fatalf("CreateListItem failed: %v", err)
}
toggled, err := store.ToggleListItem(item.ID, true, user.ID, user.DisplayName)
if err != nil || !toggled.IsCompleted {
t.Fatalf("ToggleListItem failed: %v", err)
}
// 4. Test Webhook API Key generation & validation
apiKey, err := store.CreateAPIKey(user.ID, "Home Assistant")
if err != nil {
t.Fatalf("CreateAPIKey failed: %v", err)
}
validatedUser, err := store.ValidateAPIKeyAndGetUser(apiKey.RawKey)
if err != nil || validatedUser.ID != user.ID {
t.Fatalf("ValidateAPIKeyAndGetUser failed: %v", err)
}
}
+172
View File
@@ -0,0 +1,172 @@
package ws
import (
"encoding/json"
"log"
"net/http"
"sync"
"time"
"github.com/gorilla/websocket"
"github.com/sandrodach/mindsift/internal/domain"
)
var upgrader = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
CheckOrigin: func(r *http.Request) bool {
return true // Self-hosted reverse proxy compatible
},
}
type Client struct {
hub *Hub
conn *websocket.Conn
spaceID string
userID string
send chan []byte
}
type Hub struct {
mu sync.RWMutex
rooms map[string]map[*Client]bool
}
func NewHub() *Hub {
return &Hub{
rooms: make(map[string]map[*Client]bool),
}
}
func (h *Hub) Register(c *Client) {
h.mu.Lock()
if h.rooms[c.spaceID] == nil {
h.rooms[c.spaceID] = make(map[*Client]bool)
}
h.rooms[c.spaceID][c] = true
onlineCount := len(h.rooms[c.spaceID])
h.mu.Unlock()
h.Broadcast(c.spaceID, "", "USER_PRESENCE", map[string]interface{}{
"onlineCount": onlineCount,
})
}
func (h *Hub) Unregister(c *Client) {
h.mu.Lock()
var onlineCount int
if room, ok := h.rooms[c.spaceID]; ok {
if _, exists := room[c]; exists {
delete(room, c)
close(c.send)
}
onlineCount = len(room)
if onlineCount == 0 {
delete(h.rooms, c.spaceID)
}
}
h.mu.Unlock()
if onlineCount > 0 {
h.Broadcast(c.spaceID, "", "USER_PRESENCE", map[string]interface{}{
"onlineCount": onlineCount,
})
}
}
func (h *Hub) Broadcast(spaceID, listID, event string, payload interface{}) {
msg := domain.WSMessage{
Event: event,
SpaceID: spaceID,
ListID: listID,
Payload: payload,
Timestamp: domain.NowISO(),
}
data, err := json.Marshal(msg)
if err != nil {
return
}
h.mu.RLock()
defer h.mu.RUnlock()
room, ok := h.rooms[spaceID]
if !ok {
return
}
for client := range room {
select {
case client.send <- data:
default:
// Slow client buffer full
}
}
}
func (h *Hub) ServeWS(w http.ResponseWriter, r *http.Request, spaceID, userID string) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Printf("[ws] upgrade error: %v", err)
return
}
client := &Client{
hub: h,
conn: conn,
spaceID: spaceID,
userID: userID,
send: make(chan []byte, 64),
}
h.Register(client)
go client.writePump()
go client.readPump()
}
func (c *Client) readPump() {
defer func() {
c.hub.Unregister(c)
_ = c.conn.Close()
}()
c.conn.SetReadLimit(8192)
_ = c.conn.SetReadDeadline(time.Now().Add(60 * time.Second))
c.conn.SetPongHandler(func(string) error {
_ = c.conn.SetReadDeadline(time.Now().Add(60 * time.Second))
return nil
})
for {
_, _, err := c.conn.ReadMessage()
if err != nil {
break
}
}
}
func (c *Client) writePump() {
ticker := time.NewTicker(30 * time.Second)
defer func() {
ticker.Stop()
_ = c.conn.Close()
}()
for {
select {
case message, ok := <-c.send:
_ = c.conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
if !ok {
_ = c.conn.WriteMessage(websocket.CloseMessage, []byte{})
return
}
if err := c.conn.WriteMessage(websocket.TextMessage, message); err != nil {
return
}
case <-ticker.C:
_ = c.conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil {
return
}
}
}
}