Ctrl K
ComunidadGuías…

internal/adminapi/botverification.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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) handleGrantBotVerificationMark(w http.ResponseWriter, r *http.Request) {
	var req admin.GrantBotVerificationRequest
	if !decodeJSON(w, r, &req) {
		return
	}
	// The shop sends peer_type: a missing one is most likely a truncated body,
	// and silently treating it as a user would grant against the wrong peer
	// namespace rather than failing loudly.
	if req.PeerType == "" {
		req.PeerType = domain.PeerTypeUser
	}
	s.applyVerificationPrincipal(r, &req.CommandMeta)
	result, err := s.svc.GrantBotVerification(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(), Code: code}
	}
	if result.Error == "" {
		result.Error = err.Error()
	}
	if result.Code == "" {
		result.Code = code
	}
	if code == admin.CodeCustomVerificationConflict {
		result.Message = "another operator changed this row first; reload it and try again"
	}
	writeJSON(w, status, result)
}