Files
MindSift/internal/api/server.go
T
sandrodachandClaude Sonnet 5.5 4e9c4ffa8c
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m5s
feat(tasks): add read-only sharing permission for task lists
Task list shares now carry a permission (editor/viewer). The server rejects
writes from viewers with 403, and the UI hides or disables edit controls
for read-only lists.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 22:30:28 +01:00

2961 lines
92 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 (
"bytes"
"crypto/tls"
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"log/slog"
"net/http"
"net/smtp"
"strings"
"time"
"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 {
srv := &Server{
cfg: cfg,
store: store,
wsHub: wsHub,
staticFS: staticFS,
}
go srv.startAlarmTicker()
return srv
}
func (s *Server) Router() http.Handler {
r := chi.NewRouter()
r.Use(middleware.RealIP)
r.Use(middleware.Recoverer)
r.Use(s.apiLoggingMiddleware)
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, Setup & Auth Routes
r.Get("/api/v1/health", s.handleHealth)
r.Get("/api/v1/auth/status", s.handleAuthStatus)
r.Post("/api/v1/auth/setup", s.handleFirstBootSetup)
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", s.handleWebSocket)
r.Get("/api/v1/ws/spaces/{spaceId}", s.handleWebSocket)
r.Get("/api/v1/ws/lists/{listId}", s.handleWebSocket)
r.Get("/api/v1/events", s.handleSSE)
r.Post("/api/v1/events/rooms", s.handleSSERooms)
// Authenticated API Routes
r.Group(func(r chi.Router) {
r.Use(s.authMiddleware)
r.Get("/api/v1/auth/me", s.handleMe)
r.Post("/api/v1/auth/logout", s.handleLogout)
r.Post("/api/v1/auth/revoke-all", s.handleRevokeAllSessions)
r.Patch("/api/v1/auth/settings", s.handleUpdateUserSettings)
r.Post("/api/v1/auth/alarms/test", s.handleTestUserAlarm)
r.Get("/api/v1/users/directory", s.handleGetUsersDirectory)
r.Get("/api/v1/collaborators", s.handleGetCollaborators)
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.Get("/api/v1/thoughts/{id}", s.handleGetThought)
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)
r.Post("/api/v1/tasks/{id}/unlink", s.handleUnlinkEnhancedTask)
r.Post("/api/v1/tasks/reorder", s.handleReorderTasks)
r.Post("/api/v1/task-lists/{id}/tasks/reorder", s.handleReorderTasks)
// Task Lists & Shares
r.Get("/api/v1/task-lists", s.handleGetTaskLists)
r.Post("/api/v1/task-lists", s.handleCreateTaskList)
r.Put("/api/v1/task-lists/{id}", s.handleUpdateTaskList)
r.Delete("/api/v1/task-lists/{id}", s.handleDeleteTaskList)
r.Get("/api/v1/task-lists/{id}/shares", s.handleGetTaskListShares)
r.Post("/api/v1/task-lists/{id}/shares", s.handleCreateTaskListShare)
r.Delete("/api/v1/task-lists/{id}/shares/{shareId}", s.handleDeleteTaskListShare)
// Workspace Documents & Folders
r.Get("/api/v1/folders", s.handleGetFolders)
r.Post("/api/v1/folders", s.handleCreateFolder)
r.Post("/api/v1/folders/move", s.handleMoveFolder)
r.Delete("/api/v1/folders", s.handleDeleteFolder)
r.Get("/api/v1/workspace/folders/shared", s.handleGetSharedFolders)
r.Post("/api/v1/workspace/folders/shared", s.handleCreateSharedFolder)
r.Get("/api/v1/workspace/folders/shared/{id}", s.handleGetSharedFolderByID)
r.Delete("/api/v1/workspace/folders/shared/{id}", s.handleDeleteSharedFolder)
r.Put("/api/v1/workspace/folders/shared/{id}/mount", s.handleUpdateFolderMount)
r.Get("/api/v1/workspace/folders/shared/{id}/shares", s.handleGetFolderShares)
r.Post("/api/v1/workspace/folders/shared/{id}/shares", s.handleCreateFolderShare)
r.Delete("/api/v1/workspace/folders/shared/{id}/shares/{shareId}", s.handleDeleteFolderShare)
// Workspace Tags & Pages
r.Get("/api/v1/workspace/tags", s.handleGetWorkspaceTags)
r.Get("/api/v1/workspace/tags/{tag}", s.handleGetTagDetails)
r.Put("/api/v1/workspace/tags/{tag}/note", s.handleUpdateTagPageNote)
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.Patch("/api/v1/documents/{id}/blocks", s.handlePatchDocumentBlocks)
r.Put("/api/v1/documents/{id}/move", s.handleMoveDocument)
r.Delete("/api/v1/documents/{id}", s.handleDeleteDocument)
r.Post("/api/v1/documents/{id}/blocks/{blockId}/enhanced-task", s.handleCreateEnhancedTask)
// Document Sharing & Collaboration
r.Get("/api/v1/documents/{id}/shares", s.handleGetDocShares)
r.Post("/api/v1/documents/{id}/shares", s.handleCreateDocShare)
r.Delete("/api/v1/documents/{id}/shares/{userId}", s.handleDeleteDocShare)
// Shared Lists & Realtime Collaboration
r.Get("/api/v1/lists", s.handleGetAllLists)
r.Post("/api/v1/lists", s.handleCreateList)
r.Get("/api/v1/lists/{id}", s.handleGetListByID)
r.Delete("/api/v1/lists/{id}", s.handleDeleteList)
r.Get("/api/v1/lists/{id}/shares", s.handleGetListShares)
r.Post("/api/v1/lists/{id}/shares", s.handleCreateListShare)
r.Delete("/api/v1/lists/{id}/shares/{shareId}", s.handleDeleteListShare)
r.Post("/api/v1/lists/{id}/items", s.handleCreateListItem)
r.Patch("/api/v1/lists/{id}/items/{itemId}", s.handleToggleListItem)
r.Put("/api/v1/lists/{id}/items/{itemId}", s.handleUpdateListItem)
r.Delete("/api/v1/lists/{id}/items/{itemId}", s.handleDeleteListItem)
r.Post("/api/v1/lists/{id}/items/reorder", s.handleReorderListItems)
r.Put("/api/v1/lists/{id}/items/reorder", s.handleReorderListItems)
r.Post("/api/v1/lists/{id}/clear-completed", s.handleClearCompletedItems)
// Organizations & Teams
r.Get("/api/v1/organizations", s.handleGetOrganizations)
r.Post("/api/v1/organizations", s.handleCreateOrganization)
r.Delete("/api/v1/organizations/{id}", s.handleDeleteOrganization)
r.Get("/api/v1/organizations/{id}/teams", s.handleGetOrgTeams)
r.Post("/api/v1/organizations/{id}/teams", s.handleCreateOrgTeam)
r.Get("/api/v1/teams", s.handleGetUserTeams)
r.Post("/api/v1/teams", s.handleCreateTeamDirect)
r.Put("/api/v1/teams/{id}", s.handleUpdateTeam)
r.Delete("/api/v1/teams/{id}", s.handleDeleteTeam)
r.Post("/api/v1/teams/{id}/members", s.handleAddTeamMember)
r.Put("/api/v1/teams/{id}/members/{userId}", s.handleUpdateTeamMemberRole)
r.Delete("/api/v1/teams/{id}/members/{userId}", s.handleRemoveTeamMember)
// 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)
// Staff Routes (Server-Admin & Team-Admin)
r.Group(func(r chi.Router) {
r.Use(s.staffMiddleware)
r.Get("/api/v1/admin/users", s.handleAdminGetUsers)
r.Post("/api/v1/admin/users", s.handleAdminCreateUser)
r.Patch("/api/v1/admin/users/{id}", s.handleAdminUpdateUser)
r.Put("/api/v1/admin/users/{id}/password", s.handleAdminResetPassword)
r.Delete("/api/v1/admin/users/{id}", s.handleAdminDeleteUser)
r.Get("/api/v1/admin/invites", s.handleAdminGetInvites)
r.Post("/api/v1/admin/invites", s.handleAdminCreateInvite)
r.Delete("/api/v1/admin/invites/{id}", s.handleAdminDeleteInvite)
})
// Server-Admin Strictly Protected Routes
r.Group(func(r chi.Router) {
r.Use(s.adminMiddleware)
r.Get("/api/v1/admin/settings", s.handleAdminGetSettings)
r.Put("/api/v1/admin/settings", s.handleAdminUpdateSettings)
r.Post("/api/v1/admin/smtp/test", s.handleAdminTestSMTP)
})
})
// 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 {
if strings.HasPrefix(path, "assets/") || strings.HasSuffix(path, ".js") || strings.HasSuffix(path, ".css") {
http.NotFound(w, req)
return
}
req.URL.Path = "/"
path = "index.html"
}
if path == "index.html" || path == "sw.js" || path == "manifest.json" {
w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate")
w.Header().Set("Pragma", "no-cache")
w.Header().Set("Expires", "0")
}
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.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate")
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})
}
// writeStoreError maps read-only task list violations to 403 and everything else to the given fallback status.
func writeStoreError(w http.ResponseWriter, fallbackStatus int, err error) {
if errors.Is(err, storage.ErrTaskListReadOnly) {
writeError(w, http.StatusForbidden, err.Error())
return
}
writeError(w, fallbackStatus, err.Error())
}
func (s *Server) apiLoggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Only log API requests (/api/...)
if !strings.HasPrefix(r.URL.Path, "/api") {
next.ServeHTTP(w, r)
return
}
start := time.Now()
ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
next.ServeHTTP(ww, r)
duration := time.Since(start)
durationMs := float64(duration.Microseconds()) / 1000.0
status := ww.Status()
if status == 0 {
status = http.StatusOK
}
method := r.Method
path := r.URL.Path
if status >= 500 {
slog.Error("HTTP request failed",
"method", method,
"path", path,
"status", status,
"duration_ms", durationMs,
)
} else if status >= 400 {
slog.Warn("HTTP request warning",
"method", method,
"path", path,
"status", status,
"duration_ms", durationMs,
)
} else {
slog.Debug("HTTP request completed",
"method", method,
"path", path,
"status", status,
"duration_ms", durationMs,
)
}
})
}
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 && user.IsActive {
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 {
if !s.store.IsTokenRevoked(tokenStr) {
user, userErr := s.store.GetUserByID(claims.UserID)
if userErr == nil && user != nil && user.IsActive {
if claims.TokenVersion == 0 || claims.TokenVersion == user.TokenVersion {
next.ServeHTTP(w, r.WithContext(auth.WithUserID(r.Context(), user.ID)))
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 && user.IsActive {
next.ServeHTTP(w, r.WithContext(auth.WithUserID(r.Context(), user.ID)))
return
}
}
writeError(w, http.StatusUnauthorized, "Nicht autorisiert – bitte anmelden")
})
}
func (s *Server) staffMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
if userID == "" {
writeError(w, http.StatusUnauthorized, "Nicht autorisiert")
return
}
user, err := s.store.GetUserByID(userID)
if err != nil || user == nil || user.Role != "admin" {
writeError(w, http.StatusForbidden, "Admin-Berechtigung erforderlich")
return
}
next.ServeHTTP(w, r)
})
}
func (s *Server) adminMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
if userID == "" {
writeError(w, http.StatusUnauthorized, "Nicht autorisiert")
return
}
user, err := s.store.GetUserByID(userID)
if err != nil || user == nil || user.Role != "admin" {
writeError(w, http.StatusForbidden, "Server-Administrator-Rechte erforderlich")
return
}
next.ServeHTTP(w, r)
})
}
// --- 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) handleAuthStatus(w http.ResponseWriter, r *http.Request) {
count, _ := s.store.CountUsers()
sys, _ := s.store.GetSystemSettings()
allowReg := false
instanceName := "MindSift"
if sys != nil {
allowReg = sys.AllowRegistration
if sys.InstanceName != "" {
instanceName = sys.InstanceName
}
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"needsSetup": count == 0,
"allowRegistration": allowReg,
"instanceName": instanceName,
"demoMode": s.cfg.DemoMode,
})
}
func (s *Server) handleFirstBootSetup(w http.ResponseWriter, r *http.Request) {
count, err := s.store.CountUsers()
if err != nil || count > 0 {
writeError(w, http.StatusForbidden, "Ersteinrichtung wurde bereits abgeschlossen")
return
}
var req struct {
Email string `json:"email"`
Password string `json:"password"`
DisplayName string `json:"displayName"`
InstanceName string `json:"instanceName"`
AllowRegistration bool `json:"allowRegistration"`
SharingMode string `json:"sharingMode"`
SeedSampleData bool `json:"seedSampleData"`
}
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 = "Administrator"
}
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, "admin")
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
sys, _ := s.store.GetSystemSettings()
if sys != nil {
if strings.TrimSpace(req.InstanceName) != "" {
sys.InstanceName = strings.TrimSpace(req.InstanceName)
}
sys.AllowRegistration = req.AllowRegistration
if req.SharingMode == "open" || req.SharingMode == "teams_only" || req.SharingMode == "strict_private" {
sys.SharingMode = req.SharingMode
}
_ = s.store.UpdateSystemSettings(sys)
}
if req.SeedSampleData {
_ = s.store.SeedStarterContent(user)
}
token, err := auth.GenerateToken(s.cfg.JWTSecret, user.ID, user.Email, user.DisplayName, user.TokenVersion)
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) handleRegister(w http.ResponseWriter, r *http.Request) {
var req struct {
Email string `json:"email"`
Password string `json:"password"`
DisplayName string `json:"displayName"`
InviteCode string `json:"inviteCode"`
}
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]
}
count, _ := s.store.CountUsers()
role := "member"
var validInvite *domain.UserInvite
if strings.TrimSpace(req.InviteCode) != "" {
inv, err := s.store.GetUserInviteByCode(req.InviteCode)
if err != nil {
writeError(w, http.StatusForbidden, err.Error())
return
}
if inv.Email != "" && !strings.EqualFold(inv.Email, strings.TrimSpace(req.Email)) {
writeError(w, http.StatusForbidden, "Dieser Einladungscode ist an eine andere E-Mail-Adresse gebunden")
return
}
validInvite = inv
role = inv.Role
} else if count == 0 {
role = "admin"
} else {
sysSettings, _ := s.store.GetSystemSettings()
if sysSettings == nil || !sysSettings.AllowRegistration {
writeError(w, http.StatusForbidden, "Öffentliche Registrierung ist deaktiviert – bitte nutze einen Einladungscode vom Administrator")
return
}
}
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, role)
if err != nil {
writeError(w, http.StatusConflict, "E-Mail-Adresse existiert bereits")
return
}
if validInvite != nil {
_ = s.store.MarkUserInviteUsed(validInvite.ID, user.ID)
if validInvite.TeamID != "" {
_ = s.store.AddTeamMember(validInvite.TeamID, user.ID, "member")
}
}
token, err := auth.GenerateToken(s.cfg.JWTSecret, user.ID, user.Email, user.DisplayName, user.TokenVersion)
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"`
Username string `json:"username"`
Login string `json:"login"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültige Anfrage")
return
}
identifier := strings.TrimSpace(req.Login)
if identifier == "" {
identifier = strings.TrimSpace(req.Username)
}
if identifier == "" {
identifier = strings.TrimSpace(req.Email)
}
if identifier == "" || req.Password == "" {
writeError(w, http.StatusBadRequest, "Benutzername/E-Mail und Passwort erforderlich")
return
}
user, err := s.store.GetUserByLogin(identifier)
if err != nil {
slog.Warn("Login fehlgeschlagen: Benutzer existiert nicht", "login", identifier, "remote_addr", r.RemoteAddr)
writeError(w, http.StatusUnauthorized, "Benutzername/E-Mail oder Passwort ungültig")
return
}
if !auth.CheckPassword(req.Password, user.PasswordHash) {
slog.Warn("Login fehlgeschlagen: Falsches Passwort", "login", identifier, "user_id", user.ID, "display_name", user.DisplayName, "remote_addr", r.RemoteAddr)
writeError(w, http.StatusUnauthorized, "Benutzername/E-Mail oder Passwort ungültig")
return
}
if !user.IsActive {
slog.Warn("Login verweigert: Benutzerkonto ist deaktiviert", "login", identifier, "user_id", user.ID, "display_name", user.DisplayName, "remote_addr", r.RemoteAddr)
writeError(w, http.StatusForbidden, "Dieses Benutzerkonto ist deaktiviert")
return
}
token, err := auth.GenerateToken(s.cfg.JWTSecret, user.ID, user.Email, user.DisplayName, user.TokenVersion)
if err != nil {
slog.Error("Login-Token konnte nicht erstellt werden", "login", identifier, "user_id", user.ID, "error", err)
writeError(w, http.StatusInternalServerError, "Token konnte nicht erstellt werden")
return
}
slog.Info("Login erfolgreich", "login", identifier, "user_id", user.ID, "display_name", user.DisplayName, "role", user.Role, "remote_addr", r.RemoteAddr)
writeJSON(w, http.StatusOK, map[string]interface{}{
"token": token,
"user": user,
})
}
func (s *Server) handleQuickStart(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusForbidden, "Automatischer Gast-Login ist deaktiviert – bitte anmelden")
}
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"`
Target string `json:"target"` // inbox | task | list_item | document
TargetListID string `json:"targetListId"`
TargetList2 string `json:"target_list_id"`
TaskListID string `json:"taskListId"`
TaskListID2 string `json:"task_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
}
taskListID := req.TaskListID
if taskListID == "" {
taskListID = req.TaskListID2
}
source := strings.ToLower(strings.TrimSpace(req.Source))
if source == "" {
source = strings.ToLower(strings.TrimSpace(r.URL.Query().Get("source")))
}
if source == "" {
source = strings.ToLower(strings.TrimSpace(r.Header.Get("X-Source")))
}
if source == "" {
// When called via API key or webhook token, automatically default source to "webhook"
if r.Header.Get("X-API-Key") != "" ||
strings.HasPrefix(r.Header.Get("Authorization"), "Bearer mindsift_sec_") ||
strings.HasPrefix(r.URL.Query().Get("token"), "mindsift_sec_") {
source = "webhook"
} else {
source = "manual"
}
}
thought, err := s.store.CreateThoughtWithDetails(userID, req.Content, req.Priority, source, dueDate, taskListID, "todo", "")
if err != nil {
writeStoreError(w, http.StatusBadRequest, err)
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, taskListID); convErr == nil && converted != nil {
thought = converted
if target == "list_item" && targetListID != "" {
if lst, lErr := s.store.GetListByID(userID, targetListID); lErr == nil && lst != nil {
s.wsHub.BroadcastListEvent(lst.ID, "LIST_UPDATED", lst)
}
}
}
}
s.wsHub.BroadcastTaskEvent(thought.UserID, "TASK_CREATED", thought)
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) handleGetThought(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
thoughtID := chi.URLParam(r, "id")
thought, err := s.store.GetThoughtByID(userID, thoughtID)
if err != nil || thought == nil {
writeError(w, http.StatusNotFound, "Aufgabe nicht gefunden")
return
}
writeJSON(w, http.StatusOK, thought)
}
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"`
TaskListID string `json:"taskListId"`
TaskListID2 string `json:"task_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
}
taskListID := req.TaskListID
if taskListID == "" {
taskListID = req.TaskListID2
}
dueDate := req.DueDate
if dueDate == "" {
dueDate = req.DueDate2
}
updated, err := s.store.UpdateThoughtTriage(userID, thoughtID, req.Action, convertTo, targetListID, dueDate, taskListID)
if err != nil {
writeStoreError(w, http.StatusBadRequest, err)
return
}
// If converted to a list item, broadcast via WebSocket to the space
if convertTo == "list_item" && targetListID != "" {
if lst, err := s.store.GetListByID(userID, targetListID); err == nil && lst != nil {
s.wsHub.BroadcastListEvent(lst.ID, "LIST_UPDATED", lst)
}
}
s.wsHub.BroadcastTaskEvent(updated.UserID, "TASK_UPDATED", updated)
writeJSON(w, http.StatusOK, updated)
}
func (s *Server) handleGetTasks(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
taskListID := r.URL.Query().Get("taskListId")
tasks, err := s.store.GetTasks(userID, taskListID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, tasks)
}
func (s *Server) handleReorderTasks(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
taskListID := chi.URLParam(r, "id")
if taskListID == "" {
taskListID = r.URL.Query().Get("taskListId")
}
var req struct {
Items []domain.ThoughtReorderItem `json:"items"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
if taskListID != "" && !s.store.CanEditTaskList(userID, taskListID) {
writeError(w, http.StatusForbidden, storage.ErrTaskListReadOnly.Error())
return
}
for _, it := range req.Items {
if th, err := s.store.GetThoughtByID(userID, it.ID); err == nil && th.TaskListID != "" && !s.store.CanEditTaskList(userID, th.TaskListID) {
writeError(w, http.StatusForbidden, storage.ErrTaskListReadOnly.Error())
return
}
}
if err := s.store.ReorderTasks(taskListID, req.Items); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
s.wsHub.BroadcastToAll("TASK_REORDERED", map[string]interface{}{
"taskListId": taskListID,
"items": req.Items,
"userId": userID,
})
writeJSON(w, http.StatusOK, map[string]bool{"success": true})
}
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"`
TaskListID *string `json:"taskListId"`
KanbanStatus *string `json:"kanbanStatus"`
Notes *string `json:"notes"`
Subtasks *[]domain.SubTask `json:"subtasks"`
Tags *[]string `json:"tags"`
}
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, req.TaskListID, req.KanbanStatus, req.Notes, req.Subtasks, req.Tags)
if err != nil {
writeStoreError(w, http.StatusBadRequest, err)
return
}
s.store.SyncDocBlockAndTask(userID, thoughtID, updated.IsCompleted)
s.wsHub.BroadcastToAll("TASK_UPDATED", updated)
writeJSON(w, http.StatusOK, updated)
}
func (s *Server) handleUnlinkEnhancedTask(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
thoughtID := chi.URLParam(r, "id")
s.store.UnlinkTaskFromDocBlock(userID, thoughtID)
writeJSON(w, http.StatusOK, map[string]bool{"unlinked": true})
}
func (s *Server) handleDeleteThought(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
thoughtID := chi.URLParam(r, "id")
if th, err := s.store.GetThoughtByID(userID, thoughtID); err == nil && th.TaskListID != "" && !s.store.CanEditTaskList(userID, th.TaskListID) {
writeError(w, http.StatusForbidden, storage.ErrTaskListReadOnly.Error())
return
}
s.store.UnlinkTaskFromDocBlock(userID, thoughtID)
if err := s.store.DeleteThought(userID, thoughtID); err != nil {
writeStoreError(w, http.StatusInternalServerError, err)
return
}
s.wsHub.BroadcastToAll("TASK_DELETED", map[string]string{"id": thoughtID})
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) broadcastDocToAuthorizedUsers(docID, event string) {
users, err := s.store.GetAuthorizedDocUsers(docID)
if err != nil {
return
}
var firstDoc *domain.Document
for _, uid := range users {
if userDoc, err := s.store.GetDocumentByID(uid, docID); err == nil && userDoc != nil {
if firstDoc == nil {
firstDoc = userDoc
}
s.wsHub.BroadcastToUser(uid, event, userDoc)
}
}
if s.wsHub != nil && firstDoc != nil {
s.wsHub.BroadcastRoom("doc:"+docID, event, firstDoc, nil)
}
}
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"`
FolderPath string `json:"folderPath"`
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.FolderPath, req.Blocks, req.Tags, req.IsPinned)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
s.broadcastDocToAuthorizedUsers(doc.ID, "DOC_CREATED")
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"`
FolderPath *string `json:"folderPath"`
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.FolderPath, req.Blocks, req.Tags, req.IsPinned)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if req.Title != nil || req.Icon != nil || req.FolderPath != nil || req.Blocks != nil || req.Tags != nil {
s.broadcastDocToAuthorizedUsers(docID, "DOC_UPDATED")
}
writeJSON(w, http.StatusOK, doc)
}
func (s *Server) handlePatchDocumentBlocks(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
docID := chi.URLParam(r, "id")
var req domain.DocBlocksPatchRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
doc, mergedOrder, err := s.store.PatchDocumentBlocks(userID, docID, req)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
patchEvent := domain.DocBlocksPatchEvent{
DocID: docID,
UpdatedBy: userID,
UpdatedAt: doc.UpdatedAt,
Upserted: req.Upserted,
DeletedIDs: req.DeletedIDs,
BlockOrder: mergedOrder,
}
s.wsHub.BroadcastDocEvent(docID, "DOC_BLOCKS_PATCHED", patchEvent)
if users, err := s.store.GetAuthorizedDocUsers(docID); err == nil {
for _, uid := range users {
s.wsHub.BroadcastToUser(uid, "DOC_BLOCKS_PATCHED", patchEvent)
}
}
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")
authorizedUsers, _ := s.store.GetAuthorizedDocUsers(docID)
if err := s.store.DeleteDocument(userID, docID); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
for _, uid := range authorizedUsers {
s.wsHub.BroadcastToUser(uid, "DOC_DELETED", map[string]string{"id": docID})
}
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
}
func (s *Server) handleCreateEnhancedTask(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
docID := chi.URLParam(r, "id")
blockID := chi.URLParam(r, "blockId")
var req struct {
Content string `json:"content"`
Checked bool `json:"checked"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
thought, err := s.store.CreateTaskFromDocBlock(userID, docID, blockID, req.Content, req.Checked)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
s.wsHub.BroadcastToUser(userID, "TASK_CREATED", thought)
writeJSON(w, http.StatusCreated, thought)
}
// --- WORKSPACE FOLDERS ---
func (s *Server) handleGetFolders(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
folders, err := s.store.GetFolders(userID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, folders)
}
func (s *Server) handleCreateFolder(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
var req struct {
Path string `json:"path"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
cleanPath, err := s.store.CreateFolder(userID, req.Path)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
s.wsHub.BroadcastDocEvent("", "WORKSPACE_FOLDERS_UPDATED", map[string]string{"path": cleanPath})
writeJSON(w, http.StatusCreated, map[string]string{"path": cleanPath})
}
func (s *Server) handleDeleteFolder(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
folderPath := r.URL.Query().Get("path")
if folderPath == "" {
writeError(w, http.StatusBadRequest, "Pfad erforderlich")
return
}
deletedDocIDs, err := s.store.DeleteFolder(userID, folderPath)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
for _, docID := range deletedDocIDs {
s.wsHub.BroadcastDocEvent(docID, "DOC_DELETED", map[string]string{"id": docID})
}
s.wsHub.BroadcastDocEvent("", "WORKSPACE_FOLDERS_UPDATED", map[string]string{"path": folderPath})
writeJSON(w, http.StatusOK, map[string]interface{}{
"deleted": true,
"deletedDocIds": deletedDocIDs,
})
}
func (s *Server) handleMoveFolder(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
var req struct {
SourcePath string `json:"sourcePath"`
TargetParentPath string `json:"targetParentPath"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
newPath, movedDocIDs, err := s.store.MoveFolder(userID, req.SourcePath, req.TargetParentPath)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
for _, id := range movedDocIDs {
if doc, err := s.store.GetDocumentByID(userID, id); err == nil && doc != nil {
s.wsHub.BroadcastDocEvent(id, "DOC_UPDATED", doc)
s.broadcastDocToAuthorizedUsers(id, "DOC_UPDATED")
}
}
s.wsHub.BroadcastDocEvent("", "WORKSPACE_FOLDERS_UPDATED", map[string]string{
"sourcePath": req.SourcePath,
"newPath": newPath,
})
writeJSON(w, http.StatusOK, map[string]interface{}{
"newPath": newPath,
"movedDocIds": movedDocIDs,
})
}
// --- WORKSPACE SHARED FOLDERS ---
func (s *Server) handleGetSharedFolders(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
folders, err := s.store.GetSharedFolders(userID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, folders)
}
func (s *Server) handleCreateSharedFolder(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
var req struct {
Name string `json:"name"`
MountPath string `json:"mountPath"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
folder, err := s.store.CreateSharedFolder(userID, req.Name, req.MountPath)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
s.wsHub.BroadcastDocEvent("", "WORKSPACE_FOLDERS_UPDATED", folder)
writeJSON(w, http.StatusCreated, folder)
}
func (s *Server) handleGetSharedFolderByID(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
folderID := chi.URLParam(r, "id")
folder, err := s.store.GetSharedFolderByID(userID, folderID)
if err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
writeJSON(w, http.StatusOK, folder)
}
func (s *Server) handleDeleteSharedFolder(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
folderID := chi.URLParam(r, "id")
if err := s.store.DeleteSharedFolder(userID, folderID); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
s.wsHub.BroadcastDocEvent("", "WORKSPACE_FOLDERS_UPDATED", map[string]string{"folderId": folderID})
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
}
func (s *Server) handleUpdateFolderMount(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
folderID := chi.URLParam(r, "id")
var req struct {
MountPath string `json:"mountPath"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
if err := s.store.UpdateFolderMount(userID, folderID, req.MountPath); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
s.wsHub.BroadcastToUser(userID, "WORKSPACE_FOLDERS_UPDATED", map[string]string{"folderId": folderID, "mountPath": req.MountPath})
writeJSON(w, http.StatusOK, map[string]bool{"updated": true})
}
func (s *Server) handleGetFolderShares(w http.ResponseWriter, r *http.Request) {
folderID := chi.URLParam(r, "id")
shares, err := s.store.GetFolderShares(folderID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, shares)
}
func (s *Server) handleCreateFolderShare(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
folderID := chi.URLParam(r, "id")
var req struct {
TargetType string `json:"targetType"` // team | user
TargetID string `json:"targetId"`
UserId string `json:"userId"`
Email string `json:"email"`
TeamId string `json:"teamId"`
Permission string `json:"permission"` // read | write
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
targetType := req.TargetType
targetIdentifier := req.TargetID
if targetIdentifier == "" {
if req.TeamId != "" {
targetType = "team"
targetIdentifier = req.TeamId
} else if req.UserId != "" {
targetType = "user"
targetIdentifier = req.UserId
} else if req.Email != "" {
targetType = "user"
targetIdentifier = req.Email
}
}
share, err := s.store.CreateFolderShare(userID, folderID, targetType, targetIdentifier, req.Permission)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
s.wsHub.BroadcastDocEvent("", "WORKSPACE_FOLDERS_UPDATED", share)
if docIDs, err := s.store.GetSharedFolderDocIDs(folderID); err == nil {
for _, docID := range docIDs {
s.broadcastDocToAuthorizedUsers(docID, "DOC_CREATED")
}
}
writeJSON(w, http.StatusCreated, share)
}
func (s *Server) handleDeleteFolderShare(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
folderID := chi.URLParam(r, "id")
shareID := chi.URLParam(r, "shareId")
docIDs, _ := s.store.GetSharedFolderDocIDs(folderID)
if err := s.store.DeleteFolderShare(userID, folderID, shareID); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
s.wsHub.BroadcastDocEvent("", "WORKSPACE_FOLDERS_UPDATED", map[string]string{"folderId": folderID, "shareId": shareID})
for _, docID := range docIDs {
s.wsHub.BroadcastToUser(shareID, "DOC_DELETED", map[string]string{"id": docID})
s.broadcastDocToAuthorizedUsers(docID, "DOC_UPDATED")
}
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
}
func (s *Server) handleMoveDocument(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
docID := chi.URLParam(r, "id")
var req struct {
FolderPath string `json:"folderPath"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
doc, err := s.store.MoveDocument(userID, docID, req.FolderPath)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
s.wsHub.BroadcastDocEvent(docID, "DOC_UPDATED", doc)
s.broadcastDocToAuthorizedUsers(docID, "DOC_UPDATED")
s.wsHub.BroadcastDocEvent("", "WORKSPACE_FOLDERS_UPDATED", nil)
writeJSON(w, http.StatusOK, doc)
}
// --- WORKSPACE TAGS & PAGES ---
func (s *Server) handleGetWorkspaceTags(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
tags, err := s.store.GetWorkspaceTags(userID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, tags)
}
func (s *Server) handleGetTagDetails(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
tag := chi.URLParam(r, "tag")
details, err := s.store.GetTagDetails(userID, tag)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, details)
}
func (s *Server) handleUpdateTagPageNote(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
tag := chi.URLParam(r, "tag")
var req struct {
Content string `json:"content"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
updatedAt, err := s.store.UpdateTagPageNote(userID, tag, req.Content)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{
"tag": tag,
"content": req.Content,
"updatedAt": updatedAt,
})
}
// --- DOCUMENT SHARING & COLLABORATION ---
func (s *Server) handleGetDocShares(w http.ResponseWriter, r *http.Request) {
docID := chi.URLParam(r, "id")
shares, err := s.store.GetDocShares(docID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, shares)
}
func (s *Server) handleCreateDocShare(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
docID := chi.URLParam(r, "id")
var req struct {
UserID string `json:"userId"`
Email string `json:"email"`
TeamID string `json:"teamId"`
Permission string `json:"permission"` // read | write
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
if req.TeamID != "" {
share, err := s.store.CreateDocTeamShare(userID, docID, req.TeamID, req.Permission)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
s.broadcastDocToAuthorizedUsers(docID, "DOC_CREATED")
s.broadcastDocToAuthorizedUsers(docID, "DOC_UPDATED")
writeJSON(w, http.StatusCreated, share)
return
}
target := req.UserID
if target == "" {
target = req.Email
}
if target == "" {
writeError(w, http.StatusBadRequest, "Benutzer-ID, E-Mail oder Team erforderlich")
return
}
share, err := s.store.CreateDocShare(userID, docID, target, req.Permission)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
s.broadcastDocToAuthorizedUsers(docID, "DOC_CREATED")
s.broadcastDocToAuthorizedUsers(docID, "DOC_UPDATED")
writeJSON(w, http.StatusCreated, share)
}
func (s *Server) handleDeleteDocShare(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
docID := chi.URLParam(r, "id")
targetUserID := chi.URLParam(r, "userId")
if err := s.store.DeleteDocShare(userID, docID, targetUserID); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
s.wsHub.BroadcastToUser(targetUserID, "DOC_DELETED", map[string]string{"id": docID})
s.broadcastDocToAuthorizedUsers(docID, "DOC_UPDATED")
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
}
func (s *Server) handleGetCollaborators(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
users, err := s.store.GetCollaborators(userID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, users)
}
func (s *Server) authenticateUserFromReq(r *http.Request) string {
token := r.URL.Query().Get("token")
if token == "" {
if authHdr := r.Header.Get("Authorization"); strings.HasPrefix(authHdr, "Bearer ") {
token = strings.TrimPrefix(authHdr, "Bearer ")
}
}
if token != "" {
if strings.HasPrefix(token, "mindsift_sec_") {
if user, err := s.store.ValidateAPIKeyAndGetUser(token); err == nil && user != nil && user.IsActive {
return user.ID
}
}
if claims, err := auth.ValidateToken(s.cfg.JWTSecret, token); err == nil && claims != nil {
if !s.store.IsTokenRevoked(token) {
if user, err := s.store.GetUserByID(claims.UserID); err == nil && user != nil && user.IsActive {
if claims.TokenVersion == 0 || claims.TokenVersion == user.TokenVersion {
return claims.UserID
}
}
}
}
}
return ""
}
// --- REALTIME LISTS & SHARING ---
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) handleGetListByID(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
listID := chi.URLParam(r, "id")
lst, err := s.store.GetListByID(userID, listID)
if err != nil {
writeError(w, http.StatusNotFound, "Liste nicht gefunden")
return
}
writeJSON(w, http.StatusOK, lst)
}
func (s *Server) handleCreateList(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
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(userID, req.Title, req.Icon, req.ListType)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
s.wsHub.BroadcastListEvent(lst.ID, "LIST_CREATED", lst)
writeJSON(w, http.StatusCreated, lst)
}
func (s *Server) handleDeleteList(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
listID := chi.URLParam(r, "id")
if err := s.store.DeleteList(userID, listID); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
s.wsHub.BroadcastListEvent(listID, "LIST_DELETED", map[string]string{"listId": listID})
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
}
func (s *Server) handleGetListShares(w http.ResponseWriter, r *http.Request) {
listID := chi.URLParam(r, "id")
shares, err := s.store.GetListShares(listID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, shares)
}
func (s *Server) handleCreateListShare(w http.ResponseWriter, r *http.Request) {
currentUserID := auth.GetUserID(r.Context())
listID := chi.URLParam(r, "id")
var req struct {
TargetType string `json:"targetType"` // "user" or "team"
TargetID string `json:"targetId"` // userId/email or teamId
Role string `json:"role"` // "editor" or "viewer"
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
targetType := strings.ToLower(strings.TrimSpace(req.TargetType))
if targetType != "team" {
targetType = "user"
}
role := strings.ToLower(strings.TrimSpace(req.Role))
if role != "viewer" && role != "editor" {
role = "editor"
}
share, err := s.store.AddListShare(currentUserID, listID, targetType, req.TargetID, role)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if targetType == "team" {
members, _ := s.store.GetTeamMembers(share.TargetID)
for _, tm := range members {
s.wsHub.BroadcastToUser(tm.UserID, "LIST_SHARED", share)
}
} else {
s.wsHub.BroadcastToUser(share.TargetID, "LIST_SHARED", share)
}
s.wsHub.BroadcastListEvent(listID, "LIST_SHARED", share)
writeJSON(w, http.StatusCreated, share)
}
func (s *Server) handleDeleteListShare(w http.ResponseWriter, r *http.Request) {
currentUserID := auth.GetUserID(r.Context())
listID := chi.URLParam(r, "id")
shareID := chi.URLParam(r, "shareId")
if err := s.store.RemoveListShare(currentUserID, listID, shareID); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
s.wsHub.BroadcastListEvent(listID, "LIST_UNSHARED", map[string]string{
"listId": listID,
"shareId": shareID,
})
writeJSON(w, http.StatusOK, map[string]bool{"success": 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
}
s.wsHub.BroadcastListEvent(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 := "Mitglied"
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
}
s.wsHub.BroadcastListEvent(listID, "ITEM_TOGGLED", item)
writeJSON(w, http.StatusOK, item)
}
func (s *Server) handleUpdateListItem(w http.ResponseWriter, r *http.Request) {
listID := chi.URLParam(r, "id")
itemID := chi.URLParam(r, "itemId")
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.UpdateListItem(itemID, req.Content, req.Category, req.Quantity)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
s.wsHub.BroadcastListEvent(listID, "ITEM_UPDATED", 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
}
s.wsHub.BroadcastListEvent(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
}
s.wsHub.BroadcastListEvent(listID, "LIST_CLEARED", map[string]interface{}{
"listId": listID,
"removed": removed,
})
writeJSON(w, http.StatusOK, map[string]int64{"removed": removed})
}
func (s *Server) handleReorderListItems(w http.ResponseWriter, r *http.Request) {
listID := chi.URLParam(r, "id")
var req struct {
Items []domain.ListItemReorderItem `json:"items"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Invalid request body")
return
}
if err := s.store.ReorderListItems(listID, req.Items); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
s.wsHub.BroadcastListEvent(listID, "ITEM_REORDERED", map[string]interface{}{
"listId": listID,
"items": req.Items,
})
writeJSON(w, http.StatusOK, map[string]bool{"success": true})
}
func (s *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) {
// Some corporate proxies and reverse proxies (e.g. OpenResty/Nginx or HTTP/2 intermediaries)
// strip or alter the Connection header (e.g. "close" or missing) while passing
// Sec-WebSocket-Key or Upgrade: websocket. Normalize it so Gorilla Upgrader succeeds.
if r.Header.Get("Sec-Websocket-Key") != "" {
if !strings.Contains(strings.ToLower(r.Header.Get("Connection")), "upgrade") {
r.Header.Set("Connection", "Upgrade")
}
if !strings.Contains(strings.ToLower(r.Header.Get("Upgrade")), "websocket") {
r.Header.Set("Upgrade", "websocket")
}
}
roomID := chi.URLParam(r, "listId")
if roomID == "" {
roomID = chi.URLParam(r, "spaceId")
}
if roomID == "" {
roomID = r.URL.Query().Get("listId")
}
if roomID == "" {
roomID = r.URL.Query().Get("spaceId")
}
userID := s.authenticateUserFromReq(r)
if userID == "" {
userID = "anonymous"
}
s.wsHub.ServeWS(w, r, roomID, userID)
}
func (s *Server) handleSSE(w http.ResponseWriter, r *http.Request) {
roomID := r.URL.Query().Get("listId")
if roomID == "" {
roomID = r.URL.Query().Get("spaceId")
}
userID := s.authenticateUserFromReq(r)
if userID == "" {
userID = "anonymous"
}
var initialRooms []string
if roomsParam := r.URL.Query().Get("rooms"); roomsParam != "" {
for _, rm := range strings.Split(roomsParam, ",") {
rm = strings.TrimSpace(rm)
if rm != "" {
initialRooms = append(initialRooms, rm)
}
}
}
if docID := r.URL.Query().Get("docId"); docID != "" {
initialRooms = append(initialRooms, "doc:"+strings.TrimSpace(docID))
}
s.wsHub.ServeSSE(w, r, roomID, userID, initialRooms)
}
func (s *Server) handleSSERooms(w http.ResponseWriter, r *http.Request) {
var req struct {
SSEID string `json:"sseId"`
Room string `json:"room"`
DocID string `json:"docId"`
Action string `json:"action"` // "join" or "leave"
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
if req.Room == "" && req.DocID != "" {
req.Room = "doc:" + req.DocID
}
if req.SSEID == "" || req.Room == "" {
writeError(w, http.StatusBadRequest, "sseId und room erforderlich")
return
}
if req.Action == "leave" {
s.wsHub.LeaveSSERoomByID(req.SSEID, req.Room)
} else {
s.wsHub.JoinSSERoomByID(req.SSEID, req.Room)
}
writeJSON(w, http.StatusOK, map[string]bool{"success": true})
}
// --- 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"`
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"`
}
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)
}
}
if req.Priority == "" {
req.Priority = "high"
}
source := strings.ToLower(strings.TrimSpace(req.Source))
if source == "" {
source = "webhook"
}
thought, err := s.store.CreateThought(user.ID, bodyText, req.Priority, source, dueDate)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
s.wsHub.BroadcastTaskEvent(thought.UserID, "TASK_CREATED", thought)
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)
}
// --- USER SETTINGS & ALARMS ---
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
authHeader := r.Header.Get("Authorization")
if strings.HasPrefix(authHeader, "Bearer ") {
tokenStr := strings.TrimPrefix(authHeader, "Bearer ")
if claims, err := auth.ValidateToken(s.cfg.JWTSecret, tokenStr); err == nil && claims != nil {
var exp time.Time
if claims.ExpiresAt != nil {
exp = claims.ExpiresAt.Time
} else {
exp = time.Now().Add(30 * 24 * time.Hour)
}
_ = s.store.RevokeToken(tokenStr, userID, exp)
}
}
writeJSON(w, http.StatusOK, map[string]bool{"success": true})
}
func (s *Server) handleRevokeAllSessions(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
if err := s.store.RevokeAllUserTokens(userID); err != nil {
writeError(w, http.StatusInternalServerError, "Sitzungen konnten nicht beendet werden")
return
}
writeJSON(w, http.StatusOK, map[string]bool{"success": true})
}
func (s *Server) handleUpdateUserSettings(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
var req struct {
DisplayName *string `json:"displayName"`
Email *string `json:"email"`
Password *string `json:"password"`
AvatarURL *string `json:"avatarUrl"`
AlarmChannels *[]string `json:"alarmChannels"`
AlarmEmail *string `json:"alarmEmail"`
DiscordWebhookURL *string `json:"discordWebhookUrl"`
CustomWebhookURL *string `json:"customWebhookUrl"`
NotifyBeforeMinutes *int `json:"notifyBeforeMinutes"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
if req.Password != nil && strings.TrimSpace(*req.Password) != "" {
hash, err := auth.HashPassword(*req.Password)
if err != nil {
writeError(w, http.StatusInternalServerError, "Fehler beim Hashen des Passworts")
return
}
_ = s.store.UpdateUserPassword(userID, hash)
}
u, err := s.store.UpdateUserFull(
userID,
req.DisplayName,
req.Email,
nil,
nil,
req.AlarmChannels,
req.AlarmEmail,
req.DiscordWebhookURL,
req.CustomWebhookURL,
req.NotifyBeforeMinutes,
req.AvatarURL,
)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, u)
}
func (s *Server) handleTestUserAlarm(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
user, err := s.store.GetUserByID(userID)
if err != nil || user == nil {
writeError(w, http.StatusNotFound, "Benutzer nicht gefunden")
return
}
var req struct {
Channel string `json:"channel"` // email | discord | webhook
Target string `json:"target"` // optional override URL or email
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
channel := strings.ToLower(strings.TrimSpace(req.Channel))
target := strings.TrimSpace(req.Target)
switch channel {
case "discord":
url := target
if url == "" {
url = user.DiscordWebhookURL
}
if url == "" {
writeError(w, http.StatusBadRequest, "Bitte eine Discord Webhook URL angeben")
return
}
if err := s.sendDiscordNotification(url, "Test-Alarm von MindSift", "Dein Discord-Alarmkanal funktioniert einwandfrei!", domain.NowISO()[:10], "normal"); err != nil {
writeError(w, http.StatusBadGateway, "Discord-Fehler: "+err.Error())
return
}
case "webhook":
url := target
if url == "" {
url = user.CustomWebhookURL
}
if url == "" {
writeError(w, http.StatusBadRequest, "Bitte eine Webhook-URL angeben")
return
}
if err := s.sendCustomWebhookNotification(url, "test_alarm", "Test-Alarm von MindSift", "Dein eigener Webhook empfängt MindSift-Alarme!", domain.NowISO()[:10], "normal"); err != nil {
writeError(w, http.StatusBadGateway, "Webhook-Fehler: "+err.Error())
return
}
case "email":
toEmail := target
if toEmail == "" {
toEmail = user.AlarmEmail
}
if toEmail == "" {
toEmail = user.Email
}
if err := s.sendEmailNotification(toEmail, "⏰ MindSift Test-Alarm", "Hallo "+user.DisplayName+",\n\ndein E-Mail-Alarmkanal in MindSift ist erfolgreich eingerichtet und einsatzbereit.\n\nViele Grüße\nDeine MindSift Instanz"); err != nil {
writeError(w, http.StatusBadGateway, "E-Mail-Versand fehlgeschlagen: "+err.Error())
return
}
default:
writeError(w, http.StatusBadRequest, "Unbekannter Alarm-Kanal (erlaubt: email, discord, webhook)")
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"sent": true,
"channel": channel,
})
}
func (s *Server) handleGetUsersDirectory(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
users, err := s.store.GetDiscoverableUsers(userID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, users)
}
// --- TASK LISTS ---
func (s *Server) handleGetTaskLists(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
lists, err := s.store.GetTaskLists(userID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, lists)
}
func (s *Server) handleCreateTaskList(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
var req struct {
SpaceID string `json:"spaceId"`
TeamID string `json:"teamId"`
Title string `json:"title"`
Icon string `json:"icon"`
Color string `json:"color"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
tl, err := s.store.CreateTaskList(userID, req.SpaceID, req.TeamID, req.Title, req.Icon, req.Color)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusCreated, tl)
}
func (s *Server) handleUpdateTaskList(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
listID := chi.URLParam(r, "id")
var req struct {
Title *string `json:"title"`
Icon *string `json:"icon"`
Color *string `json:"color"`
TeamID *string `json:"teamId"`
SpaceID *string `json:"spaceId"`
Columns *[]domain.KanbanColumn `json:"columns"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
tl, err := s.store.UpdateTaskList(userID, listID, req.Title, req.Icon, req.Color, req.TeamID, req.SpaceID, req.Columns)
if err != nil {
writeStoreError(w, http.StatusBadRequest, err)
return
}
s.wsHub.BroadcastToAll("TASK_LIST_UPDATED", tl)
writeJSON(w, http.StatusOK, tl)
}
func (s *Server) handleDeleteTaskList(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
listID := chi.URLParam(r, "id")
if err := s.store.DeleteTaskList(userID, listID); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
s.wsHub.BroadcastToAll("TASK_LIST_DELETED", map[string]string{"id": listID})
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
}
func (s *Server) handleGetTaskListShares(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
listID := chi.URLParam(r, "id")
if _, err := s.store.GetTaskListByID(userID, listID); err != nil {
writeError(w, http.StatusNotFound, "Aufgabenliste nicht gefunden oder kein Zugriff")
return
}
shares, err := s.store.GetTaskListShares(listID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, shares)
}
func (s *Server) handleCreateTaskListShare(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
listID := chi.URLParam(r, "id")
tl, err := s.store.GetTaskListByID(userID, listID)
if err != nil || tl == nil {
writeError(w, http.StatusNotFound, "Aufgabenliste nicht gefunden")
return
}
if tl.UserID != userID {
writeError(w, http.StatusForbidden, "Nur der Eigentümer kann die Liste freigeben")
return
}
var req struct {
TargetType string `json:"targetType"` // team | user
TargetID string `json:"targetId"`
Permission string `json:"permission"` // editor | viewer
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
permission := strings.ToLower(strings.TrimSpace(req.Permission))
if permission != "viewer" {
permission = "editor"
}
share, err := s.store.AddTaskListShare(listID, req.TargetType, req.TargetID, permission, userID)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
updatedList, _ := s.store.GetTaskListByID(userID, listID)
s.wsHub.BroadcastToAll("TASK_LIST_UPDATED", updatedList)
writeJSON(w, http.StatusCreated, share)
}
func (s *Server) handleDeleteTaskListShare(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
listID := chi.URLParam(r, "id")
shareID := chi.URLParam(r, "shareId")
tl, err := s.store.GetTaskListByID(userID, listID)
if err != nil || tl == nil {
writeError(w, http.StatusNotFound, "Aufgabenliste nicht gefunden")
return
}
if tl.UserID != userID {
writeError(w, http.StatusForbidden, "Nur der Eigentümer kann Freigaben entfernen")
return
}
if err := s.store.RemoveTaskListShare(listID, shareID); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
updatedList, _ := s.store.GetTaskListByID(userID, listID)
s.wsHub.BroadcastToAll("TASK_LIST_UPDATED", updatedList)
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
}
// --- ORGANIZATIONS & TEAMS ---
func (s *Server) handleGetOrganizations(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
orgs, err := s.store.GetOrganizationsForUser(userID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, orgs)
}
func (s *Server) handleCreateOrganization(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
var req struct {
Name string `json:"name"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
org, err := s.store.CreateOrganization(userID, req.Name)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusCreated, org)
}
func (s *Server) handleDeleteOrganization(w http.ResponseWriter, r *http.Request) {
orgID := chi.URLParam(r, "id")
if err := s.store.DeleteOrganization(orgID); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
}
func (s *Server) handleGetOrgTeams(w http.ResponseWriter, r *http.Request) {
orgID := chi.URLParam(r, "id")
teams, err := s.store.GetTeamsForOrg(orgID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, teams)
}
func (s *Server) handleCreateOrgTeam(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
orgID := chi.URLParam(r, "id")
var req struct {
Name string `json:"name"`
Description string `json:"description"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
team, err := s.store.CreateTeam(orgID, req.Name, req.Description)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
// Automatically add creator to team
_ = s.store.AddTeamMember(team.ID, userID, "admin")
team.Members, _ = s.store.GetTeamMembers(team.ID)
writeJSON(w, http.StatusCreated, team)
}
func (s *Server) isTeamAdmin(userID, teamID string) bool {
if userID == "" || teamID == "" {
return false
}
role, err := s.store.GetTeamMemberRole(teamID, userID)
if err != nil {
return false
}
return role == "admin"
}
func (s *Server) handleCreateTeamDirect(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
var req struct {
Name string `json:"name"`
Description string `json:"description"`
OrgID string `json:"orgId"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
name := strings.TrimSpace(req.Name)
if name == "" {
writeError(w, http.StatusBadRequest, "Name für das Team / die Gruppe ist erforderlich")
return
}
orgID := strings.TrimSpace(req.OrgID)
if orgID == "" {
orgID = "default"
}
team, err := s.store.CreateTeam(orgID, name, req.Description)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
// Automatically add creator to team as group admin
_ = s.store.AddTeamMember(team.ID, userID, "admin")
team.Members, _ = s.store.GetTeamMembers(team.ID)
writeJSON(w, http.StatusCreated, team)
}
func (s *Server) handleUpdateTeam(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
teamID := chi.URLParam(r, "id")
if !s.isTeamAdmin(userID, teamID) {
writeError(w, http.StatusForbidden, "Nur Gruppen-Administratoren können die Gruppe bearbeiten")
return
}
var req struct {
Name string `json:"name"`
Description string `json:"description"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
name := strings.TrimSpace(req.Name)
if name == "" {
writeError(w, http.StatusBadRequest, "Name für das Team / die Gruppe ist erforderlich")
return
}
updated, err := s.store.UpdateTeam(teamID, name, req.Description)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, updated)
}
func (s *Server) handleDeleteTeam(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
teamID := chi.URLParam(r, "id")
if !s.isTeamAdmin(userID, teamID) {
writeError(w, http.StatusForbidden, "Nur Gruppen-Administratoren können die Gruppe löschen")
return
}
if err := s.store.DeleteTeam(teamID); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
}
func (s *Server) handleGetUserTeams(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
teams, err := s.store.GetTeamsForUser(userID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, teams)
}
func (s *Server) handleAddTeamMember(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
teamID := chi.URLParam(r, "id")
if !s.isTeamAdmin(userID, teamID) {
writeError(w, http.StatusForbidden, "Nur Gruppen-Administratoren können Mitglieder hinzufügen")
return
}
var req struct {
UserID string `json:"userId"`
Email string `json:"email"`
Role string `json:"role"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
targetUserID := strings.TrimSpace(req.UserID)
if targetUserID == "" && strings.TrimSpace(req.Email) != "" {
u, err := s.store.GetUserByEmail(req.Email)
if err != nil || u == nil {
writeError(w, http.StatusNotFound, "Kein Benutzer mit dieser E-Mail gefunden")
return
}
targetUserID = u.ID
}
if targetUserID == "" {
writeError(w, http.StatusBadRequest, "Benutzer-ID oder E-Mail erforderlich")
return
}
role := strings.ToLower(strings.TrimSpace(req.Role))
if role != "admin" && role != "member" {
role = "member"
}
if err := s.store.AddTeamMember(teamID, targetUserID, role); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
members, _ := s.store.GetTeamMembers(teamID)
writeJSON(w, http.StatusOK, members)
}
func (s *Server) handleUpdateTeamMemberRole(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
teamID := chi.URLParam(r, "id")
memberUserID := chi.URLParam(r, "userId")
if !s.isTeamAdmin(userID, teamID) {
writeError(w, http.StatusForbidden, "Nur Gruppen-Administratoren können Mitgliedsrollen ändern")
return
}
var req struct {
Role string `json:"role"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
role := strings.ToLower(strings.TrimSpace(req.Role))
if role != "admin" && role != "member" {
writeError(w, http.StatusBadRequest, "Ungültige Rolle (erlaubt sind: admin, member)")
return
}
if err := s.store.UpdateTeamMemberRole(teamID, memberUserID, role); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
members, _ := s.store.GetTeamMembers(teamID)
writeJSON(w, http.StatusOK, members)
}
func (s *Server) handleRemoveTeamMember(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
teamID := chi.URLParam(r, "id")
memberUserID := chi.URLParam(r, "userId")
isSelf := userID == memberUserID
isAdmin := s.isTeamAdmin(userID, teamID)
if !isSelf && !isAdmin {
writeError(w, http.StatusForbidden, "Nicht berechtigt, dieses Mitglied zu entfernen")
return
}
if err := s.store.RemoveTeamMember(teamID, memberUserID); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]bool{"removed": true})
}
// --- ADMIN & STAFF PANEL ---
func (s *Server) handleAdminGetUsers(w http.ResponseWriter, r *http.Request) {
users, err := s.store.GetAllUsers()
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, users)
}
func (s *Server) handleAdminCreateUser(w http.ResponseWriter, r *http.Request) {
callerID := auth.GetUserID(r.Context())
caller, _ := s.store.GetUserByID(callerID)
var req struct {
Email string `json:"email"`
Password string `json:"password"`
DisplayName string `json:"displayName"`
Role string `json:"role"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
if req.Email == "" || req.Password == "" {
writeError(w, http.StatusBadRequest, "E-Mail und Passwort erforderlich")
return
}
// Only full server admin can create another server admin
if req.Role == "admin" && (caller == nil || caller.Role != "admin") {
req.Role = "member"
}
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, "Passwort-Hash fehlgeschlagen")
return
}
u, err := s.store.CreateUser(req.Email, hash, req.DisplayName, req.Role)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusCreated, u)
}
func (s *Server) handleAdminUpdateUser(w http.ResponseWriter, r *http.Request) {
callerID := auth.GetUserID(r.Context())
caller, _ := s.store.GetUserByID(callerID)
targetUserID := chi.URLParam(r, "id")
targetUser, err := s.store.GetUserByID(targetUserID)
if err != nil || targetUser == nil {
writeError(w, http.StatusNotFound, "Benutzer nicht gefunden")
return
}
// Team-Admins cannot modify Server-Admins
if caller != nil && caller.Role != "admin" && targetUser.Role == "admin" {
writeError(w, http.StatusForbidden, "Nur Server-Administratoren können Server-Admin-Konten bearbeiten")
return
}
var req struct {
DisplayName *string `json:"displayName"`
Email *string `json:"email"`
Role *string `json:"role"`
IsActive *bool `json:"isActive"`
DiscordWebhookURL *string `json:"discordWebhookUrl"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
if req.Role != nil && *req.Role == "admin" && (caller == nil || caller.Role != "admin") {
writeError(w, http.StatusForbidden, "Nur Server-Administratoren können die Rolle Server-Admin vergeben")
return
}
u, err := s.store.UpdateUser(targetUserID, req.DisplayName, req.Email, req.Role, req.IsActive, req.DiscordWebhookURL, nil)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, u)
}
func (s *Server) handleAdminResetPassword(w http.ResponseWriter, r *http.Request) {
callerID := auth.GetUserID(r.Context())
caller, _ := s.store.GetUserByID(callerID)
targetUserID := chi.URLParam(r, "id")
targetUser, _ := s.store.GetUserByID(targetUserID)
if caller != nil && caller.Role != "admin" && targetUser != nil && targetUser.Role == "admin" {
writeError(w, http.StatusForbidden, "Keine Berechtigung")
return
}
var req struct {
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || strings.TrimSpace(req.Password) == "" {
writeError(w, http.StatusBadRequest, "Neues Passwort erforderlich")
return
}
hash, err := auth.HashPassword(req.Password)
if err != nil {
writeError(w, http.StatusInternalServerError, "Passwort-Hash fehlgeschlagen")
return
}
if err := s.store.UpdateUserPassword(targetUserID, hash); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]bool{"success": true})
}
func (s *Server) handleAdminDeleteUser(w http.ResponseWriter, r *http.Request) {
targetUserID := chi.URLParam(r, "id")
currUserID := auth.GetUserID(r.Context())
if targetUserID == currUserID {
writeError(w, http.StatusBadRequest, "Sie können Ihren eigenen Benutzer nicht löschen")
return
}
caller, _ := s.store.GetUserByID(currUserID)
targetUser, _ := s.store.GetUserByID(targetUserID)
if caller != nil && caller.Role != "admin" && targetUser != nil && targetUser.Role == "admin" {
writeError(w, http.StatusForbidden, "Keine Berechtigung zum Löschen eines Server-Admins")
return
}
if err := s.store.DeleteUser(targetUserID); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
}
// --- INVITES MANAGEMENT ---
func (s *Server) handleAdminGetInvites(w http.ResponseWriter, r *http.Request) {
invites, err := s.store.GetUserInvites()
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, invites)
}
func (s *Server) handleAdminCreateInvite(w http.ResponseWriter, r *http.Request) {
callerID := auth.GetUserID(r.Context())
caller, _ := s.store.GetUserByID(callerID)
var req struct {
Email string `json:"email"`
Role string `json:"role"`
TeamID string `json:"teamId"`
ValidDays int `json:"validDays"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
if req.Role == "admin" && (caller == nil || caller.Role != "admin") {
req.Role = "member"
}
inv, err := s.store.CreateUserInvite(callerID, req.Email, req.Role, req.TeamID, req.ValidDays)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusCreated, inv)
}
func (s *Server) handleAdminDeleteInvite(w http.ResponseWriter, r *http.Request) {
inviteID := chi.URLParam(r, "id")
if err := s.store.DeleteUserInvite(inviteID); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
}
// --- SERVER SETTINGS & SMTP ---
func (s *Server) handleAdminGetSettings(w http.ResponseWriter, r *http.Request) {
settings, err := s.store.GetSystemSettings()
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
settings.SMTPConfiguredViaEnv = strings.TrimSpace(s.cfg.SMTPHost) != ""
writeJSON(w, http.StatusOK, settings)
}
func (s *Server) handleAdminUpdateSettings(w http.ResponseWriter, r *http.Request) {
var settings domain.SystemSettings
if err := json.NewDecoder(r.Body).Decode(&settings); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return
}
if err := s.store.UpdateSystemSettings(&settings); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
settings.SMTPConfiguredViaEnv = strings.TrimSpace(s.cfg.SMTPHost) != ""
writeJSON(w, http.StatusOK, settings)
}
func (s *Server) handleAdminTestSMTP(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context())
user, _ := s.store.GetUserByID(userID)
var req struct {
ToEmail string `json:"toEmail"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
toEmail := strings.TrimSpace(req.ToEmail)
if toEmail == "" && user != nil {
toEmail = user.Email
}
if toEmail == "" {
writeError(w, http.StatusBadRequest, "Ziel-E-Mail-Adresse erforderlich")
return
}
if err := s.sendEmailNotification(toEmail, "MindSift SMTP-Server Test", "Die SMTP-Konfiguration deiner MindSift-Instanz funktioniert einwandfrei!"); err != nil {
writeError(w, http.StatusBadGateway, "SMTP-Fehler: "+err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"sent": true,
"toEmail": toEmail,
})
}
// --- MULTI-CHANNEL ALARM DISPATCHER (EMAIL, DISCORD, CUSTOM WEBHOOK) ---
func (s *Server) sendEmailNotification(toEmail, subject, bodyText string) error {
sys, _ := s.store.GetSystemSettings()
host := ""
port := "587"
user := ""
pass := ""
from := ""
useTLS := true
if sys != nil && strings.TrimSpace(sys.SMTPHost) != "" {
host = strings.TrimSpace(sys.SMTPHost)
port = strings.TrimSpace(sys.SMTPPort)
user = strings.TrimSpace(sys.SMTPUser)
pass = sys.SMTPPass
from = strings.TrimSpace(sys.SMTPFrom)
useTLS = sys.SMTPTLS
} else if strings.TrimSpace(s.cfg.SMTPHost) != "" {
host = strings.TrimSpace(s.cfg.SMTPHost)
port = strings.TrimSpace(s.cfg.SMTPPort)
user = strings.TrimSpace(s.cfg.SMTPUser)
pass = s.cfg.SMTPPass
from = strings.TrimSpace(s.cfg.SMTPFrom)
useTLS = s.cfg.SMTPTLS
}
if host == "" {
return fmt.Errorf("kein SMTP-Server konfiguriert (bitte in den Administratoreinstellungen oder per Docker-Env hinterlegen)")
}
if port == "" {
port = "587"
}
if from == "" {
if user != "" && strings.Contains(user, "@") {
from = user
} else {
from = "noreply@mindsift.local"
}
}
addr := fmt.Sprintf("%s:%s", host, port)
msg := fmt.Sprintf("From: %s\r\nTo: %s\r\nSubject: %s\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=\"utf-8\"\r\n\r\n%s\r\n", from, toEmail, subject, bodyText)
var smtpAuth smtp.Auth
if user != "" || pass != "" {
smtpAuth = smtp.PlainAuth("", user, pass, host)
}
if port == "465" && useTLS {
tlsCfg := &tls.Config{ServerName: host}
conn, err := tls.Dial("tcp", addr, tlsCfg)
if err != nil {
return err
}
defer conn.Close()
client, err := smtp.NewClient(conn, host)
if err != nil {
return err
}
defer client.Quit()
if smtpAuth != nil {
if err := client.Auth(smtpAuth); err != nil {
return err
}
}
if err := client.Mail(from); err != nil {
return err
}
if err := client.Rcpt(toEmail); err != nil {
return err
}
wc, err := client.Data()
if err != nil {
return err
}
_, err = wc.Write([]byte(msg))
_ = wc.Close()
return err
}
return smtp.SendMail(addr, smtpAuth, from, []string{toEmail}, []byte(msg))
}
func (s *Server) sendDiscordNotification(webhookURL, title, description, dueDate, priority string) error {
httpClient := &http.Client{Timeout: 10 * time.Second}
payload := map[string]interface{}{
"content": "⏰ **MindSift Aufgaben-Alarm**",
"embeds": []map[string]interface{}{
{
"title": title,
"description": fmt.Sprintf("%s\n\n**Fällig:** %s | **Priorität:** %s", description, dueDate, priority),
"color": 2450411,
"footer": map[string]string{
"text": "MindSift Alarm-System",
},
},
},
}
body, err := json.Marshal(payload)
if err != nil {
return err
}
resp, err := httpClient.Post(webhookURL, "application/json", bytes.NewReader(body))
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return fmt.Errorf("HTTP Status %d", resp.StatusCode)
}
return nil
}
func (s *Server) sendCustomWebhookNotification(webhookURL, event, title, notes, dueDate, priority string) error {
httpClient := &http.Client{Timeout: 10 * time.Second}
payload := map[string]interface{}{
"event": event,
"title": title,
"notes": notes,
"dueDate": dueDate,
"priority": priority,
"timestamp": domain.NowISO(),
}
body, err := json.Marshal(payload)
if err != nil {
return err
}
resp, err := httpClient.Post(webhookURL, "application/json", bytes.NewReader(body))
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return fmt.Errorf("HTTP Status %d", resp.StatusCode)
}
return nil
}
func (s *Server) startAlarmTicker() {
ticker := time.NewTicker(60 * time.Second)
defer ticker.Stop()
for range ticker.C {
alarms, err := s.store.GetDueTasksForAlarm()
if err != nil || len(alarms) == 0 {
continue
}
today := time.Now().Format("2006-01-02")
for _, a := range alarms {
if a.DueDate > today {
continue
}
if len(a.AlarmChannels) == 0 {
continue
}
sentAny := false
desc := a.Notes
if desc == "" {
desc = "Aufgabe ist jetzt fällig."
}
for _, ch := range a.AlarmChannels {
switch ch {
case "discord":
if a.DiscordWebhookURL != "" {
if err := s.sendDiscordNotification(a.DiscordWebhookURL, a.Title, desc, a.DueDate, a.Priority); err == nil {
sentAny = true
}
}
case "webhook":
if a.CustomWebhookURL != "" {
if err := s.sendCustomWebhookNotification(a.CustomWebhookURL, "task_due", a.Title, desc, a.DueDate, a.Priority); err == nil {
sentAny = true
}
}
case "email":
if a.AlarmEmail != "" {
emailBody := fmt.Sprintf("Hallo,\n\ndeine Aufgabe in MindSift ist fällig:\n\nAufgabe: %s\nFällig am: %s\nPriorität: %s\nNotizen: %s\n", a.Title, a.DueDate, a.Priority, desc)
if err := s.sendEmailNotification(a.AlarmEmail, "⏰ Aufgabe fällig: "+a.Title, emailBody); err == nil {
sentAny = true
}
}
}
}
if sentAny {
_ = s.store.MarkTaskAlarmSent(a.TaskID)
}
}
}
}