MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m4s
- Per-user routing (own mail / own Discord webhook) for task alarms
(sent at once), shares, completed shared tasks and watched tasks;
settings as JSON on the user (GET /auth/me, PATCH /auth/settings).
- Everything but alarms is queued and sent as one message per user
after NOTIFY_DIGEST_MINUTES (default 5); mails now multipart with an
HTML version, Discord as one embed. Three failed attempts drop it.
- Own changes never notify the actor, except on watched tasks when
asked for; "paused" stops everything including alarms.
- Task watchers: POST/DELETE /tasks/{id}/watch, isWatching on tasks.
- Daily summary at a chosen time: overdue, today, tomorrow, plus the
count of the next 7 days; nothing due, nothing sent.
- Migration 7; jobs do not run in the demo.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
664 lines
22 KiB
Go
664 lines
22 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
|
||
}
|
||
}
|
||
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 != "",
|
||
},
|
||
}
|
||
// 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")
|
||
}
|
||
body := fmt.Sprintf("Hallo %s,\n\nin MindSift %s.\n\nWarst du das nicht, setze bitte sofort dein Passwort zurück und beende alle Sitzungen in den Kontoeinstellungen, oder wende dich an die Administration.\n",
|
||
before.DisplayName, strings.Join(what, " und "))
|
||
go func() {
|
||
_ = s.sendEmailNotification(before.Email, "MindSift: Änderung an deinem Konto", body)
|
||
}()
|
||
}
|
||
|
||
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)
|
||
}
|