package api import ( "encoding/json" "errors" "fmt" "io" "log/slog" "net/http" "strings" "time" "github.com/go-chi/chi/v5" "github.com/sandrodach/mindsift/internal/auth" "github.com/sandrodach/mindsift/internal/domain" "github.com/sandrodach/mindsift/internal/storage" ) // --- 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 } } status := map[string]interface{}{ "needsSetup": count == 0, "allowRegistration": allowReg, "instanceName": instanceName, "demoMode": s.cfg.DemoMode, "sharingMode": s.store.SharingMode(), "passwordReset": s.passwordResetAvailable(), // Public so the login screen can link Impressum and Datenschutz "legal": map[string]interface{}{ "imprintUrl": s.cfg.Legal.ImprintURL, "hasImprintContent": s.cfg.Legal.ImprintContent != "", "privacyUrl": s.cfg.Legal.PrivacyURL, "hasPrivacyContent": s.cfg.Legal.PrivacyContent != "", }, } // Countdown to the next demo reset; seconds as well, so client clocks do not matter if next := s.demoNextReset(); !next.IsZero() { status["demoNextReset"] = next.UTC().Format(time.RFC3339) status["demoResetInSeconds"] = int(time.Until(next).Seconds()) status["demoResetMinutes"] = s.cfg.DemoResetMinutes } writeJSON(w, http.StatusOK, status) } // handleLegalText serves the configured Markdown of the imprint or privacy notice (public, no login). func (s *Server) handleLegalText(w http.ResponseWriter, r *http.Request) { content := "" switch chi.URLParam(r, "type") { case "imprint": content = s.cfg.Legal.ImprintContent case "privacy": content = s.cfg.Legal.PrivacyContent } if content == "" { writeError(w, http.StatusNotFound, "Kein Text hinterlegt") return } writeJSON(w, http.StatusOK, map[string]string{"content": content}) } 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"` CreateSampleData bool `json:"createSampleData"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "Ungültige Anfrage") return } if strings.TrimSpace(req.Email) == "" { writeError(w, http.StatusBadRequest, "E-Mail-Adresse erforderlich") return } if err := auth.ValidatePassword(req.Password); err != nil { writeError(w, http.StatusBadRequest, err.Error()) 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 sys.SharingMode = storage.NormalizeSharingMode(req.SharingMode) _ = s.store.UpdateSystemSettings(sys) // In organization mode the first admin starts in an organization named after the instance if sys.SharingMode == storage.SharingOrganizations { if org, err := s.store.CreateOrganization(sys.InstanceName); err == nil { _ = s.store.SetUserOrganization(user.ID, org.ID) user.OrganizationID = org.ID } } } if req.CreateSampleData { _ = 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) == "" { writeError(w, http.StatusBadRequest, "E-Mail-Adresse erforderlich") return } if err := auth.ValidatePassword(req.Password); err != nil { writeError(w, http.StatusBadRequest, err.Error()) 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.OrganizationID != "" { _ = s.store.SetUserOrganization(user.ID, validInvite.OrganizationID) user.OrganizationID = validInvite.OrganizationID } } 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, // The settings disable "Konto löschen" for the last administrator "isLastAdmin": s.store.IsLastAdmin(userID), }) } 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) } // handlePurgeOwnData deletes all notes, tasks, thoughts and lists of the caller; the account stays. func (s *Server) handlePurgeOwnData(w http.ResponseWriter, r *http.Request) { user, ok := s.confirmPassword(w, r) if !ok { return } if err := s.store.PurgeUserData(user.ID); err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } slog.Info("Benutzer hat alle eigenen Daten gelöscht", "user_id", user.ID) writeJSON(w, http.StatusOK, map[string]bool{"deleted": true}) } // handleDeleteOwnAccount deletes the caller's account with all data; never the last administrator. func (s *Server) handleDeleteOwnAccount(w http.ResponseWriter, r *http.Request) { user, ok := s.confirmPassword(w, r) if !ok { return } if err := s.store.DeleteOwnAccount(user.ID); err != nil { status := http.StatusInternalServerError if errors.Is(err, storage.ErrLastAdmin) { status = http.StatusForbidden } writeError(w, status, err.Error()) return } slog.Info("Benutzer hat sein Konto gelöscht", "user_id", user.ID) writeJSON(w, http.StatusOK, map[string]bool{"deleted": true}) } // importUpload reads the multipart field "file" (max. storage.MaxImportUploadBytes). func importUpload(w http.ResponseWriter, r *http.Request) ([]byte, string, error) { r.Body = http.MaxBytesReader(w, r.Body, storage.MaxImportUploadBytes+1<<20) file, header, err := r.FormFile("file") if err != nil { return nil, "", fmt.Errorf("Keine Datei erhalten oder Datei zu groß (max. 100 MB)") } defer file.Close() data, err := io.ReadAll(io.LimitReader(file, storage.MaxImportUploadBytes+1)) if err != nil || len(data) > storage.MaxImportUploadBytes { return nil, "", fmt.Errorf("Datei zu groß (max. 100 MB)") } return data, header.Filename, nil } // --- 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 } // Alarm URLs are checked before they are stored (SSRF protection, see netguard.go) allowPrivate := s.allowPrivateWebhooks() if req.DiscordWebhookURL != nil { if err := validateWebhookURL(*req.DiscordWebhookURL, true, allowPrivate); err != nil { writeError(w, http.StatusBadRequest, "Discord-Webhook: "+err.Error()) return } } if req.CustomWebhookURL != nil { if err := validateWebhookURL(*req.CustomWebhookURL, false, allowPrivate); err != nil { writeError(w, http.StatusBadRequest, "Webhook-URL: "+err.Error()) return } } if req.Password != nil && strings.TrimSpace(*req.Password) != "" { if err := auth.ValidatePassword(*req.Password); err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } 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) }