MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m32s
WebSocket and EventSource put the 30-day session token into the URL, where proxies log it. The app now gets a one-time ticket right before connecting (POST /auth/stream-ticket, 60 s, bound to the token version); ?token= is no longer accepted for streams, non-browser clients may use the Authorization header. With BASE_URL set, CORS allows only that origin and WebSocket upgrades only it or the requested host; credentials are off (no cookies exist). Logout, ending all sessions, a new password and deactivation close all live streams of the account; clients with a valid session reconnect with a fresh ticket. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
354 lines
11 KiB
Go
354 lines
11 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
|
|
}
|
|
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
|
|
}
|
|
settings.SMTPConfiguredViaEnv = strings.TrimSpace(s.cfg.SMTPHost) != ""
|
|
writeJSON(w, http.StatusOK, settings)
|
|
}
|
|
|
|
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
|
|
}
|
|
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 {
|
|
_, _ = s.store.PruneSharing(settings.SharingMode, false)
|
|
}
|
|
settings.SMTPConfiguredViaEnv = strings.TrimSpace(s.cfg.SMTPHost) != ""
|
|
writeJSON(w, http.StatusOK, 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,
|
|
})
|
|
}
|