auth.ValidatePassword checks every way a password is set: setup, registration, profile, admin create and reset, reset by mail. Same rule in the browser before sending (lib/password.ts), with our own German message instead of the browser popup. Existing shorter passwords still log in. Profile and admin reset no longer trim the password, which made passwords with surrounding spaces unusable for login. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
536 lines
17 KiB
Go
536 lines
17 KiB
Go
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
|
||
}
|
||
}
|
||
writeJSON(w, http.StatusOK, 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 != "",
|
||
},
|
||
})
|
||
}
|
||
|
||
// 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)
|
||
}
|