package handlers import ( "encoding/json" "errors" "net/http" "strconv" "github.com/go-chi/chi/v5" "github.com/noderunners/nip05api/internal/audit" "github.com/noderunners/nip05api/internal/dm" "github.com/noderunners/nip05api/internal/nostr" "github.com/noderunners/nip05api/internal/user" "github.com/noderunners/nip05api/internal/webhook" ) type AdminUsers struct { Users *user.Service DMs *dm.Service Hooks *webhook.Service Audit *audit.Logger Domain string Frontend string } type adminAddReq struct { Pubkey string `json:"pubkey"` Username string `json:"username"` SubscriptionType string `json:"subscription_type"` Years int `json:"years"` } func (h *AdminUsers) Add(w http.ResponseWriter, r *http.Request) { var body adminAddReq if err := json.NewDecoder(r.Body).Decode(&body); err != nil { WriteError(w, http.StatusBadRequest, "ValidationError", "invalid JSON") return } hexpk, err := nostr.NormalizePubkey(body.Pubkey) if err != nil { WriteError(w, http.StatusBadRequest, "ValidationError", "Invalid pubkey format") return } sub := user.SubscriptionType(body.SubscriptionType) if !sub.Valid() { WriteError(w, http.StatusBadRequest, "ValidationError", "invalid subscription_type") return } years := body.Years if years <= 0 { years = 1 } if existing, err := h.Users.Repo().GetByPubkey(r.Context(), hexpk); err == nil && existing != nil { WriteError(w, http.StatusConflict, "Conflict", "user already exists") return } if existing, err := h.Users.Repo().GetByUsername(r.Context(), user.NormalizeUsername(body.Username)); err == nil && existing != nil { WriteError(w, http.StatusConflict, "Conflict", "username taken") return } u, err := h.Users.CreateOrActivate(r.Context(), hexpk, body.Username, sub, years, true) if err != nil { if errors.Is(err, user.ErrInvalidUsername) { WriteError(w, http.StatusBadRequest, "ValidationError", "invalid username") return } WriteError(w, http.StatusInternalServerError, "InternalError", err.Error()) return } vars := dmVars(u, h.Domain, h.Frontend) _ = h.DMs.Send(r.Context(), dm.EventWelcome, u.Pubkey, vars) _ = h.Hooks.Enqueue(r.Context(), webhook.EventUserAdded, hookData(u, h.Domain)) h.Audit.Log(r.Context(), audit.ActionUserAdded, audit.ActorAdmin, u.Pubkey, map[string]any{ "username": u.Username, "subscription_type": string(u.SubscriptionType), "years": years, }) WriteJSON(w, http.StatusCreated, userResponse(u)) } func (h *AdminUsers) List(w http.ResponseWriter, r *http.Request) { q := r.URL.Query() activeOnly := q.Get("active") == "true" limit, _ := strconv.Atoi(q.Get("limit")) if limit <= 0 { limit = 100 } users, err := h.Users.Repo().List(r.Context(), user.ListFilter{ ActiveOnly: activeOnly, Search: q.Get("q"), Limit: limit, }) if err != nil { WriteError(w, http.StatusInternalServerError, "InternalError", err.Error()) return } out := make([]map[string]any, 0, len(users)) for _, u := range users { out = append(out, userResponse(u)) } WriteJSON(w, http.StatusOK, out) } func (h *AdminUsers) Update(w http.ResponseWriter, r *http.Request) { hexpk, err := nostr.NormalizePubkey(chi.URLParam(r, "pubkey")) if err != nil { WriteError(w, http.StatusBadRequest, "ValidationError", "Invalid pubkey format") return } var body struct { Username string `json:"username"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { WriteError(w, http.StatusBadRequest, "ValidationError", "invalid JSON") return } u, err := h.Users.SetUsername(r.Context(), hexpk, body.Username) if err != nil { switch { case errors.Is(err, user.ErrUserNotFound): WriteError(w, http.StatusNotFound, "NotFound", "user not found") case errors.Is(err, user.ErrInvalidUsername): WriteError(w, http.StatusBadRequest, "ValidationError", "invalid username") case errors.Is(err, user.ErrUsernameTaken): WriteError(w, http.StatusConflict, "Conflict", "username taken") default: WriteError(w, http.StatusInternalServerError, "InternalError", err.Error()) } return } h.Audit.Log(r.Context(), audit.ActionUserUsernameChanged, audit.ActorAdmin, u.Pubkey, map[string]any{ "username": u.Username, }) WriteJSON(w, http.StatusOK, userResponse(u)) } func (h *AdminUsers) Delete(w http.ResponseWriter, r *http.Request) { hexpk, err := nostr.NormalizePubkey(chi.URLParam(r, "pubkey")) if err != nil { WriteError(w, http.StatusBadRequest, "ValidationError", "Invalid pubkey format") return } u, err := h.Users.Repo().GetByPubkey(r.Context(), hexpk) if errors.Is(err, user.ErrUserNotFound) { WriteError(w, http.StatusNotFound, "NotFound", "user not found") return } if err != nil { WriteError(w, http.StatusInternalServerError, "InternalError", err.Error()) return } if err := h.Users.Delete(r.Context(), hexpk); err != nil { WriteError(w, http.StatusInternalServerError, "InternalError", err.Error()) return } _ = h.Hooks.Enqueue(r.Context(), webhook.EventUserRemoved, hookData(u, h.Domain)) h.Audit.Log(r.Context(), audit.ActionUserDeleted, audit.ActorAdmin, u.Pubkey, map[string]any{ "username": u.Username, }) WriteJSON(w, http.StatusOK, map[string]bool{"deleted": true}) }