MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m4s
Store.execInTx runs related statements in one transaction and returns the first error. Used for deleting and moving folders (with the trash), shared folders, folder shares and mounts, deleting task lists and accounts, pinning, and the password reset (new password and the end of all other links together). Writes whose success the API reports now pass their error to the client (logout, new password, setup, sharing mode, new group, restore, unlinking a note block), follow-up tasks and alarm markers log failures. Remaining ignored writes are listed as intentional in flaws.md. Found on the way: DeleteTaskList unlinked all tasks before checking ownership, so a stranger with the list ID could empty it; viewers could unlink tasks from note blocks. Both fixed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
574 lines
19 KiB
Go
574 lines
19 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(),
|
||
// 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 := 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 {
|
||
// Otherwise the code could be used again
|
||
if err := s.store.MarkUserInviteUsed(validInvite.ID, 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 := 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)
|
||
// 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 := 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)
|
||
}
|
||
// The session must really end; the client is told if it did not
|
||
if err := s.store.RevokeToken(tokenStr, userID, exp); err != nil {
|
||
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"`
|
||
}
|
||
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
|
||
}
|
||
if err := s.store.UpdateUserPassword(userID, hash); err != nil {
|
||
writeError(w, http.StatusInternalServerError, "Passwort konnte nicht gespeichert werden")
|
||
return
|
||
}
|
||
s.wsHub.DisconnectUser(userID)
|
||
}
|
||
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)
|
||
}
|