feat: add NFT usernames and bot verification (#22)

Implements collectible usernames, official verification workflows, and third-party bot verification after maintainer protocol and migration review.

The composite activity/moderation rating remains an admin-only read model; Telegram Stars Rating wire fields stay unset pending a dedicated official-semantics implementation.

Reviewed-Head: 2796345775ea0f908fb7734601e5e1dee4b653b9
Original-Head: fa082b892fd5180c9c9bc53c81c21cf5d250a75b

Co-authored-by: Egor Egorov <business.egor.sg@gmail.com>
This commit is contained in:
Egor Egorov 2026-07-27 20:18:00 +03:00 committed by GitHub
parent b0fd3976f1
commit fff8de783a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
169 changed files with 55769 additions and 282 deletions

View file

@ -2,12 +2,13 @@ package adminapi
import (
"context"
"crypto/subtle"
"encoding/json"
"errors"
"fmt"
"io"
"math"
"net/http"
"net/url"
"strconv"
"strings"
"time"
@ -22,6 +23,20 @@ import (
type Config struct {
Addr string
Token string
// ScopedTokens are additional bearer tokens with a bounded permission set
// each. Token stays the unrestricted master token, so a deployment that
// configures no scoped token behaves exactly as it did before.
//
// The shape mirrors config.AdminScopedToken without importing the loader --
// only the main packages depend on internal/config -- so the caller converts:
//
// scoped := make([]adminapi.ScopedToken, 0, len(cfg.AdminScopedTokens))
// for _, item := range cfg.AdminScopedTokens {
// scoped = append(scoped, adminapi.ScopedToken{
// Name: item.Name, Token: item.Token, Permissions: item.Permissions,
// })
// }
ScopedTokens []ScopedToken
}
type Service interface {
@ -64,6 +79,47 @@ type Service interface {
DecideModerationCase(ctx context.Context, request domain.ModerationDecisionRequest) (domain.ModerationCaseDetail, bool, error)
SubmitModerationAppeal(ctx context.Context, caseID, appellantUserID int64, text string) (domain.ModerationAppeal, bool, error)
ReviewModerationAppeal(ctx context.Context, request domain.ModerationDecisionRequest) (domain.ModerationCaseDetail, bool, error)
MintCollectibleUsername(ctx context.Context, req admin.MintCollectibleUsernameRequest) (admin.CommandResult, error)
TransferCollectibleUsername(ctx context.Context, req admin.TransferCollectibleUsernameRequest) (admin.CommandResult, error)
RevokeCollectibleUsername(ctx context.Context, req admin.RevokeCollectibleUsernameRequest) (admin.CommandResult, error)
DeleteCollectibleUsername(ctx context.Context, req admin.DeleteCollectibleUsernameRequest) (admin.CommandResult, error)
CollectibleUsernames(ctx context.Context, filter domain.CollectibleUsernameFilter) ([]domain.CollectibleUsername, error)
CollectibleUsernameByID(ctx context.Context, id int64) (domain.CollectibleUsername, error)
CollectibleUsernameTransfers(ctx context.Context, collectibleID int64, limit int) ([]domain.CollectibleUsernameTransfer, error)
RecomputeAccountRating(ctx context.Context, req admin.RecomputeAccountRatingRequest) (admin.CommandResult, error)
AdjustAccountRating(ctx context.Context, req admin.AdjustAccountRatingRequest) (admin.CommandResult, error)
AccountRating(ctx context.Context, userID int64) (domain.AccountRating, error)
AccountRatings(ctx context.Context, filter domain.AccountRatingFilter) ([]domain.AccountRating, error)
AccountRatingEvents(ctx context.Context, userID int64, limit int) ([]domain.AccountRatingEvent, error)
ClaimVerification(ctx context.Context, req admin.ClaimVerificationRequest) (admin.CommandResult, error)
ApproveVerification(ctx context.Context, req admin.ApproveVerificationRequest) (admin.CommandResult, error)
RejectVerification(ctx context.Context, req admin.RejectVerificationRequest) (admin.CommandResult, error)
RevokeVerification(ctx context.Context, req admin.RevokeVerificationRequest) (admin.CommandResult, error)
VerificationApplications(ctx context.Context, filter domain.VerificationApplicationFilter) ([]domain.VerificationApplication, error)
VerificationApplication(ctx context.Context, applicationID int64) (domain.VerificationApplication, error)
VerificationApplicationEvents(ctx context.Context, applicationID int64, limit int) ([]domain.VerificationApplicationEvent, error)
VerificationCounts(ctx context.Context) (domain.VerificationStatusCounts, error)
VerificationTargetSnapshot(ctx context.Context, targetType domain.VerificationTargetType, targetID int64) (domain.VerificationTarget, error)
// Third-party bot verification. A separate mechanism from the official
// verification methods above, over separate tables and separate permissions;
// see botverification.go.
GrantBotVerifier(ctx context.Context, req admin.GrantBotVerifierRequest) (admin.CommandResult, error)
SetBotVerifierEnabled(ctx context.Context, req admin.SetBotVerifierEnabledRequest) (admin.CommandResult, error)
RevokeBotVerifier(ctx context.Context, req admin.RevokeBotVerifierRequest) (admin.CommandResult, error)
UpsertVerificationIcon(ctx context.Context, req admin.UpsertVerificationIconRequest) (admin.CommandResult, error)
SetVerificationIconActive(ctx context.Context, req admin.SetVerificationIconActiveRequest) (admin.CommandResult, error)
RevokeCustomVerification(ctx context.Context, req admin.RevokeCustomVerificationRequest) (admin.CommandResult, error)
ApproveBotVerification(ctx context.Context, req admin.ApproveBotVerificationRequest) (admin.CommandResult, error)
RejectBotVerification(ctx context.Context, req admin.RejectBotVerificationRequest) (admin.CommandResult, error)
RevokeBotVerification(ctx context.Context, req admin.RevokeBotVerificationRequest) (admin.CommandResult, error)
BotVerifiers(ctx context.Context, enabledOnly bool, limit int) ([]domain.BotVerifierSettings, error)
BotVerifier(ctx context.Context, botID int64) (domain.BotVerifierSettings, error)
VerificationIcons(ctx context.Context, activeOnly bool, limit int) ([]domain.VerificationIcon, error)
CustomVerifications(ctx context.Context, filter domain.CustomVerificationFilter) ([]domain.CustomVerification, error)
CustomVerificationRequests(ctx context.Context, filter domain.CustomVerificationRequestFilter) ([]domain.CustomVerificationRequest, error)
CustomVerificationRequest(ctx context.Context, requestID int64) (domain.CustomVerificationRequest, error)
CustomVerificationRequestCounts(ctx context.Context) (map[domain.CustomVerificationRequestStatus]int64, error)
CustomVerificationMarkActive(ctx context.Context, verifierBotID int64, peer domain.Peer) (bool, error)
}
func Start(ctx context.Context, cfg Config, svc Service, log *zap.Logger) (*http.Server, error) {
@ -80,7 +136,7 @@ func Start(ctx context.Context, cfg Config, svc Service, log *zap.Logger) (*http
if log == nil {
log = zap.NewNop()
}
server := &Server{token: cfg.Token, svc: svc, log: log}
server := &Server{token: cfg.Token, scoped: cfg.ScopedTokens, svc: svc, log: log}
httpServer := &http.Server{
Addr: cfg.Addr,
Handler: server.routes(),
@ -102,9 +158,10 @@ func Start(ctx context.Context, cfg Config, svc Service, log *zap.Logger) (*http
}
type Server struct {
token string
svc Service
log *zap.Logger
token string
scoped []ScopedToken
svc Service
log *zap.Logger
}
func (s *Server) routes() http.Handler {
@ -151,20 +208,50 @@ func (s *Server) routes() http.Handler {
mux.HandleFunc("POST /v1/moderation/cases/{id}/decide", s.authenticated(s.handleDecideModerationCase))
mux.HandleFunc("POST /v1/moderation/cases/{id}/appeals", s.authenticated(s.handleSubmitModerationAppeal))
mux.HandleFunc("POST /v1/moderation/cases/{id}/appeals/{appeal_id}/review", s.authenticated(s.handleReviewModerationAppeal))
mux.HandleFunc("POST /v1/collectible-usernames/mint", s.authenticated(s.handleMintCollectibleUsername))
mux.HandleFunc("POST /v1/collectible-usernames/transfer", s.authenticated(s.handleTransferCollectibleUsername))
mux.HandleFunc("POST /v1/collectible-usernames/revoke", s.authenticated(s.handleRevokeCollectibleUsername))
mux.HandleFunc("POST /v1/collectible-usernames/delete", s.authenticated(s.handleDeleteCollectibleUsername))
mux.HandleFunc("GET /v1/collectible-usernames", s.authenticated(s.handleCollectibleUsernames))
mux.HandleFunc("GET /v1/collectible-usernames/{id}", s.authenticated(s.handleCollectibleUsername))
mux.HandleFunc("POST /v1/account-ratings/recompute", s.authenticated(s.handleRecomputeAccountRating))
mux.HandleFunc("POST /v1/account-ratings/adjust", s.authenticated(s.handleAdjustAccountRating))
mux.HandleFunc("GET /v1/account-ratings", s.authenticated(s.handleAccountRatings))
mux.HandleFunc("GET /v1/account-ratings/{id}", s.authenticated(s.handleAccountRating))
// Official platform verification. Unlike every route above, these carry a
// named permission, so a scoped token can be given the review surface and
// nothing else. Revocation additionally requires verification.revoke.
mux.HandleFunc("GET /v1/verification/applications", s.authorized(PermissionVerificationReview, s.handleVerificationApplications))
mux.HandleFunc("GET /v1/verification/applications/{id}", s.authorized(PermissionVerificationReview, s.handleVerificationApplication))
mux.HandleFunc("GET /v1/verification/counts", s.authorized(PermissionVerificationReview, s.handleVerificationCounts))
mux.HandleFunc("POST /v1/verification/applications/{id}/claim", s.authorized(PermissionVerificationReview, s.handleClaimVerification))
mux.HandleFunc("POST /v1/verification/applications/{id}/approve", s.authorized(PermissionVerificationReview, s.handleApproveVerification))
mux.HandleFunc("POST /v1/verification/applications/{id}/reject", s.authorized(PermissionVerificationReview, s.handleRejectVerification))
mux.HandleFunc("POST /v1/verification/revoke", s.authorizedAll(
[]string{PermissionVerificationReview, PermissionVerificationRevoke}, s.handleRevokeVerification))
// Third-party bot verification. Separate routes, separate permissions and
// separate tables from the official verification block above -- the two
// mechanisms never read each other's state. Reads and queue decisions need
// botverification.review; appointing verifiers, curating icons and stripping a
// granted mark need botverification.manage.
mux.HandleFunc("GET /v1/botverification/verifiers", s.authorized(PermissionBotVerificationReview, s.handleBotVerifiers))
mux.HandleFunc("GET /v1/botverification/icons", s.authorized(PermissionBotVerificationReview, s.handleVerificationIcons))
mux.HandleFunc("GET /v1/botverification/marks", s.authorized(PermissionBotVerificationReview, s.handleCustomVerifications))
mux.HandleFunc("GET /v1/botverification/requests", s.authorized(PermissionBotVerificationReview, s.handleCustomVerificationRequests))
mux.HandleFunc("GET /v1/botverification/requests/{id}", s.authorized(PermissionBotVerificationReview, s.handleCustomVerificationRequest))
mux.HandleFunc("GET /v1/botverification/counts", s.authorized(PermissionBotVerificationReview, s.handleCustomVerificationCounts))
mux.HandleFunc("POST /v1/botverification/requests/{id}/approve", s.authorized(PermissionBotVerificationReview, s.handleApproveBotVerification))
mux.HandleFunc("POST /v1/botverification/requests/{id}/reject", s.authorized(PermissionBotVerificationReview, s.handleRejectBotVerification))
mux.HandleFunc("POST /v1/botverification/requests/{id}/revoke", s.authorized(PermissionBotVerificationReview, s.handleRevokeBotVerification))
mux.HandleFunc("POST /v1/botverification/verifiers/grant", s.authorized(PermissionBotVerificationManage, s.handleGrantBotVerifier))
mux.HandleFunc("POST /v1/botverification/verifiers/set-enabled", s.authorized(PermissionBotVerificationManage, s.handleSetBotVerifierEnabled))
mux.HandleFunc("POST /v1/botverification/verifiers/revoke", s.authorized(PermissionBotVerificationManage, s.handleRevokeBotVerifier))
mux.HandleFunc("POST /v1/botverification/icons/upsert", s.authorized(PermissionBotVerificationManage, s.handleUpsertVerificationIcon))
mux.HandleFunc("POST /v1/botverification/icons/set-active", s.authorized(PermissionBotVerificationManage, s.handleSetVerificationIconActive))
mux.HandleFunc("POST /v1/botverification/marks/revoke", s.authorized(PermissionBotVerificationManage, s.handleRevokeCustomVerification))
return mux
}
func (s *Server) authenticated(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
got := strings.TrimSpace(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer "))
if subtle.ConstantTimeCompare([]byte(got), []byte(s.token)) != 1 {
writeError(w, http.StatusUnauthorized, "unauthorized")
return
}
next(w, r)
}
}
func (s *Server) handleSetAccountFrozen(w http.ResponseWriter, r *http.Request) {
var req admin.SetAccountFrozenRequest
if !decodeJSON(w, r, &req) {
@ -938,6 +1025,372 @@ func writeModerationError(w http.ResponseWriter, err error) {
}
}
func (s *Server) handleMintCollectibleUsername(w http.ResponseWriter, r *http.Request) {
var req admin.MintCollectibleUsernameRequest
if !decodeJSON(w, r, &req) {
return
}
result, err := s.svc.MintCollectibleUsername(r.Context(), req)
writeCommandResult(w, result, err)
}
func (s *Server) handleTransferCollectibleUsername(w http.ResponseWriter, r *http.Request) {
var req admin.TransferCollectibleUsernameRequest
if !decodeJSON(w, r, &req) {
return
}
result, err := s.svc.TransferCollectibleUsername(r.Context(), req)
writeCommandResult(w, result, err)
}
func (s *Server) handleRevokeCollectibleUsername(w http.ResponseWriter, r *http.Request) {
var req admin.RevokeCollectibleUsernameRequest
if !decodeJSON(w, r, &req) {
return
}
result, err := s.svc.RevokeCollectibleUsername(r.Context(), req)
writeCommandResult(w, result, err)
}
func (s *Server) handleDeleteCollectibleUsername(w http.ResponseWriter, r *http.Request) {
var req admin.DeleteCollectibleUsernameRequest
if !decodeJSON(w, r, &req) {
return
}
result, err := s.svc.DeleteCollectibleUsername(r.Context(), req)
writeCommandResult(w, result, err)
}
func (s *Server) handleRecomputeAccountRating(w http.ResponseWriter, r *http.Request) {
var req admin.RecomputeAccountRatingRequest
if !decodeJSON(w, r, &req) {
return
}
result, err := s.svc.RecomputeAccountRating(r.Context(), req)
writeCommandResult(w, result, err)
}
func (s *Server) handleAdjustAccountRating(w http.ResponseWriter, r *http.Request) {
var req admin.AdjustAccountRatingRequest
if !decodeJSON(w, r, &req) {
return
}
result, err := s.svc.AdjustAccountRating(r.Context(), req)
writeCommandResult(w, result, err)
}
func (s *Server) handleCollectibleUsernames(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query()
filter := domain.CollectibleUsernameFilter{
Status: domain.CollectibleUsernameStatus(strings.TrimSpace(query.Get("status"))),
Query: query.Get("q"),
}
if filter.Status != "" && !filter.Status.Valid() {
writeCodedError(w, http.StatusBadRequest, admin.CodeCollectibleStateInvalid, "invalid status")
return
}
owner, ok := collectibleOwnerFilter(w, query)
if !ok {
return
}
filter.Owner = owner
limit, ok := optionalQueryInt(w, query, "limit")
if !ok {
return
}
filter.Limit = limit
beforeID, ok := optionalQueryInt64(w, query, "before_id")
if !ok {
return
}
filter.BeforeID = beforeID
items, err := s.svc.CollectibleUsernames(r.Context(), filter)
if err != nil {
writeCollectibleUsernameError(w, err)
return
}
assets := make([]map[string]any, 0, len(items))
for _, item := range items {
assets = append(assets, collectibleUsernameResponse(item))
}
writeJSON(w, http.StatusOK, map[string]any{"assets": assets})
}
func (s *Server) handleCollectibleUsername(w http.ResponseWriter, r *http.Request) {
id, ok := moderationPathID(w, r, "id")
if !ok {
return
}
asset, err := s.svc.CollectibleUsernameByID(r.Context(), id)
if err != nil {
writeCollectibleUsernameError(w, err)
return
}
limit, ok := optionalQueryInt(w, r.URL.Query(), "limit")
if !ok {
return
}
transfers, err := s.svc.CollectibleUsernameTransfers(r.Context(), asset.ID, limit)
if err != nil {
writeCollectibleUsernameError(w, err)
return
}
log := make([]map[string]any, 0, len(transfers))
for _, item := range transfers {
log = append(log, collectibleUsernameTransferResponse(item))
}
writeJSON(w, http.StatusOK, map[string]any{
"asset": collectibleUsernameResponse(asset), "transfers": log,
})
}
func (s *Server) handleAccountRatings(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query()
minLevel, ok := optionalQueryInt(w, query, "min_level")
if !ok {
return
}
userID, ok := optionalQueryInt64(w, query, "user_id")
if !ok {
return
}
beforeID, ok := optionalQueryInt64(w, query, "before_id")
if !ok {
return
}
limit, ok := optionalQueryInt(w, query, "limit")
if !ok {
return
}
items, err := s.svc.AccountRatings(r.Context(), domain.AccountRatingFilter{
MinLevel: minLevel, UserID: userID, BeforeID: beforeID, Limit: limit,
})
if err != nil {
writeAccountRatingError(w, err)
return
}
ratings := make([]map[string]any, 0, len(items))
for _, item := range items {
ratings = append(ratings, accountRatingResponse(item))
}
writeJSON(w, http.StatusOK, map[string]any{"ratings": ratings})
}
func (s *Server) handleAccountRating(w http.ResponseWriter, r *http.Request) {
userID, ok := moderationPathID(w, r, "id")
if !ok {
return
}
rating, err := s.svc.AccountRating(r.Context(), userID)
if err != nil {
writeAccountRatingError(w, err)
return
}
limit, ok := optionalQueryInt(w, r.URL.Query(), "limit")
if !ok {
return
}
events, err := s.svc.AccountRatingEvents(r.Context(), userID, limit)
if err != nil {
writeAccountRatingError(w, err)
return
}
ledger := make([]map[string]any, 0, len(events))
for _, item := range events {
ledger = append(ledger, accountRatingEventResponse(item))
}
writeJSON(w, http.StatusOK, map[string]any{
"rating": accountRatingResponse(rating), "events": ledger,
})
}
// collectibleOwnerFilter reads the optional owner filter. At most one of the two
// identifiers may be present, mirroring the mint/transfer request shape.
func collectibleOwnerFilter(w http.ResponseWriter, query url.Values) (domain.Peer, bool) {
userID, ok := optionalQueryInt64(w, query, "owner_user_id")
if !ok {
return domain.Peer{}, false
}
channelID, ok := optionalQueryInt64(w, query, "owner_channel_id")
if !ok {
return domain.Peer{}, false
}
switch {
case userID > 0 && channelID > 0:
writeError(w, http.StatusBadRequest, "at most one owner filter is allowed")
return domain.Peer{}, false
case userID > 0:
return domain.Peer{Type: domain.PeerTypeUser, ID: userID}, true
case channelID > 0:
return domain.Peer{Type: domain.PeerTypeChannel, ID: channelID}, true
default:
return domain.Peer{}, true
}
}
func optionalQueryInt64(w http.ResponseWriter, query url.Values, name string) (int64, bool) {
raw := strings.TrimSpace(query.Get(name))
if raw == "" {
return 0, true
}
value, err := strconv.ParseInt(raw, 10, 64)
if err != nil || value < 0 {
writeError(w, http.StatusBadRequest, "invalid "+name)
return 0, false
}
return value, true
}
func optionalQueryInt(w http.ResponseWriter, query url.Values, name string) (int, bool) {
value, ok := optionalQueryInt64(w, query, name)
if !ok {
return 0, false
}
if value > math.MaxInt32 {
writeError(w, http.StatusBadRequest, "invalid "+name)
return 0, false
}
return int(value), true
}
// collectibleUsernameResponse renders one asset. Every int64 crosses the JSON
// boundary as a decimal string: asset ids and nanoton amounts exceed the exact
// range of a JSON number, and a rounded id would address the wrong asset.
func collectibleUsernameResponse(asset domain.CollectibleUsername) map[string]any {
out := map[string]any{
"id": strconv.FormatInt(asset.ID, 10),
"username": asset.Username,
"status": string(asset.Status),
"owner_type": string(asset.Owner.Type),
"owner_id": strconv.FormatInt(asset.Owner.ID, 10),
"purchase_date": asset.Info().PurchaseDate,
"currency": asset.Currency,
"amount": strconv.FormatInt(asset.Amount, 10),
"crypto_currency": asset.CryptoCurrency,
"crypto_amount": strconv.FormatInt(asset.CryptoAmount, 10),
"url": asset.URL,
"original_owner_type": string(asset.OriginalOwner.Type),
"original_owner_id": strconv.FormatInt(asset.OriginalOwner.ID, 10),
"transfer_count": asset.TransferCount,
"version": strconv.FormatInt(asset.Version, 10),
}
if !asset.CreatedAt.IsZero() {
out["created_at"] = asset.CreatedAt.UTC().Format(time.RFC3339)
}
if !asset.UpdatedAt.IsZero() {
out["updated_at"] = asset.UpdatedAt.UTC().Format(time.RFC3339)
}
return out
}
func collectibleUsernameTransferResponse(item domain.CollectibleUsernameTransfer) map[string]any {
out := map[string]any{
"id": strconv.FormatInt(item.ID, 10),
"collectible_id": strconv.FormatInt(item.CollectibleID, 10),
"kind": string(item.Kind),
"from_type": string(item.From.Type),
"from_id": strconv.FormatInt(item.From.ID, 10),
"to_type": string(item.To.Type),
"to_id": strconv.FormatInt(item.To.ID, 10),
"currency": item.Currency,
"amount": strconv.FormatInt(item.Amount, 10),
"actor": item.Actor,
"reason": item.Reason,
"command_key": item.CommandKey,
}
if !item.CreatedAt.IsZero() {
out["created_at"] = item.CreatedAt.UTC().Format(time.RFC3339)
}
return out
}
// accountRatingResponse renders one composite rating. The score and every
// component stay decimal strings for the same exactness reason as the asset ids.
func accountRatingResponse(rating domain.AccountRating) map[string]any {
out := map[string]any{
"user_id": strconv.FormatInt(rating.UserID, 10),
"level": rating.Level,
"stars": strconv.FormatInt(rating.Stars, 10),
"current_level_stars": strconv.FormatInt(rating.CurrentLevelStars, 10),
"has_next_level": rating.HasNextLevel,
"stars_component": strconv.FormatInt(rating.StarsComponent, 10),
"activity_component": strconv.FormatInt(rating.ActivityComponent, 10),
"penalty_component": strconv.FormatInt(rating.PenaltyComponent, 10),
"manual_component": strconv.FormatInt(rating.ManualComponent, 10),
"pending_stars": strconv.FormatInt(rating.PendingStars, 10),
"version": strconv.FormatInt(rating.Version, 10),
}
if rating.HasNextLevel {
out["next_level_stars"] = strconv.FormatInt(rating.NextLevelStars, 10)
}
if !rating.PendingDate.IsZero() {
out["pending_date"] = rating.PendingDate.UTC().Format(time.RFC3339)
}
if !rating.ComputedAt.IsZero() {
out["computed_at"] = rating.ComputedAt.UTC().Format(time.RFC3339)
}
if !rating.UpdatedAt.IsZero() {
out["updated_at"] = rating.UpdatedAt.UTC().Format(time.RFC3339)
}
return out
}
func accountRatingEventResponse(event domain.AccountRatingEvent) map[string]any {
out := map[string]any{
"id": strconv.FormatInt(event.ID, 10),
"user_id": strconv.FormatInt(event.UserID, 10),
"kind": string(event.Kind),
"amount": strconv.FormatInt(event.Amount, 10),
"reason": event.Reason,
"actor": event.Actor,
"command_key": event.CommandKey,
}
if !event.CreatedAt.IsZero() {
out["created_at"] = event.CreatedAt.UTC().Format(time.RFC3339)
}
return out
}
// writeCollectibleUsernameError maps a collectible-username failure onto its
// stable admin code and the matching HTTP status, the way writeModerationError
// does for moderation. An unmapped failure stays a 500 with its own text rather
// than being dressed up as a client error.
func writeCollectibleUsernameError(w http.ResponseWriter, err error) {
code := admin.CollectibleUsernameErrorCode(err)
status := http.StatusInternalServerError
switch code {
case admin.CodeCollectibleNotFound:
status = http.StatusNotFound
case admin.CodeUsernameOccupied, admin.CodeCollectibleBurned,
admin.CodeCollectiblePeerLimit, admin.CodeCollectibleNotOwned:
status = http.StatusConflict
case admin.CodeUsernameInvalid, admin.CodeUsernameNotCollectible,
admin.CodeCollectibleCurrencyInvalid, admin.CodeCollectibleStateInvalid:
status = http.StatusBadRequest
}
writeCodedError(w, status, code, err.Error())
}
func writeAccountRatingError(w http.ResponseWriter, err error) {
code := admin.AccountRatingErrorCode(err)
status := http.StatusInternalServerError
switch code {
case admin.CodeRatingNotFound:
status = http.StatusNotFound
case admin.CodeRatingAdjustmentInvalid, admin.CodeRatingWeightsInvalid:
status = http.StatusBadRequest
}
writeCodedError(w, status, code, err.Error())
}
func writeCodedError(w http.ResponseWriter, status int, code, msg string) {
body := map[string]string{"error": msg}
if code != "" {
body["code"] = code
}
writeJSON(w, status, body)
}
func decodeJSON(w http.ResponseWriter, r *http.Request, dst any) bool {
defer r.Body.Close()
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20))