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>
357 lines
11 KiB
Go
357 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 {
|
|
if _, err := s.store.PruneSharing(settings.SharingMode, false); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "Freigaben konnten nicht angepasst werden: "+err.Error())
|
|
return
|
|
}
|
|
}
|
|
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,
|
|
})
|
|
}
|