MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m52s
- Demo: senders for mail, Discord and webhooks refuse, no alarm ticker; test alarm, SMTP test, server settings, backups, account and session changes of the shared demo accounts answer 403; the session list only shows the own device. The UI shows the notices and disables the controls. - SMTP password is never sent back (smtpPassSet), empty keeps it. - ?token= on webhook ingest accepts only API keys. - Request bodies capped at 2 MB (sync 8 MB, imports 101 MB). - Deleting an account removes its server backups. - Backup downloads, exports and import uploads get 30 minutes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
371 lines
12 KiB
Go
371 lines
12 KiB
Go
package api
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/sandrodach/mindsift/internal/auth"
|
|
"github.com/sandrodach/mindsift/internal/domain"
|
|
)
|
|
|
|
// --- ORGANIZATIONS & TEAMS ---
|
|
|
|
func (s *Server) handleAdminGetOrganizations(w http.ResponseWriter, r *http.Request) {
|
|
orgs, err := s.store.GetOrganizations()
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, orgs)
|
|
}
|
|
|
|
func (s *Server) handleAdminCreateOrganization(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
Name string `json:"name"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
|
return
|
|
}
|
|
org, err := s.store.CreateOrganization(req.Name)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusCreated, org)
|
|
}
|
|
|
|
func (s *Server) handleAdminRenameOrganization(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
Name string `json:"name"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
|
return
|
|
}
|
|
if err := s.store.RenameOrganization(chi.URLParam(r, "id"), req.Name); err != nil {
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]bool{"updated": true})
|
|
}
|
|
|
|
func (s *Server) handleAdminDeleteOrganization(w http.ResponseWriter, r *http.Request) {
|
|
if err := s.store.DeleteOrganization(chi.URLParam(r, "id")); err != nil {
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
|
|
}
|
|
|
|
func (s *Server) handleAdminSetUserOrganization(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
OrganizationID string `json:"organizationId"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
|
return
|
|
}
|
|
if err := s.store.SetUserOrganization(chi.URLParam(r, "id"), req.OrganizationID); err != nil {
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]bool{"updated": true})
|
|
}
|
|
|
|
// handleAdminSharingImpact counts the shares and group memberships a switch to ?mode= would remove.
|
|
func (s *Server) handleAdminSharingImpact(w http.ResponseWriter, r *http.Request) {
|
|
n, err := s.store.PruneSharing(r.URL.Query().Get("mode"), true)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]int64{"affected": n})
|
|
}
|
|
|
|
// --- ADMIN & STAFF PANEL ---
|
|
|
|
func (s *Server) handleAdminGetUsers(w http.ResponseWriter, r *http.Request) {
|
|
users, err := s.store.GetAllUsers()
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, users)
|
|
}
|
|
|
|
func (s *Server) handleAdminCreateUser(w http.ResponseWriter, r *http.Request) {
|
|
callerID := auth.GetUserID(r.Context())
|
|
caller, _ := s.store.GetUserByID(callerID)
|
|
|
|
var req struct {
|
|
Email string `json:"email"`
|
|
Password string `json:"password"`
|
|
DisplayName string `json:"displayName"`
|
|
Role string `json:"role"`
|
|
OrganizationID string `json:"organizationId"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
|
return
|
|
}
|
|
if 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
|
|
}
|
|
// Only full server admin can create another server admin
|
|
if req.Role == "admin" && (caller == nil || caller.Role != "admin") {
|
|
req.Role = "member"
|
|
}
|
|
if strings.TrimSpace(req.DisplayName) == "" {
|
|
req.DisplayName = strings.Split(req.Email, "@")[0]
|
|
}
|
|
|
|
hash, err := auth.HashPassword(req.Password)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "Passwort-Hash fehlgeschlagen")
|
|
return
|
|
}
|
|
u, err := s.store.CreateUser(req.Email, hash, req.DisplayName, req.Role)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
if req.OrganizationID != "" {
|
|
if err := s.store.SetUserOrganization(u.ID, req.OrganizationID); err == nil {
|
|
u.OrganizationID = req.OrganizationID
|
|
}
|
|
}
|
|
writeJSON(w, http.StatusCreated, u)
|
|
}
|
|
|
|
func (s *Server) handleAdminUpdateUser(w http.ResponseWriter, r *http.Request) {
|
|
callerID := auth.GetUserID(r.Context())
|
|
caller, _ := s.store.GetUserByID(callerID)
|
|
targetUserID := chi.URLParam(r, "id")
|
|
targetUser, err := s.store.GetUserByID(targetUserID)
|
|
if err != nil || targetUser == nil {
|
|
writeError(w, http.StatusNotFound, "Benutzer nicht gefunden")
|
|
return
|
|
}
|
|
|
|
// Team-Admins cannot modify Server-Admins
|
|
if caller != nil && caller.Role != "admin" && targetUser.Role == "admin" {
|
|
writeError(w, http.StatusForbidden, "Nur Server-Administratoren können Server-Admin-Konten bearbeiten")
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
DisplayName *string `json:"displayName"`
|
|
Email *string `json:"email"`
|
|
Role *string `json:"role"`
|
|
IsActive *bool `json:"isActive"`
|
|
DiscordWebhookURL *string `json:"discordWebhookUrl"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
|
return
|
|
}
|
|
if req.Role != nil && *req.Role == "admin" && (caller == nil || caller.Role != "admin") {
|
|
writeError(w, http.StatusForbidden, "Nur Server-Administratoren können die Rolle Server-Admin vergeben")
|
|
return
|
|
}
|
|
|
|
if req.DiscordWebhookURL != nil {
|
|
if err := validateWebhookURL(*req.DiscordWebhookURL, true, s.allowPrivateWebhooks()); err != nil {
|
|
writeError(w, http.StatusBadRequest, "Discord-Webhook: "+err.Error())
|
|
return
|
|
}
|
|
}
|
|
u, err := s.store.UpdateUser(targetUserID, req.DisplayName, req.Email, req.Role, req.IsActive, req.DiscordWebhookURL, nil)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
if req.IsActive != nil && !*req.IsActive {
|
|
s.wsHub.DisconnectUser(targetUserID)
|
|
}
|
|
writeJSON(w, http.StatusOK, u)
|
|
}
|
|
|
|
func (s *Server) handleAdminResetPassword(w http.ResponseWriter, r *http.Request) {
|
|
callerID := auth.GetUserID(r.Context())
|
|
caller, _ := s.store.GetUserByID(callerID)
|
|
targetUserID := chi.URLParam(r, "id")
|
|
targetUser, _ := s.store.GetUserByID(targetUserID)
|
|
if caller != nil && caller.Role != "admin" && targetUser != nil && targetUser.Role == "admin" {
|
|
writeError(w, http.StatusForbidden, "Keine Berechtigung")
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
Password string `json:"password"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "Neues Passwort erforderlich")
|
|
return
|
|
}
|
|
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, "Passwort-Hash fehlgeschlagen")
|
|
return
|
|
}
|
|
s.loginGuard.unlockAccount(targetUserID)
|
|
if err := s.store.UpdateUserPassword(targetUserID, hash); err != nil {
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
s.wsHub.DisconnectUser(targetUserID)
|
|
writeJSON(w, http.StatusOK, map[string]bool{"success": true})
|
|
}
|
|
|
|
func (s *Server) handleAdminDeleteUser(w http.ResponseWriter, r *http.Request) {
|
|
targetUserID := chi.URLParam(r, "id")
|
|
currUserID := auth.GetUserID(r.Context())
|
|
if targetUserID == currUserID {
|
|
writeError(w, http.StatusBadRequest, "Sie können Ihren eigenen Benutzer nicht löschen")
|
|
return
|
|
}
|
|
caller, _ := s.store.GetUserByID(currUserID)
|
|
targetUser, _ := s.store.GetUserByID(targetUserID)
|
|
if caller != nil && caller.Role != "admin" && targetUser != nil && targetUser.Role == "admin" {
|
|
writeError(w, http.StatusForbidden, "Keine Berechtigung zum Löschen eines Server-Admins")
|
|
return
|
|
}
|
|
if err := s.store.DeleteUser(targetUserID); err != nil {
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
s.removeUserBackups(targetUserID)
|
|
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
|
|
}
|
|
|
|
// --- INVITES MANAGEMENT ---
|
|
|
|
func (s *Server) handleAdminGetInvites(w http.ResponseWriter, r *http.Request) {
|
|
invites, err := s.store.GetUserInvites()
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, invites)
|
|
}
|
|
|
|
func (s *Server) handleAdminCreateInvite(w http.ResponseWriter, r *http.Request) {
|
|
callerID := auth.GetUserID(r.Context())
|
|
caller, _ := s.store.GetUserByID(callerID)
|
|
|
|
var req struct {
|
|
Email string `json:"email"`
|
|
Role string `json:"role"`
|
|
OrganizationID string `json:"organizationId"`
|
|
ValidDays int `json:"validDays"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
|
return
|
|
}
|
|
if req.Role == "admin" && (caller == nil || caller.Role != "admin") {
|
|
req.Role = "member"
|
|
}
|
|
inv, err := s.store.CreateUserInvite(callerID, req.Email, req.Role, req.OrganizationID, req.ValidDays)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusCreated, inv)
|
|
}
|
|
|
|
func (s *Server) handleAdminDeleteInvite(w http.ResponseWriter, r *http.Request) {
|
|
inviteID := chi.URLParam(r, "id")
|
|
if err := s.store.DeleteUserInvite(inviteID); err != nil {
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
|
|
}
|
|
|
|
// --- SERVER SETTINGS & SMTP ---
|
|
|
|
func (s *Server) handleAdminGetSettings(w http.ResponseWriter, r *http.Request) {
|
|
settings, err := s.store.GetSystemSettings()
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, s.settingsForBrowser(settings))
|
|
}
|
|
|
|
// settingsForBrowser hides the SMTP password; the admin only sees whether one is stored.
|
|
func (s *Server) settingsForBrowser(settings *domain.SystemSettings) *domain.SystemSettings {
|
|
out := *settings
|
|
out.SMTPPassSet = out.SMTPPass != ""
|
|
out.SMTPPass = ""
|
|
out.SMTPConfiguredViaEnv = strings.TrimSpace(s.cfg.SMTPHost) != ""
|
|
return &out
|
|
}
|
|
|
|
func (s *Server) handleAdminUpdateSettings(w http.ResponseWriter, r *http.Request) {
|
|
var settings domain.SystemSettings
|
|
if err := json.NewDecoder(r.Body).Decode(&settings); err != nil {
|
|
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
|
return
|
|
}
|
|
// Empty means unchanged (the browser never has it); without SMTP user the password goes too
|
|
if settings.SMTPPass == "" && strings.TrimSpace(settings.SMTPUser) != "" {
|
|
if cur, err := s.store.GetSystemSettings(); err == nil {
|
|
settings.SMTPPass = cur.SMTPPass
|
|
}
|
|
}
|
|
previousMode := s.store.SharingMode()
|
|
if err := s.store.UpdateSystemSettings(&settings); err != nil {
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
// A stricter mode takes away what is no longer allowed (shares, group memberships, assignments)
|
|
if settings.SharingMode != previousMode {
|
|
if _, err := s.store.PruneSharing(settings.SharingMode, false); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "Freigaben konnten nicht angepasst werden: "+err.Error())
|
|
return
|
|
}
|
|
}
|
|
writeJSON(w, http.StatusOK, s.settingsForBrowser(&settings))
|
|
}
|
|
|
|
func (s *Server) handleAdminTestSMTP(w http.ResponseWriter, r *http.Request) {
|
|
userID := auth.GetUserID(r.Context())
|
|
user, _ := s.store.GetUserByID(userID)
|
|
|
|
var req struct {
|
|
ToEmail string `json:"toEmail"`
|
|
}
|
|
_ = json.NewDecoder(r.Body).Decode(&req)
|
|
toEmail := strings.TrimSpace(req.ToEmail)
|
|
if toEmail == "" && user != nil {
|
|
toEmail = user.Email
|
|
}
|
|
if toEmail == "" {
|
|
writeError(w, http.StatusBadRequest, "Ziel-E-Mail-Adresse erforderlich")
|
|
return
|
|
}
|
|
|
|
if err := s.sendEmailNotification(toEmail, "MindSift SMTP-Server Test", "Die SMTP-Konfiguration deiner MindSift-Instanz funktioniert einwandfrei!"); err != nil {
|
|
writeError(w, http.StatusBadGateway, "SMTP-Fehler: "+err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
"sent": true,
|
|
"toEmail": toEmail,
|
|
})
|
|
}
|