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:
parent
b0fd3976f1
commit
fff8de783a
169 changed files with 55769 additions and 282 deletions
561
internal/adminapi/botverification.go
Normal file
561
internal/adminapi/botverification.go
Normal file
|
|
@ -0,0 +1,561 @@
|
|||
package adminapi
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/admin"
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// Third-party bot verification over the admin API
|
||||
// (core.telegram.org/api/bots/verification).
|
||||
//
|
||||
// These are the mirror routes of the panel's own /api/botverification endpoints:
|
||||
// the panel reads straight from PostgreSQL for speed, while an integration holding
|
||||
// a scoped token reads it here. Every mutation only ever travels this way, so the
|
||||
// command journal, the status machine and the optimistic lock are enforced in one
|
||||
// place.
|
||||
//
|
||||
// This is NOT the official platform verification surface in verification.go. The
|
||||
// two mechanisms own separate tables, separate permissions (botverification.* vs
|
||||
// verification.*) and separate routes, and neither reads the other's state: a
|
||||
// third-party verifier must never be able to mint a platform checkmark.
|
||||
//
|
||||
// Every int64 crosses the JSON boundary as a decimal string. Bot ids, peer ids,
|
||||
// custom emoji document ids and the optimistic-locking version all exceed the
|
||||
// range a JSON number holds exactly, and a rounded version would decide the wrong
|
||||
// revision of a row.
|
||||
|
||||
// handleBotVerifiers lists verifier bots.
|
||||
func (s *Server) handleBotVerifiers(w http.ResponseWriter, r *http.Request) {
|
||||
query := r.URL.Query()
|
||||
limit, ok := optionalQueryInt(w, query, "limit")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
items, err := s.svc.BotVerifiers(r.Context(), queryBool(query.Get("enabled_only")), limit)
|
||||
if err != nil {
|
||||
writeBotVerificationError(w, err)
|
||||
return
|
||||
}
|
||||
rows := make([]map[string]any, 0, len(items))
|
||||
for _, item := range items {
|
||||
rows = append(rows, botVerifierResponse(item))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"rows": rows})
|
||||
}
|
||||
|
||||
// handleVerificationIcons lists the icon catalogue.
|
||||
func (s *Server) handleVerificationIcons(w http.ResponseWriter, r *http.Request) {
|
||||
query := r.URL.Query()
|
||||
limit, ok := optionalQueryInt(w, query, "limit")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
items, err := s.svc.VerificationIcons(r.Context(), queryBool(query.Get("active_only")), limit)
|
||||
if err != nil {
|
||||
writeBotVerificationError(w, err)
|
||||
return
|
||||
}
|
||||
rows := make([]map[string]any, 0, len(items))
|
||||
for _, item := range items {
|
||||
rows = append(rows, verificationIconResponse(item))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"rows": rows})
|
||||
}
|
||||
|
||||
// handleCustomVerifications lists granted marks with keyset paging.
|
||||
func (s *Server) handleCustomVerifications(w http.ResponseWriter, r *http.Request) {
|
||||
query := r.URL.Query()
|
||||
peerType, ok := botVerificationPeerType(w, query.Get("peer_type"))
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
verifierBotID, ok := optionalQueryInt64(w, query, "verifier_bot_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.CustomVerifications(r.Context(), domain.CustomVerificationFilter{
|
||||
VerifierBotID: verifierBotID,
|
||||
PeerType: peerType,
|
||||
Query: query.Get("q"),
|
||||
BeforeID: beforeID,
|
||||
Limit: limit,
|
||||
})
|
||||
if err != nil {
|
||||
writeBotVerificationError(w, err)
|
||||
return
|
||||
}
|
||||
rows := make([]map[string]any, 0, len(items))
|
||||
for _, item := range items {
|
||||
rows = append(rows, customVerificationResponse(item))
|
||||
}
|
||||
// The page bound is the use-case layer's, so has_more is derived from what came
|
||||
// back rather than from the limit the caller asked for.
|
||||
hasMore := limit > 0 && len(items) >= limit
|
||||
nextBeforeID := ""
|
||||
if hasMore && len(items) > 0 {
|
||||
nextBeforeID = strconv.FormatInt(items[len(items)-1].ID, 10)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"rows": rows,
|
||||
"has_more": hasMore,
|
||||
"next_before_id": nextBeforeID,
|
||||
})
|
||||
}
|
||||
|
||||
// handleCustomVerificationRequests is the third-party review queue.
|
||||
func (s *Server) handleCustomVerificationRequests(w http.ResponseWriter, r *http.Request) {
|
||||
query := r.URL.Query()
|
||||
peerType, ok := botVerificationPeerType(w, query.Get("peer_type"))
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
filter := domain.CustomVerificationRequestFilter{
|
||||
PeerType: peerType,
|
||||
Query: query.Get("q"),
|
||||
}
|
||||
// status accepts a comma-separated list, so a "pending,approved" view is one
|
||||
// request rather than two.
|
||||
for _, raw := range strings.Split(query.Get("status"), ",") {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
continue
|
||||
}
|
||||
status := domain.CustomVerificationRequestStatus(raw)
|
||||
if !status.Valid() {
|
||||
writeCodedError(w, http.StatusBadRequest, admin.CodeCustomVerificationStatusInvalid, "invalid status "+raw)
|
||||
return
|
||||
}
|
||||
filter.Statuses = append(filter.Statuses, status)
|
||||
}
|
||||
verifierBotID, ok := optionalQueryInt64(w, query, "verifier_bot_id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
filter.VerifierBotID = verifierBotID
|
||||
beforeID, ok := optionalQueryInt64(w, query, "before_id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
filter.BeforeID = beforeID
|
||||
limit, ok := optionalQueryInt(w, query, "limit")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
filter.Limit = limit
|
||||
items, err := s.svc.CustomVerificationRequests(r.Context(), filter)
|
||||
if err != nil {
|
||||
writeBotVerificationError(w, err)
|
||||
return
|
||||
}
|
||||
rows := make([]map[string]any, 0, len(items))
|
||||
for _, item := range items {
|
||||
rows = append(rows, customVerificationRequestResponse(item))
|
||||
}
|
||||
hasMore := limit > 0 && len(items) >= limit
|
||||
nextBeforeID := ""
|
||||
if hasMore && len(items) > 0 {
|
||||
nextBeforeID = strconv.FormatInt(items[len(items)-1].ID, 10)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"rows": rows,
|
||||
"has_more": hasMore,
|
||||
"next_before_id": nextBeforeID,
|
||||
})
|
||||
}
|
||||
|
||||
// handleCustomVerificationRequest is one application with the verifier behind it
|
||||
// and whether the mark is on the peer right now.
|
||||
func (s *Server) handleCustomVerificationRequest(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := moderationPathID(w, r, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
req, err := s.svc.CustomVerificationRequest(r.Context(), id)
|
||||
if err != nil {
|
||||
writeBotVerificationError(w, err)
|
||||
return
|
||||
}
|
||||
body := map[string]any{"request": customVerificationRequestResponse(req)}
|
||||
// The verifier row is advisory: it may have been revoked since the application
|
||||
// was filed, and that must not turn the audit record into a 500. An absent row
|
||||
// is reported as a verifier with only its id, so the reviewer can see which bot
|
||||
// it was.
|
||||
if settings, err := s.svc.BotVerifier(r.Context(), req.VerifierBotID); err == nil {
|
||||
body["verifier"] = botVerifierResponse(settings)
|
||||
} else if errors.Is(err, domain.ErrVerifierNotFound) {
|
||||
body["verifier"] = botVerifierResponse(domain.BotVerifierSettings{BotID: req.VerifierBotID})
|
||||
} else {
|
||||
body["verifier"] = botVerifierResponse(domain.BotVerifierSettings{BotID: req.VerifierBotID})
|
||||
body["verifier_error"] = err.Error()
|
||||
}
|
||||
// mark_active tells "approved" apart from "approved and since stripped by the
|
||||
// operator", which is the one thing the status alone cannot say.
|
||||
if active, err := s.svc.CustomVerificationMarkActive(r.Context(), req.VerifierBotID, req.Peer); err == nil {
|
||||
body["mark_active"] = active
|
||||
} else {
|
||||
body["mark_active"] = false
|
||||
body["mark_error"] = err.Error()
|
||||
}
|
||||
writeJSON(w, http.StatusOK, body)
|
||||
}
|
||||
|
||||
// handleCustomVerificationCounts is the queue summary.
|
||||
func (s *Server) handleCustomVerificationCounts(w http.ResponseWriter, r *http.Request) {
|
||||
counts, err := s.svc.CustomVerificationRequestCounts(r.Context())
|
||||
if err != nil {
|
||||
writeBotVerificationError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"counts": customVerificationCountsResponse(counts)})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Commands
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func (s *Server) handleGrantBotVerifier(w http.ResponseWriter, r *http.Request) {
|
||||
var req admin.GrantBotVerifierRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
s.applyVerificationPrincipal(r, &req.CommandMeta)
|
||||
result, err := s.svc.GrantBotVerifier(r.Context(), req)
|
||||
writeBotVerificationCommandResult(w, result, err)
|
||||
}
|
||||
|
||||
func (s *Server) handleSetBotVerifierEnabled(w http.ResponseWriter, r *http.Request) {
|
||||
var req admin.SetBotVerifierEnabledRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
s.applyVerificationPrincipal(r, &req.CommandMeta)
|
||||
result, err := s.svc.SetBotVerifierEnabled(r.Context(), req)
|
||||
writeBotVerificationCommandResult(w, result, err)
|
||||
}
|
||||
|
||||
func (s *Server) handleRevokeBotVerifier(w http.ResponseWriter, r *http.Request) {
|
||||
var req admin.RevokeBotVerifierRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
s.applyVerificationPrincipal(r, &req.CommandMeta)
|
||||
result, err := s.svc.RevokeBotVerifier(r.Context(), req)
|
||||
writeBotVerificationCommandResult(w, result, err)
|
||||
}
|
||||
|
||||
func (s *Server) handleUpsertVerificationIcon(w http.ResponseWriter, r *http.Request) {
|
||||
var req admin.UpsertVerificationIconRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
s.applyVerificationPrincipal(r, &req.CommandMeta)
|
||||
result, err := s.svc.UpsertVerificationIcon(r.Context(), req)
|
||||
writeBotVerificationCommandResult(w, result, err)
|
||||
}
|
||||
|
||||
func (s *Server) handleSetVerificationIconActive(w http.ResponseWriter, r *http.Request) {
|
||||
var req admin.SetVerificationIconActiveRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
s.applyVerificationPrincipal(r, &req.CommandMeta)
|
||||
result, err := s.svc.SetVerificationIconActive(r.Context(), req)
|
||||
writeBotVerificationCommandResult(w, result, err)
|
||||
}
|
||||
|
||||
func (s *Server) handleRevokeCustomVerification(w http.ResponseWriter, r *http.Request) {
|
||||
var req admin.RevokeCustomVerificationRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
s.applyVerificationPrincipal(r, &req.CommandMeta)
|
||||
result, err := s.svc.RevokeCustomVerification(r.Context(), req)
|
||||
writeBotVerificationCommandResult(w, result, err)
|
||||
}
|
||||
|
||||
func (s *Server) handleApproveBotVerification(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := moderationPathID(w, r, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req admin.ApproveBotVerificationRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
// The path is the authority on which application is decided: a body naming a
|
||||
// different one would make the URL lie to the audit trail.
|
||||
req.RequestID = id
|
||||
s.applyVerificationPrincipal(r, &req.CommandMeta)
|
||||
result, err := s.svc.ApproveBotVerification(r.Context(), req)
|
||||
writeBotVerificationCommandResult(w, result, err)
|
||||
}
|
||||
|
||||
func (s *Server) handleRejectBotVerification(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := moderationPathID(w, r, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req admin.RejectBotVerificationRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
req.RequestID = id
|
||||
s.applyVerificationPrincipal(r, &req.CommandMeta)
|
||||
result, err := s.svc.RejectBotVerification(r.Context(), req)
|
||||
writeBotVerificationCommandResult(w, result, err)
|
||||
}
|
||||
|
||||
func (s *Server) handleRevokeBotVerification(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := moderationPathID(w, r, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req admin.RevokeBotVerificationRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
req.RequestID = id
|
||||
s.applyVerificationPrincipal(r, &req.CommandMeta)
|
||||
result, err := s.svc.RevokeBotVerification(r.Context(), req)
|
||||
writeBotVerificationCommandResult(w, result, err)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rendering
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// botVerifierResponse renders one verifier bot. The keys are the panel's row
|
||||
// field names, so the same shape reaches the browser whether it came from here or
|
||||
// from the panel's direct read.
|
||||
func botVerifierResponse(settings domain.BotVerifierSettings) map[string]any {
|
||||
out := map[string]any{
|
||||
"BotID": strconv.FormatInt(settings.BotID, 10),
|
||||
"IconDocumentID": strconv.FormatInt(settings.IconDocumentID, 10),
|
||||
"CompanyName": settings.CompanyName,
|
||||
"DefaultDescription": settings.DefaultDescription,
|
||||
"CanModifyCustomDescription": settings.CanModifyCustomDescription,
|
||||
"Enabled": settings.Enabled,
|
||||
"GrantedBy": settings.GrantedBy,
|
||||
"GrantReason": settings.GrantReason,
|
||||
"Version": strconv.FormatInt(settings.Version, 10),
|
||||
}
|
||||
if !settings.CreatedAt.IsZero() {
|
||||
out["CreatedAt"] = settings.CreatedAt.UTC().Format(time.RFC3339)
|
||||
}
|
||||
if !settings.UpdatedAt.IsZero() {
|
||||
out["UpdatedAt"] = settings.UpdatedAt.UTC().Format(time.RFC3339)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func verificationIconResponse(icon domain.VerificationIcon) map[string]any {
|
||||
out := map[string]any{
|
||||
"ID": strconv.FormatInt(icon.ID, 10),
|
||||
"DocumentID": strconv.FormatInt(icon.DocumentID, 10),
|
||||
"OwnerBotID": strconv.FormatInt(icon.OwnerBotID, 10),
|
||||
"Name": icon.Name,
|
||||
"Active": icon.Active,
|
||||
}
|
||||
if !icon.CreatedAt.IsZero() {
|
||||
out["CreatedAt"] = icon.CreatedAt.UTC().Format(time.RFC3339)
|
||||
}
|
||||
if !icon.UpdatedAt.IsZero() {
|
||||
out["UpdatedAt"] = icon.UpdatedAt.UTC().Format(time.RFC3339)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func customVerificationResponse(mark domain.CustomVerification) map[string]any {
|
||||
out := map[string]any{
|
||||
"ID": strconv.FormatInt(mark.ID, 10),
|
||||
"VerifierBotID": strconv.FormatInt(mark.VerifierBotID, 10),
|
||||
"PeerType": string(mark.Peer.Type),
|
||||
"PeerID": strconv.FormatInt(mark.Peer.ID, 10),
|
||||
"IconDocumentID": strconv.FormatInt(mark.IconDocumentID, 10),
|
||||
"Description": mark.Description,
|
||||
"Version": strconv.FormatInt(mark.Version, 10),
|
||||
}
|
||||
if !mark.CreatedAt.IsZero() {
|
||||
out["CreatedAt"] = mark.CreatedAt.UTC().Format(time.RFC3339)
|
||||
}
|
||||
if !mark.UpdatedAt.IsZero() {
|
||||
out["UpdatedAt"] = mark.UpdatedAt.UTC().Format(time.RFC3339)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func customVerificationRequestResponse(req domain.CustomVerificationRequest) map[string]any {
|
||||
out := map[string]any{
|
||||
"ID": strconv.FormatInt(req.ID, 10),
|
||||
"VerifierBotID": strconv.FormatInt(req.VerifierBotID, 10),
|
||||
"ApplicantUserID": strconv.FormatInt(req.ApplicantUserID, 10),
|
||||
"PeerType": string(req.Peer.Type),
|
||||
"PeerID": strconv.FormatInt(req.Peer.ID, 10),
|
||||
"PeerTitle": req.PeerTitle,
|
||||
"PeerUsername": req.PeerUsername,
|
||||
"Reason": req.Reason,
|
||||
"RequestedDescription": req.RequestedDescription,
|
||||
"Status": string(req.Status),
|
||||
"DecidedBy": req.DecidedBy,
|
||||
"DecisionReason": req.DecisionReason,
|
||||
// InternalNote is operator-only. It is exposed here because every caller of
|
||||
// this route already holds botverification.review, and it is the reviewer's
|
||||
// own handover note; it is never part of the applicant-facing projection.
|
||||
"InternalNote": req.InternalNote,
|
||||
"CorrelationID": req.CorrelationID,
|
||||
"Version": strconv.FormatInt(req.Version, 10),
|
||||
}
|
||||
if !req.CreatedAt.IsZero() {
|
||||
out["CreatedAt"] = req.CreatedAt.UTC().Format(time.RFC3339)
|
||||
}
|
||||
if !req.UpdatedAt.IsZero() {
|
||||
out["UpdatedAt"] = req.UpdatedAt.UTC().Format(time.RFC3339)
|
||||
}
|
||||
if !req.ApprovedAt.IsZero() {
|
||||
out["ApprovedAt"] = req.ApprovedAt.UTC().Format(time.RFC3339)
|
||||
}
|
||||
if !req.RejectedAt.IsZero() {
|
||||
out["RejectedAt"] = req.RejectedAt.UTC().Format(time.RFC3339)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// customVerificationCountsResponse renders the queue summary with every modelled
|
||||
// status present, so the panel never has to distinguish "zero" from "absent". The
|
||||
// values are decimal strings for the same exactness reason as the ids.
|
||||
func customVerificationCountsResponse(counts map[domain.CustomVerificationRequestStatus]int64) map[string]string {
|
||||
out := make(map[string]string, len(customVerificationStatusOrder))
|
||||
for _, status := range customVerificationStatusOrder {
|
||||
out[string(status)] = strconv.FormatInt(counts[status], 10)
|
||||
}
|
||||
for status, count := range counts {
|
||||
if _, ok := out[string(status)]; !ok {
|
||||
out[string(status)] = strconv.FormatInt(count, 10)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// customVerificationStatusOrder is the closed status set, in lifecycle order.
|
||||
var customVerificationStatusOrder = []domain.CustomVerificationRequestStatus{
|
||||
domain.CustomVerificationPending,
|
||||
domain.CustomVerificationApproved,
|
||||
domain.CustomVerificationRejected,
|
||||
domain.CustomVerificationRevoked,
|
||||
}
|
||||
|
||||
// botVerificationPeerType validates the peer filter against the peer kinds a
|
||||
// third-party mark can sit on. An unmodelled value is a 400 rather than an empty
|
||||
// result, so a typo is reported instead of silently returning nothing.
|
||||
func botVerificationPeerType(w http.ResponseWriter, raw string) (domain.PeerType, bool) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return "", true
|
||||
}
|
||||
peerType := domain.PeerType(raw)
|
||||
if peerType != domain.PeerTypeUser && peerType != domain.PeerTypeChannel {
|
||||
writeCodedError(w, http.StatusBadRequest, admin.CodeCustomVerificationTargetInvalid, "invalid peer_type")
|
||||
return "", false
|
||||
}
|
||||
return peerType, true
|
||||
}
|
||||
|
||||
// queryBool reads a boolean flag the way the panel writes it: an absent or empty
|
||||
// value is false, and "1"/"true"/"yes" are true.
|
||||
func queryBool(raw string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(raw)) {
|
||||
case "1", "true", "yes", "on":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// botVerificationErrorStatus maps a third-party verification failure onto its HTTP
|
||||
// status.
|
||||
//
|
||||
// The version conflict is 409, not 400, because nothing about the request was
|
||||
// wrong -- another operator simply decided first, and the panel has to answer that
|
||||
// by reloading rather than by correcting input. The per-verifier bound is 409 for
|
||||
// the same reason: the request was well formed and the state refused it.
|
||||
func botVerificationErrorStatus(code string) int {
|
||||
switch code {
|
||||
case admin.CodeBotVerifierNotFound,
|
||||
admin.CodeBotVerifierBotNotFound,
|
||||
admin.CodeVerificationIconNotFound,
|
||||
admin.CodeCustomVerificationNotFound,
|
||||
admin.CodeCustomVerificationRequestNotFound:
|
||||
return http.StatusNotFound
|
||||
case admin.CodeCustomVerificationConflict,
|
||||
admin.CodeCustomVerificationLimit,
|
||||
admin.CodeCustomVerificationRequestExists:
|
||||
return http.StatusConflict
|
||||
case admin.CodeCustomVerificationRateLimited:
|
||||
return http.StatusTooManyRequests
|
||||
case admin.CodeBotVerifierForbidden,
|
||||
admin.CodeBotVerifierDescriptionForbidden,
|
||||
admin.CodeBotVerifierInvalid,
|
||||
admin.CodeVerificationIconInactive,
|
||||
admin.CodeVerificationIconInvalid,
|
||||
admin.CodeCustomVerificationStatusInvalid,
|
||||
admin.CodeCustomVerificationReasonRequired,
|
||||
admin.CodeCustomVerificationTargetInvalid,
|
||||
admin.CodeCustomVerificationTargetSystem,
|
||||
admin.CodeCustomVerificationInvalid:
|
||||
// BOTVERIFIER_FORBIDDEN is 400 rather than 403 on purpose: the caller is
|
||||
// authorised (403 is reserved for the permission gate), it is the *subject*
|
||||
// that may not verify, which the operator fixes by enabling the verifier.
|
||||
return http.StatusBadRequest
|
||||
default:
|
||||
return http.StatusInternalServerError
|
||||
}
|
||||
}
|
||||
|
||||
func writeBotVerificationError(w http.ResponseWriter, err error) {
|
||||
code := admin.BotVerificationErrorCode(err)
|
||||
writeCodedError(w, botVerificationErrorStatus(code), code, err.Error())
|
||||
}
|
||||
|
||||
// writeBotVerificationCommandResult answers a command.
|
||||
//
|
||||
// The body stays a CommandResult so the panel parses one shape for every operator
|
||||
// action, but the status is derived from the failure: a lost optimistic-locking
|
||||
// race must reach the browser as 409, because that is the one failure the panel
|
||||
// resolves by reloading the row instead of by asking the operator to fix the form.
|
||||
func writeBotVerificationCommandResult(w http.ResponseWriter, result admin.CommandResult, err error) {
|
||||
if err == nil {
|
||||
writeJSON(w, http.StatusOK, result)
|
||||
return
|
||||
}
|
||||
code := admin.BotVerificationErrorCode(err)
|
||||
status := botVerificationErrorStatus(code)
|
||||
if status == http.StatusInternalServerError {
|
||||
// An unmapped command failure is a bad request, as everywhere else in this
|
||||
// API, rather than a server fault.
|
||||
status = http.StatusBadRequest
|
||||
}
|
||||
if result.CommandID == "" {
|
||||
result = admin.CommandResult{Status: "failed", Message: "command failed", Error: err.Error()}
|
||||
}
|
||||
if result.Error == "" {
|
||||
result.Error = err.Error()
|
||||
}
|
||||
if code == admin.CodeCustomVerificationConflict {
|
||||
result.Message = "another operator changed this row first; reload it and try again"
|
||||
}
|
||||
writeJSON(w, status, result)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue