MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m34s
DEMO_RESET_MINUTES (default 20, 0 = off) resets the database to the sample data. Demo accounts get fixed IDs so sessions survive; requests hold a read lock so the reset never runs under a half-finished request (streams excluded). DEMO_RESET goes to every connection, /auth/status announces the next reset. The app shows "✨ Demo · Reset in MM:SS" in place of the version, then clears cached answers, remounts the views, reloads in the background and shows a short notice; if no event arrives (long polling) it asks the server after the countdown. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
543 lines
17 KiB
Go
543 lines
17 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)
|
||
_ = s.store.UpdateSystemSettings(sys)
|
||
// 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 {
|
||
_ = s.store.MarkUserInviteUsed(validInvite.ID, user.ID)
|
||
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)
|
||
if err != nil {
|
||
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) {
|
||
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
|
||
}
|
||
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)
|
||
}
|
||
_ = s.store.RevokeToken(tokenStr, userID, exp)
|
||
}
|
||
}
|
||
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
|
||
}
|
||
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
|
||
}
|
||
_ = s.store.UpdateUserPassword(userID, hash)
|
||
}
|
||
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)
|
||
}
|