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(), // How voice input works: "server" (own Whisper), "browser" or "off"; set by STT_* only "voice": map[string]interface{}{"mode": s.voiceMode(), "maxSeconds": s.cfg.Speech.MaxSeconds}, // 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 != "", "customLinkLabel": s.cfg.Legal.CustomLinkLabel, "customLinkUrl": s.cfg.Legal.CustomLinkURL, }, } // 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) if err := s.store.UpdateSystemSettings(sys); err != nil { writeError(w, http.StatusInternalServerError, "Einstellungen konnten nicht gespeichert werden: "+err.Error()) return } // 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 := s.startSession(r, user) 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 wait := s.loginGuard.throttle("register", clientIPFrom(r), limitRegisterPerIP); wait > 0 { writeThrottled(w, wait) 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 } // The invite is claimed before the account exists, so it cannot be redeemed twice at once var claim string if validInvite != nil { if claim, err = s.store.ClaimUserInvite(validInvite.ID); err != nil { writeError(w, http.StatusForbidden, err.Error()) return } } user, err := s.store.CreateUser(req.Email, hash, req.DisplayName, role) if err != nil { if validInvite != nil { s.store.ReleaseUserInvite(validInvite.ID, claim) } writeError(w, http.StatusConflict, "E-Mail-Adresse existiert bereits") return } if validInvite != nil { if err := s.store.FinishUserInvite(validInvite.ID, claim, user.ID); err != nil { slog.Error("Einladungscode konnte nicht als benutzt markiert werden", "invite", validInvite.ID, "error", err) } if validInvite.OrganizationID != "" { _ = s.store.SetUserOrganization(user.ID, validInvite.OrganizationID) user.OrganizationID = validInvite.OrganizationID } } token, err := s.startSession(r, user) 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) // Unknown names are counted under the typed login, so locking looks the same as for real accounts account := "login:" + strings.ToLower(identifier) if err == nil { account = user.ID } ip := clientIPFrom(r) if wait := s.loginGuard.lockedFor(account, ip); wait > 0 { slog.Warn("Login gesperrt nach Fehlversuchen", "login", identifier, "remote_addr", r.RemoteAddr, "wait", wait.Round(time.Second)) writeLoginLocked(w, wait) return } if err != nil { s.loginGuard.recordFailure(account, ip) 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) { s.loginGuard.recordFailure(account, ip) 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 } s.loginGuard.recordSuccess(account, ip) 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 := s.startSession(r, user) 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) ns := s.store.NotificationSettingsOf(userID) user.NotificationSettings = &ns 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 } s.removeUserBackups(user.ID) 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) { allowLongTransfer(w) 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()) // The session must really end; the client is told if it did not if sid := sessionIDFrom(r); sid != "" { if err := s.store.RevokeSession(userID, sid); err != nil && !errors.Is(err, storage.ErrSessionNotFound) { writeError(w, http.StatusInternalServerError, "Abmelden fehlgeschlagen") return } } // Streams are not tied to one session; other devices reconnect with a new ticket on their own s.wsHub.DisconnectUser(userID) 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 } s.wsHub.DisconnectUser(userID) 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"` // Needed to change password or e-mail address (FLAW-30) CurrentPassword string `json:"currentPassword"` // Which notifications go where (notify.go) NotificationSettings *domain.NotificationSettings `json:"notificationSettings"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "Ungültiges JSON") return } before, err := s.store.GetUserByID(userID) if err != nil || before == nil { writeError(w, http.StatusNotFound, "Benutzer nicht gefunden") return } changesPassword := req.Password != nil && strings.TrimSpace(*req.Password) != "" changesEmail := req.Email != nil && !strings.EqualFold(strings.TrimSpace(*req.Email), before.Email) if (changesPassword || changesEmail) && s.cfg.DemoMode { writeError(w, http.StatusForbidden, "In der Demo deaktiviert") return } if (changesPassword || changesEmail) && !s.checkCurrentPassword(w, r, before, req.CurrentPassword) { 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 } if err := s.store.UpdateUserPassword(userID, hash); err != nil { writeError(w, http.StatusInternalServerError, "Passwort konnte nicht gespeichert werden") return } // Every other device is logged out; this one keeps its session with a fresh token sid := sessionIDFrom(r) if err := s.store.RevokeOtherSessions(userID, sid); err != nil { writeError(w, http.StatusInternalServerError, "Andere Sitzungen konnten nicht beendet werden") return } s.wsHub.DisconnectUser(userID) if fresh, err := s.store.GetUserByID(userID); err == nil { current := &auth.Claims{} current.ID = sid s.renewSessionToken(w, fresh, current, true) } } 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 } if changesPassword || changesEmail { s.notifyAccountChange(before, changesPassword, changesEmail, u.Email) } if req.NotificationSettings != nil { if err := s.store.SetNotificationSettings(userID, *req.NotificationSettings); err != nil { writeError(w, http.StatusInternalServerError, "Benachrichtigungen konnten nicht gespeichert werden") return } } ns := s.store.NotificationSettingsOf(userID) u.NotificationSettings = &ns writeJSON(w, http.StatusOK, u) } // checkCurrentPassword verifies the current password for sensitive changes; wrong attempts count // towards the login protection, so this cannot be used to guess the password. func (s *Server) checkCurrentPassword(w http.ResponseWriter, r *http.Request, user *domain.User, password string) bool { ip := clientIPFrom(r) if wait := s.loginGuard.lockedFor(user.ID, ip); wait > 0 { writeLoginLocked(w, wait) return false } // A forgotten field is no guess and does not count as a failure if password == "" || !auth.CheckPassword(password, user.PasswordHash) { if password != "" { s.loginGuard.recordFailure(user.ID, ip) } writeError(w, http.StatusForbidden, "Bitte gib zur Bestätigung dein aktuelles Passwort richtig ein") return false } return true } // notifyAccountChange tells the previous address about a new password or e-mail address, so a // takeover does not go unnoticed. Only when mail is set up; errors are logged, never shown. func (s *Server) notifyAccountChange(before *domain.User, password, email bool, newEmail string) { if _, err := s.smtpSettings(); err != nil || before.Email == "" { return } var what []string if password { what = append(what, "dein Passwort wurde geändert") } if email { what = append(what, "die E-Mail-Adresse deines Kontos wurde auf "+newEmail+" geändert") } c := mailContent{ Preheader: "Änderung an deinem MindSift-Konto", Greeting: mailGreeting(time.Now(), before.DisplayName), Intro: "In MindSift " + strings.Join(what, " und ") + ".", Notice: "Warst du das nicht? Setze bitte sofort dein Passwort zurück und beende alle Sitzungen in den Kontoeinstellungen, oder wende dich an die Administration.", Footer: mailFooterAccount, } if link := s.notifyLink(); link != "" { c.Button = &mailButton{Label: "MindSift öffnen", URL: link} } go func() { _ = s.sendMailContent(before.Email, "MindSift: Änderung an deinem Konto", c) }() } 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.sendMailContent(toEmail, "⏰ MindSift Test-Alarm", mailContent{ Preheader: "Dein E-Mail-Alarmkanal funktioniert", Greeting: mailGreeting(time.Now(), user.DisplayName), Intro: "Dein E-Mail-Alarmkanal in MindSift ist eingerichtet und einsatzbereit. So sehen deine Alarme aus:", Sections: []mailSection{{Items: []mailItem{{Title: "Beispiel: Heizung entlüften", Meta: "Fällig: heute, 18:00 Uhr · Wichtig", Tone: "warning"}}}}, Footer: mailFooterAlarms, }); 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) }