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
569
cmd/telesrv-admin/botverification.go
Normal file
569
cmd/telesrv-admin/botverification.go
Normal file
|
|
@ -0,0 +1,569 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"telesrv/internal/admin"
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// Third-party bot verification in the panel BFF
|
||||
// (core.telegram.org/api/bots/verification).
|
||||
//
|
||||
// This is NOT the official platform badge (see verification.go): third-party
|
||||
// verification is an attributed mark granted by a verifier bot, carrying that
|
||||
// verifier's own custom emoji icon and description. The two mechanisms own
|
||||
// separate tables (verification_icons / bot_verifier_settings /
|
||||
// custom_verifications / custom_verification_requests vs
|
||||
// verification_applications), separate permissions (botverification.* vs
|
||||
// verification.*) and separate routes, and neither reads the other's state.
|
||||
//
|
||||
// Reads come straight from PostgreSQL, like every other table view, so the tables
|
||||
// page without a hop through the admin API and peers can be resolved by a join.
|
||||
// Every mutation goes the other way -- always through the admin API, so the command
|
||||
// journal, the status machine and the optimistic lock are enforced in one place and
|
||||
// a panel action is indistinguishable from an API one in the audit trail.
|
||||
|
||||
// botVerificationRead mounts a route behind a session and botverification.review.
|
||||
func (s *server) botVerificationRead(handler http.HandlerFunc) http.Handler {
|
||||
return s.requireAuthAPI(s.requirePermission(permissionBotVerificationReview, handler))
|
||||
}
|
||||
|
||||
// botVerificationManage mounts a route behind a session and botverification.manage.
|
||||
//
|
||||
// The manage right is checked on its own rather than on top of review: appointing
|
||||
// a verifier and working its queue are different jobs, so an operator may hold
|
||||
// either without the other.
|
||||
func (s *server) botVerificationManage(handler http.HandlerFunc) http.Handler {
|
||||
return s.requireAuthAPI(s.requirePermission(permissionBotVerificationManage, handler))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reads
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func (s *server) handleBotVerifiersAPI(w http.ResponseWriter, r *http.Request) {
|
||||
query := r.URL.Query()
|
||||
limit, err := parseInt(query.Get("limit"))
|
||||
if err != nil || limit < 0 {
|
||||
writeAPIError(w, http.StatusBadRequest, "invalid limit")
|
||||
return
|
||||
}
|
||||
if s.read == nil {
|
||||
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")
|
||||
return
|
||||
}
|
||||
rows, err := s.read.ListBotVerifiers(r.Context(), queryFlag(query.Get("enabled_only")), limit)
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"rows": rows})
|
||||
}
|
||||
|
||||
func (s *server) handleVerificationIconsAPI(w http.ResponseWriter, r *http.Request) {
|
||||
query := r.URL.Query()
|
||||
limit, err := parseInt(query.Get("limit"))
|
||||
if err != nil || limit < 0 {
|
||||
writeAPIError(w, http.StatusBadRequest, "invalid limit")
|
||||
return
|
||||
}
|
||||
if s.read == nil {
|
||||
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")
|
||||
return
|
||||
}
|
||||
rows, err := s.read.ListVerificationIcons(r.Context(), queryFlag(query.Get("active_only")), limit)
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"rows": rows})
|
||||
}
|
||||
|
||||
// handleCustomVerificationsAPI pages granted marks. The filter is validated before
|
||||
// the read store is consulted: a malformed query is a 400 whether or not the
|
||||
// database happens to be reachable.
|
||||
func (s *server) handleCustomVerificationsAPI(w http.ResponseWriter, r *http.Request) {
|
||||
query := r.URL.Query()
|
||||
peerType := strings.TrimSpace(query.Get("peer_type"))
|
||||
if !validMarkablePeerType(peerType) {
|
||||
writeAPIError(w, http.StatusBadRequest, "invalid peer_type")
|
||||
return
|
||||
}
|
||||
verifierBotID, err := parseInt64(query.Get("verifier_bot_id"))
|
||||
if err != nil || verifierBotID < 0 {
|
||||
writeAPIError(w, http.StatusBadRequest, "invalid verifier_bot_id")
|
||||
return
|
||||
}
|
||||
beforeID, err := parseInt64(query.Get("before_id"))
|
||||
if err != nil || beforeID < 0 {
|
||||
writeAPIError(w, http.StatusBadRequest, "invalid before_id")
|
||||
return
|
||||
}
|
||||
limit, err := parseInt(query.Get("limit"))
|
||||
if err != nil || limit < 0 {
|
||||
writeAPIError(w, http.StatusBadRequest, "invalid limit")
|
||||
return
|
||||
}
|
||||
if s.read == nil {
|
||||
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")
|
||||
return
|
||||
}
|
||||
rows, hasMore, err := s.read.ListCustomVerifications(r.Context(), verifierBotID, peerType, query.Get("q"), beforeID, limit)
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
nextBeforeID := ""
|
||||
if hasMore && len(rows) > 0 {
|
||||
nextBeforeID = strconv.FormatInt(rows[len(rows)-1].ID, 10)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"rows": rows,
|
||||
"has_more": hasMore,
|
||||
"next_before_id": nextBeforeID,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *server) handleCustomVerificationRequestsAPI(w http.ResponseWriter, r *http.Request) {
|
||||
query := r.URL.Query()
|
||||
status := strings.TrimSpace(query.Get("status"))
|
||||
if status != "" && !domain.CustomVerificationRequestStatus(status).Valid() {
|
||||
writeAPIError(w, http.StatusBadRequest, "invalid status")
|
||||
return
|
||||
}
|
||||
peerType := strings.TrimSpace(query.Get("peer_type"))
|
||||
if !validMarkablePeerType(peerType) {
|
||||
writeAPIError(w, http.StatusBadRequest, "invalid peer_type")
|
||||
return
|
||||
}
|
||||
verifierBotID, err := parseInt64(query.Get("verifier_bot_id"))
|
||||
if err != nil || verifierBotID < 0 {
|
||||
writeAPIError(w, http.StatusBadRequest, "invalid verifier_bot_id")
|
||||
return
|
||||
}
|
||||
beforeID, err := parseInt64(query.Get("before_id"))
|
||||
if err != nil || beforeID < 0 {
|
||||
writeAPIError(w, http.StatusBadRequest, "invalid before_id")
|
||||
return
|
||||
}
|
||||
limit, err := parseInt(query.Get("limit"))
|
||||
if err != nil || limit < 0 {
|
||||
writeAPIError(w, http.StatusBadRequest, "invalid limit")
|
||||
return
|
||||
}
|
||||
if s.read == nil {
|
||||
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")
|
||||
return
|
||||
}
|
||||
rows, hasMore, err := s.read.ListCustomVerificationRequests(
|
||||
r.Context(), status, verifierBotID, peerType, query.Get("q"), beforeID, limit,
|
||||
)
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
nextBeforeID := ""
|
||||
if hasMore && len(rows) > 0 {
|
||||
nextBeforeID = strconv.FormatInt(rows[len(rows)-1].ID, 10)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"rows": rows,
|
||||
"has_more": hasMore,
|
||||
"next_before_id": nextBeforeID,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *server) handleCustomVerificationRequestDetailAPI(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := botVerificationPathID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if s.read == nil {
|
||||
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")
|
||||
return
|
||||
}
|
||||
detail, err := s.read.CustomVerificationRequestDetail(r.Context(), id)
|
||||
if err != nil {
|
||||
if errors.Is(err, errReadNotFound) {
|
||||
writeAPIError(w, http.StatusNotFound, "custom verification request not found")
|
||||
return
|
||||
}
|
||||
writeAPIError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"request": detail.Request,
|
||||
"verifier": detail.Verifier,
|
||||
// mark_active describes the peer as it is now, not as the status implies: a
|
||||
// reviewer has to see that an approved mark was since stripped by the
|
||||
// operator before deciding anything else about it.
|
||||
"mark_active": detail.MarkActive,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *server) handleCustomVerificationCountsAPI(w http.ResponseWriter, r *http.Request) {
|
||||
if s.read == nil {
|
||||
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")
|
||||
return
|
||||
}
|
||||
counts, err := s.read.CustomVerificationRequestCounts(r.Context())
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"counts": counts})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Queue decisions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// botVerificationDecisionAPIRequest is the decision payload shared by the three
|
||||
// per-application actions. version is the optimistic-locking token the reviewer
|
||||
// read; internal_note is operator-only and is not part of what the applicant is
|
||||
// told. It is optional everywhere, so one panel form can drive all three actions
|
||||
// without tripping the strict decoder.
|
||||
type botVerificationDecisionAPIRequest struct {
|
||||
CommandID string `json:"command_id"`
|
||||
Reason string `json:"reason"`
|
||||
Confirm bool `json:"confirm"`
|
||||
Version flexInt64 `json:"version"`
|
||||
InternalNote string `json:"internal_note"`
|
||||
}
|
||||
|
||||
func (s *server) handleApproveBotVerificationAPI(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := botVerificationPathID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var body botVerificationDecisionAPIRequest
|
||||
if !decodeAction(w, r, &body) {
|
||||
return
|
||||
}
|
||||
req := admin.ApproveBotVerificationRequest{
|
||||
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "approve-bot-verification"),
|
||||
RequestID: id,
|
||||
Version: body.Version.Int64(),
|
||||
InternalNote: body.InternalNote,
|
||||
}
|
||||
result, status, err := s.callAdminCommand(r.Context(), botVerificationDecisionPath(id, "approve"), req)
|
||||
writeBotVerificationResultAPI(w, result, status, err)
|
||||
}
|
||||
|
||||
func (s *server) handleRejectBotVerificationAPI(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := botVerificationPathID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var body botVerificationDecisionAPIRequest
|
||||
if !decodeAction(w, r, &body) {
|
||||
return
|
||||
}
|
||||
req := admin.RejectBotVerificationRequest{
|
||||
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "reject-bot-verification"),
|
||||
RequestID: id,
|
||||
Version: body.Version.Int64(),
|
||||
InternalNote: body.InternalNote,
|
||||
}
|
||||
result, status, err := s.callAdminCommand(r.Context(), botVerificationDecisionPath(id, "reject"), req)
|
||||
writeBotVerificationResultAPI(w, result, status, err)
|
||||
}
|
||||
|
||||
func (s *server) handleRevokeBotVerificationAPI(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := botVerificationPathID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var body botVerificationDecisionAPIRequest
|
||||
if !decodeAction(w, r, &body) {
|
||||
return
|
||||
}
|
||||
req := admin.RevokeBotVerificationRequest{
|
||||
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "revoke-bot-verification"),
|
||||
RequestID: id,
|
||||
Version: body.Version.Int64(),
|
||||
InternalNote: body.InternalNote,
|
||||
}
|
||||
result, status, err := s.callAdminCommand(r.Context(), botVerificationDecisionPath(id, "revoke"), req)
|
||||
writeBotVerificationResultAPI(w, result, status, err)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Operator actions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// grantBotVerifierAPIRequest appoints a bot as a verifier or reconfigures one.
|
||||
// version is 0 for a new grant and the token the operator read for an update, so
|
||||
// two operators editing the same verifier cannot clobber each other. enabled is
|
||||
// deliberately absent: the kill switch is its own action.
|
||||
type grantBotVerifierAPIRequest struct {
|
||||
CommandID string `json:"command_id"`
|
||||
Reason string `json:"reason"`
|
||||
Confirm bool `json:"confirm"`
|
||||
BotID flexInt64 `json:"bot_id"`
|
||||
IconDocumentID flexInt64 `json:"icon_document_id"`
|
||||
CompanyName string `json:"company_name"`
|
||||
DefaultDescription string `json:"default_description"`
|
||||
CanModifyCustomDescription bool `json:"can_modify_custom_description"`
|
||||
Version flexInt64 `json:"version"`
|
||||
}
|
||||
|
||||
func (s *server) handleGrantBotVerifierAPI(w http.ResponseWriter, r *http.Request) {
|
||||
var body grantBotVerifierAPIRequest
|
||||
if !decodeAction(w, r, &body) {
|
||||
return
|
||||
}
|
||||
if body.BotID.Int64() <= 0 {
|
||||
writeAPIError(w, http.StatusBadRequest, "invalid bot_id")
|
||||
return
|
||||
}
|
||||
if body.IconDocumentID.Int64() <= 0 {
|
||||
writeAPIError(w, http.StatusBadRequest, "invalid icon_document_id")
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(body.CompanyName) == "" {
|
||||
writeAPIError(w, http.StatusBadRequest, "company_name is required")
|
||||
return
|
||||
}
|
||||
if body.Version.Int64() < 0 {
|
||||
writeAPIError(w, http.StatusBadRequest, "invalid version")
|
||||
return
|
||||
}
|
||||
req := admin.GrantBotVerifierRequest{
|
||||
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "grant-bot-verifier"),
|
||||
BotID: body.BotID.Int64(),
|
||||
IconDocumentID: body.IconDocumentID.Int64(),
|
||||
CompanyName: body.CompanyName,
|
||||
DefaultDescription: body.DefaultDescription,
|
||||
CanModifyCustomDescription: body.CanModifyCustomDescription,
|
||||
Version: body.Version.Int64(),
|
||||
}
|
||||
result, status, err := s.callAdminCommand(r.Context(), "/v1/botverification/verifiers/grant", req)
|
||||
writeBotVerificationResultAPI(w, result, status, err)
|
||||
}
|
||||
|
||||
type setBotVerifierEnabledAPIRequest struct {
|
||||
CommandID string `json:"command_id"`
|
||||
Reason string `json:"reason"`
|
||||
Confirm bool `json:"confirm"`
|
||||
BotID flexInt64 `json:"bot_id"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
func (s *server) handleSetBotVerifierEnabledAPI(w http.ResponseWriter, r *http.Request) {
|
||||
var body setBotVerifierEnabledAPIRequest
|
||||
if !decodeAction(w, r, &body) {
|
||||
return
|
||||
}
|
||||
if body.BotID.Int64() <= 0 {
|
||||
writeAPIError(w, http.StatusBadRequest, "invalid bot_id")
|
||||
return
|
||||
}
|
||||
req := admin.SetBotVerifierEnabledRequest{
|
||||
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "set-bot-verifier-enabled"),
|
||||
BotID: body.BotID.Int64(),
|
||||
Enabled: body.Enabled,
|
||||
}
|
||||
result, status, err := s.callAdminCommand(r.Context(), "/v1/botverification/verifiers/set-enabled", req)
|
||||
writeBotVerificationResultAPI(w, result, status, err)
|
||||
}
|
||||
|
||||
type revokeBotVerifierAPIRequest struct {
|
||||
CommandID string `json:"command_id"`
|
||||
Reason string `json:"reason"`
|
||||
Confirm bool `json:"confirm"`
|
||||
BotID flexInt64 `json:"bot_id"`
|
||||
}
|
||||
|
||||
func (s *server) handleRevokeBotVerifierAPI(w http.ResponseWriter, r *http.Request) {
|
||||
var body revokeBotVerifierAPIRequest
|
||||
if !decodeAction(w, r, &body) {
|
||||
return
|
||||
}
|
||||
if body.BotID.Int64() <= 0 {
|
||||
writeAPIError(w, http.StatusBadRequest, "invalid bot_id")
|
||||
return
|
||||
}
|
||||
req := admin.RevokeBotVerifierRequest{
|
||||
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "revoke-bot-verifier"),
|
||||
BotID: body.BotID.Int64(),
|
||||
}
|
||||
result, status, err := s.callAdminCommand(r.Context(), "/v1/botverification/verifiers/revoke", req)
|
||||
writeBotVerificationResultAPI(w, result, status, err)
|
||||
}
|
||||
|
||||
// upsertVerificationIconAPIRequest adds or updates a catalogue entry. owner_bot_id
|
||||
// is optional: absent (or 0) means a shared entry any verifier may use.
|
||||
type upsertVerificationIconAPIRequest struct {
|
||||
CommandID string `json:"command_id"`
|
||||
Reason string `json:"reason"`
|
||||
Confirm bool `json:"confirm"`
|
||||
DocumentID flexInt64 `json:"document_id"`
|
||||
Name string `json:"name"`
|
||||
OwnerBotID flexInt64 `json:"owner_bot_id"`
|
||||
}
|
||||
|
||||
func (s *server) handleUpsertVerificationIconAPI(w http.ResponseWriter, r *http.Request) {
|
||||
var body upsertVerificationIconAPIRequest
|
||||
if !decodeAction(w, r, &body) {
|
||||
return
|
||||
}
|
||||
if body.DocumentID.Int64() <= 0 {
|
||||
writeAPIError(w, http.StatusBadRequest, "invalid document_id")
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(body.Name) == "" {
|
||||
writeAPIError(w, http.StatusBadRequest, "name is required")
|
||||
return
|
||||
}
|
||||
if body.OwnerBotID.Int64() < 0 {
|
||||
writeAPIError(w, http.StatusBadRequest, "invalid owner_bot_id")
|
||||
return
|
||||
}
|
||||
req := admin.UpsertVerificationIconRequest{
|
||||
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "upsert-verification-icon"),
|
||||
DocumentID: body.DocumentID.Int64(),
|
||||
Name: body.Name,
|
||||
OwnerBotID: body.OwnerBotID.Int64(),
|
||||
}
|
||||
result, status, err := s.callAdminCommand(r.Context(), "/v1/botverification/icons/upsert", req)
|
||||
writeBotVerificationResultAPI(w, result, status, err)
|
||||
}
|
||||
|
||||
type setVerificationIconActiveAPIRequest struct {
|
||||
CommandID string `json:"command_id"`
|
||||
Reason string `json:"reason"`
|
||||
Confirm bool `json:"confirm"`
|
||||
IconID flexInt64 `json:"icon_id"`
|
||||
Active bool `json:"active"`
|
||||
}
|
||||
|
||||
func (s *server) handleSetVerificationIconActiveAPI(w http.ResponseWriter, r *http.Request) {
|
||||
var body setVerificationIconActiveAPIRequest
|
||||
if !decodeAction(w, r, &body) {
|
||||
return
|
||||
}
|
||||
if body.IconID.Int64() <= 0 {
|
||||
writeAPIError(w, http.StatusBadRequest, "invalid icon_id")
|
||||
return
|
||||
}
|
||||
req := admin.SetVerificationIconActiveRequest{
|
||||
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "set-verification-icon-active"),
|
||||
IconID: body.IconID.Int64(),
|
||||
Active: body.Active,
|
||||
}
|
||||
result, status, err := s.callAdminCommand(r.Context(), "/v1/botverification/icons/set-active", req)
|
||||
writeBotVerificationResultAPI(w, result, status, err)
|
||||
}
|
||||
|
||||
// revokeCustomVerificationAPIRequest strips one verifier's mark from a peer. It
|
||||
// addresses the (verifier, peer) pair rather than an application, because the
|
||||
// operator may have to strip a mark no application ever produced.
|
||||
type revokeCustomVerificationAPIRequest struct {
|
||||
CommandID string `json:"command_id"`
|
||||
Reason string `json:"reason"`
|
||||
Confirm bool `json:"confirm"`
|
||||
VerifierBotID flexInt64 `json:"verifier_bot_id"`
|
||||
PeerType string `json:"peer_type"`
|
||||
PeerID flexInt64 `json:"peer_id"`
|
||||
}
|
||||
|
||||
func (s *server) handleRevokeCustomVerificationAPI(w http.ResponseWriter, r *http.Request) {
|
||||
var body revokeCustomVerificationAPIRequest
|
||||
if !decodeAction(w, r, &body) {
|
||||
return
|
||||
}
|
||||
if body.VerifierBotID.Int64() <= 0 {
|
||||
writeAPIError(w, http.StatusBadRequest, "invalid verifier_bot_id")
|
||||
return
|
||||
}
|
||||
peerType := strings.TrimSpace(body.PeerType)
|
||||
if peerType == "" || !validMarkablePeerType(peerType) {
|
||||
writeAPIError(w, http.StatusBadRequest, "invalid peer_type")
|
||||
return
|
||||
}
|
||||
if body.PeerID.Int64() <= 0 {
|
||||
writeAPIError(w, http.StatusBadRequest, "invalid peer_id")
|
||||
return
|
||||
}
|
||||
req := admin.RevokeCustomVerificationRequest{
|
||||
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "revoke-custom-verification"),
|
||||
VerifierBotID: body.VerifierBotID.Int64(),
|
||||
PeerType: domain.PeerType(peerType),
|
||||
PeerID: body.PeerID.Int64(),
|
||||
}
|
||||
result, status, err := s.callAdminCommand(r.Context(), "/v1/botverification/marks/revoke", req)
|
||||
writeBotVerificationResultAPI(w, result, status, err)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func botVerificationPathID(w http.ResponseWriter, r *http.Request) (int64, bool) {
|
||||
id, err := parseInt64(r.PathValue("id"))
|
||||
if err != nil || id <= 0 {
|
||||
writeAPIError(w, http.StatusBadRequest, "invalid id")
|
||||
return 0, false
|
||||
}
|
||||
return id, true
|
||||
}
|
||||
|
||||
func botVerificationDecisionPath(requestID int64, action string) string {
|
||||
return "/v1/botverification/requests/" + strconv.FormatInt(requestID, 10) + "/" + action
|
||||
}
|
||||
|
||||
// validMarkablePeerType accepts the peer kinds a third-party mark can sit on, plus
|
||||
// the empty string for "no filter". An unmodelled value is refused rather than
|
||||
// silently returning nothing, so a typo is reported.
|
||||
func validMarkablePeerType(peerType string) bool {
|
||||
switch domain.PeerType(peerType) {
|
||||
case "", domain.PeerTypeUser, domain.PeerTypeChannel:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// queryFlag reads a boolean query flag the way the panel writes it.
|
||||
func queryFlag(raw string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(raw)) {
|
||||
case "1", "true", "yes", "on":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// writeBotVerificationResultAPI relays the admin API's own status to the browser.
|
||||
//
|
||||
// The generic action handlers flatten every upstream failure into 502, which is
|
||||
// fine when the only failure mode is "bad request". These have more: 409 when
|
||||
// another operator changed the row first or a verifier hit its mark bound, and 404
|
||||
// for a row that is gone. Those have to reach the panel intact, because 409 is the
|
||||
// one failure it resolves by reloading rather than by asking the operator to change
|
||||
// something.
|
||||
func writeBotVerificationResultAPI(w http.ResponseWriter, result admin.CommandResult, status int, err error) {
|
||||
if err == nil {
|
||||
writeJSON(w, http.StatusOK, result)
|
||||
return
|
||||
}
|
||||
if result.Status == "" {
|
||||
result.Status = "failed"
|
||||
}
|
||||
if result.Message == "" {
|
||||
result.Message = "command failed"
|
||||
}
|
||||
if result.Error == "" {
|
||||
result.Error = err.Error()
|
||||
}
|
||||
if status < 400 {
|
||||
// No HTTP answer at all: the admin API was unreachable or unparsable.
|
||||
status = http.StatusBadGateway
|
||||
}
|
||||
writeJSON(w, status, result)
|
||||
}
|
||||
559
cmd/telesrv-admin/botverification_test.go
Normal file
559
cmd/telesrv-admin/botverification_test.go
Normal file
|
|
@ -0,0 +1,559 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"telesrv/internal/admin"
|
||||
)
|
||||
|
||||
// Third-party bot verification in the panel BFF. The section is separate from the
|
||||
// official verification one in every dimension that matters here: its own routes,
|
||||
// its own two permissions, and no overlap with verification.* in either direction.
|
||||
|
||||
// botVerificationRoute is one panel route with a body its handler accepts.
|
||||
type panelBotVerificationRoute struct {
|
||||
method string
|
||||
path string
|
||||
body string
|
||||
}
|
||||
|
||||
var panelBotVerificationReadRoutes = []panelBotVerificationRoute{
|
||||
{http.MethodGet, "/api/botverification/verifiers", ""},
|
||||
{http.MethodGet, "/api/botverification/icons", ""},
|
||||
{http.MethodGet, "/api/botverification/marks", ""},
|
||||
{http.MethodGet, "/api/botverification/requests", ""},
|
||||
{http.MethodGet, "/api/botverification/requests/7", ""},
|
||||
{http.MethodGet, "/api/botverification/counts", ""},
|
||||
{http.MethodPost, "/api/botverification/requests/7/approve", `{}`},
|
||||
{http.MethodPost, "/api/botverification/requests/7/reject", `{}`},
|
||||
{http.MethodPost, "/api/botverification/requests/7/revoke", `{}`},
|
||||
}
|
||||
|
||||
var panelBotVerificationManageRoutes = []panelBotVerificationRoute{
|
||||
{http.MethodPost, "/api/actions/grant-bot-verifier", `{}`},
|
||||
{http.MethodPost, "/api/actions/set-bot-verifier-enabled", `{}`},
|
||||
{http.MethodPost, "/api/actions/revoke-bot-verifier", `{}`},
|
||||
{http.MethodPost, "/api/actions/upsert-verification-icon", `{}`},
|
||||
{http.MethodPost, "/api/actions/set-verification-icon-active", `{}`},
|
||||
{http.MethodPost, "/api/actions/revoke-custom-verification", `{}`},
|
||||
}
|
||||
|
||||
func panelBotVerificationRoutes() []panelBotVerificationRoute {
|
||||
out := make([]panelBotVerificationRoute, 0,
|
||||
len(panelBotVerificationReadRoutes)+len(panelBotVerificationManageRoutes))
|
||||
out = append(out, panelBotVerificationReadRoutes...)
|
||||
return append(out, panelBotVerificationManageRoutes...)
|
||||
}
|
||||
|
||||
func TestBotVerificationPanelRoutesRequireASession(t *testing.T) {
|
||||
srv := panelServer(t, permissionAll)
|
||||
for _, item := range panelBotVerificationRoutes() {
|
||||
rec := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, httptest.NewRequest(item.method, item.path, strings.NewReader(`{}`)))
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("%s %s status=%d, want 401", item.method, item.path, rec.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Every mutating route in the section is behind the double-submit CSRF token, like
|
||||
// every other one in the panel: a cookie-authenticated request forged by another
|
||||
// origin must not be able to appoint a verifier.
|
||||
func TestBotVerificationMutationsRequireTheCSRFHeader(t *testing.T) {
|
||||
srv := panelServer(t, permissionAll)
|
||||
cookies, token := signIn(t, srv)
|
||||
for _, item := range panelBotVerificationRoutes() {
|
||||
if item.method != http.MethodPost {
|
||||
continue
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, withCookies(
|
||||
httptest.NewRequest(item.method, item.path, strings.NewReader(item.body)), cookies))
|
||||
if rec.Code != http.StatusForbidden || !strings.Contains(rec.Body.String(), csrfHeaderName) {
|
||||
t.Fatalf("%s status=%d body=%s, want 403 without a csrf header", item.path, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
// A foreign origin is refused even when the token is right.
|
||||
req := withCookies(httptest.NewRequest(http.MethodPost, "/api/actions/grant-bot-verifier",
|
||||
strings.NewReader(`{}`)), cookies)
|
||||
req.Header.Set(csrfHeaderName, token)
|
||||
req.Header.Set("Origin", "https://evil.example")
|
||||
rec := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusForbidden || !strings.Contains(rec.Body.String(), "origin") {
|
||||
t.Fatalf("foreign origin status=%d body=%s, want 403", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// Reads do not need the token: they change nothing, and requiring it would break
|
||||
// the panel without adding protection.
|
||||
func TestBotVerificationReadsDoNotNeedTheCSRFHeader(t *testing.T) {
|
||||
srv := panelServer(t, permissionBotVerificationReview)
|
||||
cookies, _ := signIn(t, srv)
|
||||
rec := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, withCookies(
|
||||
httptest.NewRequest(http.MethodGet, "/api/botverification/verifiers", nil), cookies))
|
||||
// No read store is wired in this fixture, so the gate passing is what is under
|
||||
// test: 503 means the request got past authorisation and CSRF.
|
||||
if rec.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("GET status=%d body=%s, want the gate passed without a token", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotVerificationRoutesRefuseASessionWithoutTheRight(t *testing.T) {
|
||||
// A session holding only the OFFICIAL verification rights: the two mechanisms
|
||||
// are separate, so it must not reach this section at all.
|
||||
srv := panelServer(t, permissionVerificationReview, permissionVerificationRevoke)
|
||||
cookies, token := signIn(t, srv)
|
||||
|
||||
check := func(item panelBotVerificationRoute, wantPermission string) {
|
||||
var req *http.Request
|
||||
if item.body == "" {
|
||||
req = httptest.NewRequest(item.method, item.path, nil)
|
||||
} else {
|
||||
req = httptest.NewRequest(item.method, item.path, strings.NewReader(item.body))
|
||||
req.Header.Set(csrfHeaderName, token)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, withCookies(req, cookies))
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("%s %s status=%d body=%s, want 403", item.method, item.path, rec.Code, rec.Body.String())
|
||||
}
|
||||
var body map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("decode 403 body: %v", err)
|
||||
}
|
||||
if body["code"] != "FORBIDDEN" || body["permission"] != wantPermission {
|
||||
t.Fatalf("%s 403 body=%+v, want %s named", item.path, body, wantPermission)
|
||||
}
|
||||
}
|
||||
for _, item := range panelBotVerificationReadRoutes {
|
||||
check(item, permissionBotVerificationReview)
|
||||
}
|
||||
for _, item := range panelBotVerificationManageRoutes {
|
||||
check(item, permissionBotVerificationManage)
|
||||
}
|
||||
}
|
||||
|
||||
// The two halves are independent: the review right does not appoint verifiers, and
|
||||
// the manage right does not decide applications.
|
||||
func TestBotVerificationReviewAndManageAreIndependent(t *testing.T) {
|
||||
reviewOnly := panelServer(t, permissionBotVerificationReview)
|
||||
cookies, token := signIn(t, reviewOnly)
|
||||
req := withCookies(httptest.NewRequest(http.MethodPost, "/api/actions/grant-bot-verifier",
|
||||
strings.NewReader(`{"reason":"partner","confirm":true,"bot_id":3003,"icon_document_id":900,"company_name":"x"}`)), cookies)
|
||||
req.Header.Set(csrfHeaderName, token)
|
||||
rec := httptest.NewRecorder()
|
||||
reviewOnly.routes().ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusForbidden || !strings.Contains(rec.Body.String(), permissionBotVerificationManage) {
|
||||
t.Fatalf("review-only on grant status=%d body=%s, want 403 naming manage", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
manageOnly := panelServer(t, permissionBotVerificationManage)
|
||||
cookies, token = signIn(t, manageOnly)
|
||||
req = withCookies(httptest.NewRequest(http.MethodPost, "/api/botverification/requests/7/approve",
|
||||
strings.NewReader(`{"reason":"verified","confirm":true,"version":3}`)), cookies)
|
||||
req.Header.Set(csrfHeaderName, token)
|
||||
rec = httptest.NewRecorder()
|
||||
manageOnly.routes().ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusForbidden || !strings.Contains(rec.Body.String(), permissionBotVerificationReview) {
|
||||
t.Fatalf("manage-only on approve status=%d body=%s, want 403 naming review", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// A session holding the third-party rights must not reach the official section
|
||||
// either: the separation is symmetric.
|
||||
func TestBotVerificationSessionCannotReachTheOfficialSection(t *testing.T) {
|
||||
srv := panelServer(t, permissionBotVerificationReview, permissionBotVerificationManage)
|
||||
cookies, _ := signIn(t, srv)
|
||||
for _, path := range []string{"/api/verification/applications", "/api/verification/counts"} {
|
||||
rec := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, withCookies(httptest.NewRequest(http.MethodGet, path, nil), cookies))
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("%s status=%d body=%s, want 403", path, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestApproveBotVerificationBFFForwardsActorVersionAndNote(t *testing.T) {
|
||||
upstream := &verificationUpstream{body: admin.CommandResult{CommandID: "c1", Status: "completed"}}
|
||||
api := httptest.NewServer(upstream.handler(t))
|
||||
defer api.Close()
|
||||
|
||||
srv := &server{cfg: uiConfig{AdminAPIURL: api.URL, AdminAPIToken: "api-secret"}}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/botverification/requests/88/approve", strings.NewReader(`{
|
||||
"reason":"the outlet checks out","confirm":true,"version":"9223372036854775807",
|
||||
"internal_note":"contact came through the press office"
|
||||
}`))
|
||||
req.SetPathValue("id", "88")
|
||||
req = requestWithActor(req, "operator")
|
||||
rec := httptest.NewRecorder()
|
||||
srv.handleApproveBotVerificationAPI(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if upstream.path != "/v1/botverification/requests/88/approve" {
|
||||
t.Fatalf("upstream path=%q", upstream.path)
|
||||
}
|
||||
var got admin.ApproveBotVerificationRequest
|
||||
if err := json.Unmarshal(upstream.raw, &got); err != nil {
|
||||
t.Fatalf("decode forwarded approval: %v (%s)", err, upstream.raw)
|
||||
}
|
||||
if got.Actor != "operator" {
|
||||
t.Fatalf("actor=%q, want the signed-in operator", got.Actor)
|
||||
}
|
||||
// The version arrives as a decimal string from the browser and must survive
|
||||
// exactly: a rounded version would decide the wrong revision of the row.
|
||||
if got.RequestID != 88 || got.Version != 9223372036854775807 {
|
||||
t.Fatalf("forwarded approval=%+v, want the exact int64 version", got)
|
||||
}
|
||||
if got.InternalNote != "contact came through the press office" || got.DryRun {
|
||||
t.Fatalf("forwarded approval=%+v", got)
|
||||
}
|
||||
if got.CommandID == "" {
|
||||
t.Fatal("no command id was minted for the idempotency key")
|
||||
}
|
||||
}
|
||||
|
||||
// confirm=false is a rehearsal: nothing may be written until the operator confirms.
|
||||
func TestBotVerificationBFFDefaultsToADryRun(t *testing.T) {
|
||||
upstream := &verificationUpstream{body: admin.CommandResult{CommandID: "c1", Status: "completed", DryRun: true}}
|
||||
api := httptest.NewServer(upstream.handler(t))
|
||||
defer api.Close()
|
||||
|
||||
srv := &server{cfg: uiConfig{AdminAPIURL: api.URL, AdminAPIToken: "api-secret"}}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/botverification/requests/88/reject", strings.NewReader(
|
||||
`{"reason":"not an outlet","confirm":false,"version":3}`))
|
||||
req.SetPathValue("id", "88")
|
||||
req = requestWithActor(req, "operator")
|
||||
rec := httptest.NewRecorder()
|
||||
srv.handleRejectBotVerificationAPI(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var got admin.RejectBotVerificationRequest
|
||||
if err := json.Unmarshal(upstream.raw, &got); err != nil {
|
||||
t.Fatalf("decode forwarded rejection: %v", err)
|
||||
}
|
||||
if !got.DryRun || got.Version != 3 || got.RequestID != 88 {
|
||||
t.Fatalf("forwarded rejection=%+v", got)
|
||||
}
|
||||
|
||||
// The same on an operator action.
|
||||
req = requestWithActor(httptest.NewRequest(http.MethodPost, "/api/actions/revoke-bot-verifier", strings.NewReader(
|
||||
`{"reason":"programme ended","confirm":false,"bot_id":3003}`)), "operator")
|
||||
rec = httptest.NewRecorder()
|
||||
srv.handleRevokeBotVerifierAPI(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("revoke status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var revoke admin.RevokeBotVerifierRequest
|
||||
if err := json.Unmarshal(upstream.raw, &revoke); err != nil {
|
||||
t.Fatalf("decode forwarded revocation: %v", err)
|
||||
}
|
||||
if !revoke.DryRun || revoke.BotID != 3003 {
|
||||
t.Fatalf("forwarded revocation=%+v", revoke)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGrantBotVerifierBFFForwardsThePayloadAndRejectsBadShapes(t *testing.T) {
|
||||
upstream := &verificationUpstream{body: admin.CommandResult{CommandID: "c1", Status: "completed"}}
|
||||
api := httptest.NewServer(upstream.handler(t))
|
||||
defer api.Close()
|
||||
|
||||
srv := &server{cfg: uiConfig{AdminAPIURL: api.URL, AdminAPIToken: "api-secret"}}
|
||||
req := requestWithActor(httptest.NewRequest(http.MethodPost, "/api/actions/grant-bot-verifier", strings.NewReader(`{
|
||||
"reason":"partner programme","confirm":true,
|
||||
"bot_id":"9223372036854775807","icon_document_id":"9223372036854775806",
|
||||
"company_name":"Example Trust","default_description":"verified by Example Trust",
|
||||
"can_modify_custom_description":true,"version":"4"
|
||||
}`)), "operator")
|
||||
rec := httptest.NewRecorder()
|
||||
srv.handleGrantBotVerifierAPI(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if upstream.path != "/v1/botverification/verifiers/grant" {
|
||||
t.Fatalf("upstream path=%q", upstream.path)
|
||||
}
|
||||
var got admin.GrantBotVerifierRequest
|
||||
if err := json.Unmarshal(upstream.raw, &got); err != nil {
|
||||
t.Fatalf("decode forwarded grant: %v", err)
|
||||
}
|
||||
if got.BotID != 9223372036854775807 || got.IconDocumentID != 9223372036854775806 ||
|
||||
got.Version != 4 || got.Actor != "operator" || got.DryRun {
|
||||
t.Fatalf("forwarded grant=%+v, want the exact int64s", got)
|
||||
}
|
||||
if got.CompanyName != "Example Trust" || !got.CanModifyCustomDescription {
|
||||
t.Fatalf("forwarded grant=%+v", got)
|
||||
}
|
||||
|
||||
for _, payload := range []string{
|
||||
`{"reason":"x","confirm":true,"bot_id":0,"icon_document_id":900,"company_name":"y"}`,
|
||||
`{"reason":"x","confirm":true,"bot_id":3003,"icon_document_id":0,"company_name":"y"}`,
|
||||
`{"reason":"x","confirm":true,"bot_id":3003,"icon_document_id":900,"company_name":" "}`,
|
||||
`{"reason":"x","confirm":true,"bot_id":3003,"icon_document_id":900,"company_name":"y","version":-1}`,
|
||||
} {
|
||||
rec := httptest.NewRecorder()
|
||||
srv.handleGrantBotVerifierAPI(rec, requestWithActor(
|
||||
httptest.NewRequest(http.MethodPost, "/api/actions/grant-bot-verifier", strings.NewReader(payload)), "operator"))
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("payload %s status=%d body=%s, want 400", payload, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotVerificationOperatorActionsForwardTheirPayloads(t *testing.T) {
|
||||
upstream := &verificationUpstream{body: admin.CommandResult{CommandID: "c1", Status: "completed"}}
|
||||
api := httptest.NewServer(upstream.handler(t))
|
||||
defer api.Close()
|
||||
srv := &server{cfg: uiConfig{AdminAPIURL: api.URL, AdminAPIToken: "api-secret"}}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
srv.handleSetBotVerifierEnabledAPI(rec, requestWithActor(httptest.NewRequest(
|
||||
http.MethodPost, "/api/actions/set-bot-verifier-enabled", strings.NewReader(
|
||||
`{"reason":"abuse report","confirm":true,"bot_id":"3003","enabled":false}`)), "operator"))
|
||||
if rec.Code != http.StatusOK || upstream.path != "/v1/botverification/verifiers/set-enabled" {
|
||||
t.Fatalf("set-enabled status=%d path=%q body=%s", rec.Code, upstream.path, rec.Body.String())
|
||||
}
|
||||
var setEnabled admin.SetBotVerifierEnabledRequest
|
||||
if err := json.Unmarshal(upstream.raw, &setEnabled); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if setEnabled.BotID != 3003 || setEnabled.Enabled || setEnabled.Actor != "operator" {
|
||||
t.Fatalf("forwarded=%+v", setEnabled)
|
||||
}
|
||||
|
||||
rec = httptest.NewRecorder()
|
||||
srv.handleUpsertVerificationIconAPI(rec, requestWithActor(httptest.NewRequest(
|
||||
http.MethodPost, "/api/actions/upsert-verification-icon", strings.NewReader(
|
||||
`{"reason":"new icon","confirm":true,"document_id":"9223372036854775807","name":"blue check","owner_bot_id":"3003"}`)), "operator"))
|
||||
if rec.Code != http.StatusOK || upstream.path != "/v1/botverification/icons/upsert" {
|
||||
t.Fatalf("upsert-icon status=%d path=%q body=%s", rec.Code, upstream.path, rec.Body.String())
|
||||
}
|
||||
var icon admin.UpsertVerificationIconRequest
|
||||
if err := json.Unmarshal(upstream.raw, &icon); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if icon.DocumentID != 9223372036854775807 || icon.Name != "blue check" || icon.OwnerBotID != 3003 {
|
||||
t.Fatalf("forwarded=%+v", icon)
|
||||
}
|
||||
// owner_bot_id is optional: absent means a shared catalogue entry.
|
||||
rec = httptest.NewRecorder()
|
||||
srv.handleUpsertVerificationIconAPI(rec, requestWithActor(httptest.NewRequest(
|
||||
http.MethodPost, "/api/actions/upsert-verification-icon", strings.NewReader(
|
||||
`{"reason":"new icon","confirm":true,"document_id":900,"name":"shared"}`)), "operator"))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("shared icon status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
// A fresh target: owner_bot_id is omitted when zero, so decoding into the
|
||||
// previous value would silently keep the reserved owner.
|
||||
var shared admin.UpsertVerificationIconRequest
|
||||
if err := json.Unmarshal(upstream.raw, &shared); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if shared.OwnerBotID != 0 || shared.Name != "shared" {
|
||||
t.Fatalf("forwarded=%+v, want a shared entry", shared)
|
||||
}
|
||||
|
||||
rec = httptest.NewRecorder()
|
||||
srv.handleSetVerificationIconActiveAPI(rec, requestWithActor(httptest.NewRequest(
|
||||
http.MethodPost, "/api/actions/set-verification-icon-active", strings.NewReader(
|
||||
`{"reason":"retired","confirm":true,"icon_id":"501","active":false}`)), "operator"))
|
||||
if rec.Code != http.StatusOK || upstream.path != "/v1/botverification/icons/set-active" {
|
||||
t.Fatalf("set-icon-active status=%d path=%q", rec.Code, upstream.path)
|
||||
}
|
||||
var iconActive admin.SetVerificationIconActiveRequest
|
||||
if err := json.Unmarshal(upstream.raw, &iconActive); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if iconActive.IconID != 501 || iconActive.Active {
|
||||
t.Fatalf("forwarded=%+v", iconActive)
|
||||
}
|
||||
|
||||
rec = httptest.NewRecorder()
|
||||
srv.handleRevokeCustomVerificationAPI(rec, requestWithActor(httptest.NewRequest(
|
||||
http.MethodPost, "/api/actions/revoke-custom-verification", strings.NewReader(
|
||||
`{"reason":"impersonation","confirm":true,"verifier_bot_id":"3003","peer_type":"channel","peer_id":"9223372036854775807"}`)), "operator"))
|
||||
if rec.Code != http.StatusOK || upstream.path != "/v1/botverification/marks/revoke" {
|
||||
t.Fatalf("revoke-mark status=%d path=%q body=%s", rec.Code, upstream.path, rec.Body.String())
|
||||
}
|
||||
var mark admin.RevokeCustomVerificationRequest
|
||||
if err := json.Unmarshal(upstream.raw, &mark); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if mark.VerifierBotID != 3003 || mark.PeerType != "channel" || mark.PeerID != 9223372036854775807 {
|
||||
t.Fatalf("forwarded=%+v", mark)
|
||||
}
|
||||
|
||||
for _, payload := range []string{
|
||||
`{"reason":"x","confirm":true,"verifier_bot_id":0,"peer_type":"channel","peer_id":5}`,
|
||||
`{"reason":"x","confirm":true,"verifier_bot_id":3003,"peer_type":"chat","peer_id":5}`,
|
||||
`{"reason":"x","confirm":true,"verifier_bot_id":3003,"peer_type":"","peer_id":5}`,
|
||||
`{"reason":"x","confirm":true,"verifier_bot_id":3003,"peer_type":"channel","peer_id":0}`,
|
||||
} {
|
||||
rec := httptest.NewRecorder()
|
||||
srv.handleRevokeCustomVerificationAPI(rec, requestWithActor(httptest.NewRequest(
|
||||
http.MethodPost, "/api/actions/revoke-custom-verification", strings.NewReader(payload)), "operator"))
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("payload %s status=%d body=%s, want 400", payload, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A body may not smuggle in an actor: the signed-in operator is the audit identity,
|
||||
// and the strict decoder is what enforces it.
|
||||
func TestBotVerificationRequestsRejectUnknownFields(t *testing.T) {
|
||||
srv := &server{cfg: uiConfig{AdminAPIURL: "http://127.0.0.1:1", AdminAPIToken: "api-secret"}}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/botverification/requests/88/approve", strings.NewReader(
|
||||
`{"reason":"ok","confirm":true,"version":3,"actor":"attacker"}`))
|
||||
req.SetPathValue("id", "88")
|
||||
rec := httptest.NewRecorder()
|
||||
srv.handleApproveBotVerificationAPI(rec, requestWithActor(req, "operator"))
|
||||
if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "actor") {
|
||||
t.Fatalf("status=%d body=%s, want 400 rejecting the injected actor", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// enabled is not part of the grant form: the kill switch is its own action, and
|
||||
// a silently ignored field would hide that from the operator.
|
||||
rec = httptest.NewRecorder()
|
||||
srv.handleGrantBotVerifierAPI(rec, requestWithActor(httptest.NewRequest(
|
||||
http.MethodPost, "/api/actions/grant-bot-verifier", strings.NewReader(
|
||||
`{"reason":"x","confirm":true,"bot_id":3003,"icon_document_id":900,"company_name":"y","enabled":true}`)), "operator"))
|
||||
if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "enabled") {
|
||||
t.Fatalf("status=%d body=%s, want 400 naming the unknown field", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotVerificationPathIDIsValidated(t *testing.T) {
|
||||
srv := &server{cfg: uiConfig{AdminAPIURL: "http://127.0.0.1:1", AdminAPIToken: "api-secret"}}
|
||||
for _, id := range []string{"", "0", "-1", "abc"} {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/botverification/requests/x/approve", strings.NewReader(
|
||||
`{"reason":"ok","confirm":true,"version":3}`))
|
||||
req.SetPathValue("id", id)
|
||||
rec := httptest.NewRecorder()
|
||||
srv.handleApproveBotVerificationAPI(rec, requestWithActor(req, "operator"))
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("id=%q status=%d, want 400", id, rec.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A flattened 502 would hide the one failure the panel resolves by reloading.
|
||||
func TestBotVerificationConflictReachesThePanelAs409(t *testing.T) {
|
||||
upstream := &verificationUpstream{
|
||||
status: http.StatusConflict,
|
||||
body: admin.CommandResult{
|
||||
CommandID: "c1", Status: "failed",
|
||||
Error: admin.CodeCustomVerificationConflict + ": custom verification changed concurrently",
|
||||
Message: "another operator changed this row first; reload it and try again",
|
||||
},
|
||||
}
|
||||
api := httptest.NewServer(upstream.handler(t))
|
||||
defer api.Close()
|
||||
|
||||
srv := &server{cfg: uiConfig{AdminAPIURL: api.URL, AdminAPIToken: "api-secret"}}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/botverification/requests/88/approve", strings.NewReader(
|
||||
`{"reason":"ok","confirm":true,"version":3}`))
|
||||
req.SetPathValue("id", "88")
|
||||
rec := httptest.NewRecorder()
|
||||
srv.handleApproveBotVerificationAPI(rec, requestWithActor(req, "operator"))
|
||||
if rec.Code != http.StatusConflict {
|
||||
t.Fatalf("status=%d body=%s, want 409", rec.Code, rec.Body.String())
|
||||
}
|
||||
var result admin.CommandResult
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &result); err != nil {
|
||||
t.Fatalf("decode conflict: %v", err)
|
||||
}
|
||||
if !strings.Contains(result.Error, admin.CodeCustomVerificationConflict) ||
|
||||
!strings.Contains(result.Message, "reload") {
|
||||
t.Fatalf("result=%+v", result)
|
||||
}
|
||||
|
||||
// The manage half too: two operators can race one verifier row.
|
||||
rec = httptest.NewRecorder()
|
||||
srv.handleGrantBotVerifierAPI(rec, requestWithActor(httptest.NewRequest(
|
||||
http.MethodPost, "/api/actions/grant-bot-verifier", strings.NewReader(
|
||||
`{"reason":"x","confirm":true,"bot_id":3003,"icon_document_id":900,"company_name":"y","version":3}`)), "operator"))
|
||||
if rec.Code != http.StatusConflict {
|
||||
t.Fatalf("grant status=%d body=%s, want 409", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// A 404 from upstream is preserved as well, so a decision on a row that is gone
|
||||
// is not reported as an upstream outage.
|
||||
upstream.status = http.StatusNotFound
|
||||
upstream.body = admin.CommandResult{CommandID: "c2", Status: "failed",
|
||||
Error: admin.CodeCustomVerificationRequestNotFound + ": custom verification request not found"}
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/botverification/requests/88/reject", strings.NewReader(
|
||||
`{"reason":"ok","confirm":true,"version":3}`))
|
||||
req.SetPathValue("id", "88")
|
||||
rec = httptest.NewRecorder()
|
||||
srv.handleRejectBotVerificationAPI(rec, requestWithActor(req, "operator"))
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("status=%d body=%s, want 404", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// An unreachable admin API is the one case with no upstream status at all.
|
||||
func TestBotVerificationUnreachableUpstreamIs502(t *testing.T) {
|
||||
srv := &server{cfg: uiConfig{AdminAPIURL: "http://127.0.0.1:1", AdminAPIToken: "api-secret"}}
|
||||
rec := httptest.NewRecorder()
|
||||
srv.handleRevokeBotVerifierAPI(rec, requestWithActor(httptest.NewRequest(
|
||||
http.MethodPost, "/api/actions/revoke-bot-verifier", strings.NewReader(
|
||||
`{"reason":"x","confirm":true,"bot_id":3003}`)), "operator"))
|
||||
if rec.Code != http.StatusBadGateway {
|
||||
t.Fatalf("status=%d body=%s, want 502", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotVerificationReadFiltersAreValidatedBeforeTheStore(t *testing.T) {
|
||||
// No read store: a malformed query still has to be a 400, so the panel is told
|
||||
// what it got wrong whether or not the database is reachable.
|
||||
srv := &server{}
|
||||
cases := []struct {
|
||||
handler http.HandlerFunc
|
||||
path string
|
||||
}{
|
||||
{srv.handleCustomVerificationsAPI, "/api/botverification/marks?peer_type=chat"},
|
||||
{srv.handleCustomVerificationsAPI, "/api/botverification/marks?verifier_bot_id=abc"},
|
||||
{srv.handleCustomVerificationsAPI, "/api/botverification/marks?before_id=-1"},
|
||||
{srv.handleCustomVerificationsAPI, "/api/botverification/marks?limit=abc"},
|
||||
{srv.handleCustomVerificationRequestsAPI, "/api/botverification/requests?status=in_review"},
|
||||
{srv.handleCustomVerificationRequestsAPI, "/api/botverification/requests?peer_type=chat"},
|
||||
{srv.handleCustomVerificationRequestsAPI, "/api/botverification/requests?limit=-1"},
|
||||
{srv.handleBotVerifiersAPI, "/api/botverification/verifiers?limit=abc"},
|
||||
{srv.handleVerificationIconsAPI, "/api/botverification/icons?limit=-2"},
|
||||
}
|
||||
for _, item := range cases {
|
||||
rec := httptest.NewRecorder()
|
||||
item.handler(rec, httptest.NewRequest(http.MethodGet, item.path, nil))
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("%s status=%d body=%s, want 400", item.path, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
// A well-formed query with no store wired reports the store, not the query.
|
||||
rec := httptest.NewRecorder()
|
||||
srv.handleCustomVerificationRequestsAPI(rec, httptest.NewRequest(
|
||||
http.MethodGet, "/api/botverification/requests?status=pending&peer_type=channel&limit=10", nil))
|
||||
if rec.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("status=%d body=%s, want 503", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryFlagReadsThePanelsBooleans(t *testing.T) {
|
||||
for _, raw := range []string{"1", "true", "TRUE", " yes ", "on"} {
|
||||
if !queryFlag(raw) {
|
||||
t.Fatalf("queryFlag(%q) = false", raw)
|
||||
}
|
||||
}
|
||||
for _, raw := range []string{"", "0", "false", "no", "maybe"} {
|
||||
if queryFlag(raw) {
|
||||
t.Fatalf("queryFlag(%q) = true", raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -71,6 +71,11 @@ type uiConfig struct {
|
|||
Password string
|
||||
Token string
|
||||
SessionKey []byte
|
||||
// Permissions is the right set a panel session is issued with, from
|
||||
// TELESRV_ADMIN_UI_PERMISSIONS. The shipped default is the single wildcard
|
||||
// entry, so introducing the permission model never locks an operator out of a
|
||||
// panel that worked before.
|
||||
Permissions []string
|
||||
}
|
||||
|
||||
// loadConfig 通过 internal/config.Load() 加载 .env 配置文件与环境变量,
|
||||
|
|
@ -105,6 +110,7 @@ func loadConfig() (uiConfig, error) {
|
|||
Password: appCfg.AdminUIPassword,
|
||||
Token: appCfg.AdminUIToken,
|
||||
SessionKey: sum[:],
|
||||
Permissions: appCfg.AdminUIPermissions,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
146
cmd/telesrv-admin/readstore_accounts_integration_test.go
Normal file
146
cmd/telesrv-admin/readstore_accounts_integration_test.go
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The Accounts tab is hand-written SQL, so the collectible-username aggregation
|
||||
// can only be proven against the real schema: the jsonb keys have to match the
|
||||
// AccountUsername field names for pgx to unmarshal them, the ordering has to match
|
||||
// the projection order clients see, and the editable slot must not leak into the
|
||||
// collectible list. Gated on TELESRV_TEST_POSTGRES_DSN like the rest.
|
||||
func TestReadStoreAccountsCarryCollectibleUsernames(t *testing.T) {
|
||||
store, pool := verificationReadStore(t)
|
||||
ctx := context.Background()
|
||||
suffix := fmt.Sprintf("%d", time.Now().UnixNano()%1_000_000)
|
||||
userID := 3_600_000_000 + time.Now().UnixNano()%1_000_000
|
||||
|
||||
editable := "slot" + suffix
|
||||
// Deliberately out of alphabetical order and with a gap in sort_order, so a
|
||||
// query that sorted by name or by insertion order would produce a different
|
||||
// answer than the stored one.
|
||||
collectibles := []struct {
|
||||
name string
|
||||
sortOrder int
|
||||
active bool
|
||||
collecting bool
|
||||
}{
|
||||
{name: "zeta" + suffix, sortOrder: 0, active: true, collecting: true},
|
||||
{name: "alpha" + suffix, sortOrder: 5, active: false, collecting: true},
|
||||
{name: "mid" + suffix, sortOrder: 2, active: true, collecting: true},
|
||||
}
|
||||
|
||||
t.Cleanup(func() {
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM peer_usernames WHERE peer_type='user' AND peer_id=$1`, userID)
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM collectible_usernames WHERE username_lower LIKE $1`, "%"+suffix)
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM authorizations WHERE user_id=$1`, userID)
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM auth_keys WHERE auth_key_id=$1`, userID)
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM users WHERE id=$1`, userID)
|
||||
})
|
||||
|
||||
if _, err := pool.Exec(ctx, `
|
||||
INSERT INTO users (id, access_hash, phone, first_name, last_name, username, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, 'Collector', '', $4, now(), now())`,
|
||||
userID, userID, "+1889"+suffix, editable); err != nil {
|
||||
t.Fatalf("seed user: %v", err)
|
||||
}
|
||||
// The list query joins authorizations, so an account with no device never
|
||||
// appears there at all; an authorization in turn needs its auth key to exist.
|
||||
if _, err := pool.Exec(ctx, `
|
||||
INSERT INTO auth_keys (auth_key_id, body, server_salt) VALUES ($1, '\x00', 0)`, userID); err != nil {
|
||||
t.Fatalf("seed auth key: %v", err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `
|
||||
INSERT INTO authorizations (user_id, auth_key_id, created_at, active_at)
|
||||
VALUES ($1, $2, now(), now())`, userID, userID); err != nil {
|
||||
t.Fatalf("seed authorization: %v", err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `
|
||||
INSERT INTO peer_usernames (username_lower, username, peer_type, peer_id, active, editable, sort_order)
|
||||
VALUES (lower($1), lower($1), 'user', $2, true, true, 0)`, editable, userID); err != nil {
|
||||
t.Fatalf("seed editable slot: %v", err)
|
||||
}
|
||||
for _, item := range collectibles {
|
||||
var collectibleID int64
|
||||
if err := pool.QueryRow(ctx, `
|
||||
INSERT INTO collectible_usernames (username, username_lower, status, owner_peer_type, owner_peer_id,
|
||||
original_owner_peer_type, original_owner_peer_id, purchase_date, currency, amount, created_at, updated_at)
|
||||
VALUES ($1, lower($1), 'owned', 'user', $2, 'user', $2, now(), 'XTR', 0, now(), now())
|
||||
RETURNING id`, item.name, userID).Scan(&collectibleID); err != nil {
|
||||
t.Fatalf("seed collectible %s: %v", item.name, err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `
|
||||
INSERT INTO peer_usernames (username_lower, username, peer_type, peer_id, active, editable, sort_order, collectible_id)
|
||||
VALUES (lower($1), lower($1), 'user', $2, $3, false, $4, $5)`,
|
||||
item.name, userID, item.active, item.sortOrder, collectibleID); err != nil {
|
||||
t.Fatalf("attach collectible %s: %v", item.name, err)
|
||||
}
|
||||
}
|
||||
|
||||
want := []AccountUsername{
|
||||
{Username: "zeta" + suffix, Active: true},
|
||||
{Username: "mid" + suffix, Active: true},
|
||||
{Username: "alpha" + suffix, Active: false},
|
||||
}
|
||||
|
||||
detail, err := store.AccountDetail(ctx, userID)
|
||||
if err != nil {
|
||||
t.Fatalf("AccountDetail: %v", err)
|
||||
}
|
||||
assertCollectibles(t, "AccountDetail", detail.Account, editable, want)
|
||||
|
||||
rows, _, err := store.ListAccounts(ctx, 0, 0, 200)
|
||||
if err != nil {
|
||||
t.Fatalf("ListAccounts: %v", err)
|
||||
}
|
||||
var listed *AccountRow
|
||||
for i := range rows {
|
||||
if rows[i].ID == userID {
|
||||
listed = &rows[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if listed == nil {
|
||||
t.Fatalf("seeded account %d is absent from the first page of %d accounts", userID, len(rows))
|
||||
}
|
||||
assertCollectibles(t, "ListAccounts", *listed, editable, want)
|
||||
|
||||
// An account holding nothing collectible reports an empty list, not null: the
|
||||
// panel iterates it unconditionally.
|
||||
if _, err := pool.Exec(ctx, `DELETE FROM peer_usernames
|
||||
WHERE peer_type='user' AND peer_id=$1 AND collectible_id IS NOT NULL`, userID); err != nil {
|
||||
t.Fatalf("drop collectibles: %v", err)
|
||||
}
|
||||
bare, err := store.AccountDetail(ctx, userID)
|
||||
if err != nil {
|
||||
t.Fatalf("AccountDetail without collectibles: %v", err)
|
||||
}
|
||||
if bare.Account.Collectibles == nil || len(bare.Account.Collectibles) != 0 {
|
||||
t.Fatalf("collectibles without any rows = %#v, want an empty slice", bare.Account.Collectibles)
|
||||
}
|
||||
}
|
||||
|
||||
func assertCollectibles(t *testing.T, surface string, row AccountRow, editable string, want []AccountUsername) {
|
||||
t.Helper()
|
||||
if row.Username != editable {
|
||||
t.Fatalf("%s: editable username = %q, want %q", surface, row.Username, editable)
|
||||
}
|
||||
if len(row.Collectibles) != len(want) {
|
||||
t.Fatalf("%s: collectibles = %#v, want %#v", surface, row.Collectibles, want)
|
||||
}
|
||||
for i := range want {
|
||||
if row.Collectibles[i] != want[i] {
|
||||
t.Fatalf("%s: collectibles = %#v, want %#v", surface, row.Collectibles, want)
|
||||
}
|
||||
}
|
||||
// The editable slot is a different kind of row and must never be repeated in
|
||||
// the collectible list.
|
||||
for _, item := range row.Collectibles {
|
||||
if item.Username == editable {
|
||||
t.Fatalf("%s: editable slot leaked into the collectible list: %#v", surface, row.Collectibles)
|
||||
}
|
||||
}
|
||||
}
|
||||
571
cmd/telesrv-admin/readstore_botverification_integration_test.go
Normal file
571
cmd/telesrv-admin/readstore_botverification_integration_test.go
Normal file
|
|
@ -0,0 +1,571 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// The third-party verification tables are read with hand-written SQL, so the only
|
||||
// thing that can prove the column names, the CASE-per-peer-namespace projections
|
||||
// and the `AND editable` username joins are right is running them against the real
|
||||
// schema. Gated on TELESRV_TEST_POSTGRES_DSN, like every other integration test in
|
||||
// the repo, and reusing verificationReadStore for the pool and the migration.
|
||||
|
||||
// botVerificationFixture seeds two verifier bots (one enabled, one switched off),
|
||||
// a shared and a reserved icon, marks on a user peer and a channel peer, and
|
||||
// applications in three states.
|
||||
type botVerificationFixture struct {
|
||||
verifierBot int64
|
||||
disabledBot int64
|
||||
applicant int64
|
||||
userPeer int64
|
||||
channel int64
|
||||
sharedIcon int64
|
||||
reservedIcon int64
|
||||
sharedDoc int64
|
||||
reservedDoc int64
|
||||
userMark int64
|
||||
channelMark int64
|
||||
pendingReq int64
|
||||
approvedReq int64
|
||||
rejectedReq int64
|
||||
suffix string
|
||||
}
|
||||
|
||||
func seedBotVerificationFixture(t *testing.T, pool *pgxpool.Pool) botVerificationFixture {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
var fx botVerificationFixture
|
||||
now := time.Now().UTC().Truncate(time.Microsecond)
|
||||
// Usernames, channel ids and icon document ids are globally unique, so every run
|
||||
// needs its own suffix: this database may still hold rows another run left.
|
||||
unique := now.UnixNano() & 0x7fffffff
|
||||
suffix := strconv.FormatInt(unique, 10)
|
||||
fx.suffix = suffix
|
||||
nextChannelID := 1_200_000_000 + unique%100_000_000
|
||||
fx.sharedDoc = 7_000_000_000 + unique%1_000_000
|
||||
fx.reservedDoc = fx.sharedDoc + 1
|
||||
|
||||
insertUser := func(first, username string, isBot bool) int64 {
|
||||
var id int64
|
||||
if err := pool.QueryRow(ctx, `
|
||||
INSERT INTO users (access_hash, phone, first_name, last_name, username, is_bot)
|
||||
VALUES ($1, $2, $3, 'Fixture', $4, $5)
|
||||
RETURNING id`, unique, "71"+strconv.FormatInt(unique, 10), first, username, isBot).Scan(&id); err != nil {
|
||||
t.Fatalf("insert user %s: %v", first, err)
|
||||
}
|
||||
unique++
|
||||
return id
|
||||
}
|
||||
|
||||
fx.verifierBot = insertUser("Verifierbot", "verifierbot"+suffix, true)
|
||||
fx.disabledBot = insertUser("Disabledbot", "disabledbot"+suffix, true)
|
||||
fx.applicant = insertUser("Applicant", "bvapplicant"+suffix, false)
|
||||
fx.userPeer = insertUser("Marked", "markeduser"+suffix, false)
|
||||
|
||||
fx.channel = nextChannelID
|
||||
if _, err := pool.Exec(ctx, `
|
||||
INSERT INTO channels (
|
||||
id, access_hash, creator_user_id, title, username, broadcast, megagroup,
|
||||
participants_count, admins_count, top_message_id, pts, date
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, true, false, 1, 1, 1, 1, $6)`,
|
||||
fx.channel, unique, fx.applicant, "Fixture Marked News", "markednews"+suffix, int32(now.Unix())); err != nil {
|
||||
t.Fatalf("insert channel: %v", err)
|
||||
}
|
||||
unique++
|
||||
|
||||
insertIcon := func(documentID, ownerBotID int64, name string, active bool) int64 {
|
||||
var id int64
|
||||
if err := pool.QueryRow(ctx, `
|
||||
INSERT INTO verification_icons (document_id, owner_bot_id, name, active, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $5) RETURNING id`,
|
||||
documentID, ownerBotID, name, active, now).Scan(&id); err != nil {
|
||||
t.Fatalf("insert icon %s: %v", name, err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
fx.sharedIcon = insertIcon(fx.sharedDoc, 0, "shared check "+suffix, true)
|
||||
// Reserved to the verifier bot and retired, so both the owner join and the
|
||||
// active filter have a case to answer.
|
||||
fx.reservedIcon = insertIcon(fx.reservedDoc, fx.verifierBot, "reserved check "+suffix, false)
|
||||
|
||||
insertVerifier := func(botID, documentID int64, company string, enabled, canModify bool) {
|
||||
if _, err := pool.Exec(ctx, `
|
||||
INSERT INTO bot_verifier_settings (
|
||||
bot_id, icon_document_id, company_name, default_description,
|
||||
can_modify_custom_description, enabled, granted_by, grant_reason,
|
||||
created_at, updated_at, version
|
||||
) VALUES ($1, $2, $3, 'verified by the fixture', $4, $5, 'alice', 'partner programme', $6, $6, 4)`,
|
||||
botID, documentID, company, canModify, enabled, now); err != nil {
|
||||
t.Fatalf("insert verifier %d: %v", botID, err)
|
||||
}
|
||||
}
|
||||
insertVerifier(fx.verifierBot, fx.sharedDoc, "Fixture Trust "+suffix, true, true)
|
||||
insertVerifier(fx.disabledBot, fx.sharedDoc, "Switched Off "+suffix, false, false)
|
||||
|
||||
insertMark := func(peerType string, peerID int64, description string) int64 {
|
||||
var id int64
|
||||
if err := pool.QueryRow(ctx, `
|
||||
INSERT INTO custom_verifications (
|
||||
verifier_bot_id, peer_type, peer_id, icon_document_id, description,
|
||||
granted_by_user_id, created_at, updated_at, version
|
||||
) VALUES ($1, $2, $3, $4, $5, $1, $6, $6, 2) RETURNING id`,
|
||||
fx.verifierBot, peerType, peerID, fx.sharedDoc, description, now).Scan(&id); err != nil {
|
||||
t.Fatalf("insert %s mark: %v", peerType, err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
fx.userMark = insertMark("user", fx.userPeer, "verified individual")
|
||||
fx.channelMark = insertMark("channel", fx.channel, "verified outlet")
|
||||
|
||||
insertRequest := func(peerType string, peerID int64, status, reason string) int64 {
|
||||
var approvedAt, rejectedAt *time.Time
|
||||
decisionReason := ""
|
||||
decidedBy := ""
|
||||
switch status {
|
||||
case "approved":
|
||||
approvedAt = &now
|
||||
decidedBy = "alice"
|
||||
case "rejected":
|
||||
rejectedAt = &now
|
||||
decidedBy = "bob"
|
||||
decisionReason = reason
|
||||
}
|
||||
var id int64
|
||||
if err := pool.QueryRow(ctx, `
|
||||
INSERT INTO custom_verification_requests (
|
||||
verifier_bot_id, applicant_user_id, peer_type, peer_id, peer_title, peer_username,
|
||||
reason, requested_description, status, decided_by, decision_reason, internal_note,
|
||||
correlation_id, created_at, updated_at, approved_at, rejected_at, version
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6, 'we are the outlet', 'verified partner', $7, $8, $9,
|
||||
'operator only', $10, $11, $11, $12, $13, 3
|
||||
) RETURNING id`,
|
||||
fx.verifierBot, fx.applicant, peerType, peerID,
|
||||
"Snapshot "+peerType, "snapshot"+peerType+suffix,
|
||||
status, decidedBy, decisionReason, "bvcorr-"+status,
|
||||
now, approvedAt, rejectedAt,
|
||||
).Scan(&id); err != nil {
|
||||
t.Fatalf("insert %s request: %v", status, err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
// One live application per (verifier, peer) pair, so the three seeded rows have
|
||||
// to name three different peers: the partial unique index enforces it.
|
||||
fx.pendingReq = insertRequest("channel", fx.channel, "pending", "")
|
||||
fx.approvedReq = insertRequest("user", fx.userPeer, "approved", "")
|
||||
// Filed against a peer that does not exist, so the live-peer join has a negative
|
||||
// case and the snapshot fallback is exercised.
|
||||
fx.rejectedReq = insertRequest("user", fx.userPeer+9_000_000, "rejected", "not an outlet")
|
||||
|
||||
t.Cleanup(func() {
|
||||
reqIDs := []int64{fx.pendingReq, fx.approvedReq, fx.rejectedReq}
|
||||
_, _ = pool.Exec(ctx, "DELETE FROM custom_verification_requests WHERE id = ANY($1::bigint[])", reqIDs)
|
||||
_, _ = pool.Exec(ctx, "DELETE FROM custom_verifications WHERE id = ANY($1::bigint[])",
|
||||
[]int64{fx.userMark, fx.channelMark})
|
||||
_, _ = pool.Exec(ctx, "DELETE FROM bot_verifier_settings WHERE bot_id = ANY($1::bigint[])",
|
||||
[]int64{fx.verifierBot, fx.disabledBot})
|
||||
_, _ = pool.Exec(ctx, "DELETE FROM verification_icons WHERE id = ANY($1::bigint[])",
|
||||
[]int64{fx.sharedIcon, fx.reservedIcon})
|
||||
_, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", fx.channel)
|
||||
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])",
|
||||
[]int64{fx.verifierBot, fx.disabledBot, fx.applicant, fx.userPeer})
|
||||
})
|
||||
return fx
|
||||
}
|
||||
|
||||
func TestBotVerificationReadStoreVerifiers(t *testing.T) {
|
||||
store, pool := verificationReadStore(t)
|
||||
fx := seedBotVerificationFixture(t, pool)
|
||||
ctx := context.Background()
|
||||
|
||||
rows, err := store.ListBotVerifiers(ctx, false, 200)
|
||||
if err != nil {
|
||||
t.Fatalf("list verifiers: %v", err)
|
||||
}
|
||||
byID := map[int64]BotVerifierRow{}
|
||||
for _, row := range rows {
|
||||
byID[row.BotID] = row
|
||||
}
|
||||
verifier, ok := byID[fx.verifierBot]
|
||||
if !ok {
|
||||
t.Fatal("enabled verifier missing from the listing")
|
||||
}
|
||||
// The bot account is resolved through the join, and the icon's catalogue label
|
||||
// comes from the entry the document id points at.
|
||||
if verifier.BotUsername != "verifierbot"+fx.suffix || verifier.IconName != "shared check "+fx.suffix {
|
||||
t.Fatalf("verifier projection = %+v", verifier)
|
||||
}
|
||||
if verifier.BotName == "" || verifier.CompanyName != "Fixture Trust "+fx.suffix {
|
||||
t.Fatalf("verifier names = %+v", verifier)
|
||||
}
|
||||
if !verifier.Enabled || !verifier.CanModifyCustomDescription || verifier.Version != 4 ||
|
||||
verifier.GrantedBy != "alice" || verifier.GrantReason != "partner programme" {
|
||||
t.Fatalf("verifier settings = %+v", verifier)
|
||||
}
|
||||
// Both seeded marks belong to this verifier, and mark_count is what would
|
||||
// cascade away with a revocation.
|
||||
if verifier.MarkCount != 2 {
|
||||
t.Fatalf("mark count = %d, want 2", verifier.MarkCount)
|
||||
}
|
||||
if disabled := byID[fx.disabledBot]; disabled.Enabled || disabled.MarkCount != 0 {
|
||||
t.Fatalf("disabled verifier = %+v", disabled)
|
||||
}
|
||||
|
||||
// enabled_only hides the switched-off verifier without dropping its row.
|
||||
enabled, err := store.ListBotVerifiers(ctx, true, 200)
|
||||
if err != nil {
|
||||
t.Fatalf("list enabled verifiers: %v", err)
|
||||
}
|
||||
for _, row := range enabled {
|
||||
if !row.Enabled {
|
||||
t.Fatalf("enabled_only leaked %+v", row)
|
||||
}
|
||||
if row.BotID == fx.disabledBot {
|
||||
t.Fatal("enabled_only returned the switched-off verifier")
|
||||
}
|
||||
}
|
||||
|
||||
// The detail read reuses the list scanner, so one column order serves both.
|
||||
one, err := store.BotVerifier(ctx, fx.verifierBot)
|
||||
if err != nil {
|
||||
t.Fatalf("get verifier: %v", err)
|
||||
}
|
||||
if one.BotID != fx.verifierBot || one.MarkCount != 2 || one.IconName != verifier.IconName {
|
||||
t.Fatalf("verifier detail = %+v", one)
|
||||
}
|
||||
if _, err := store.BotVerifier(ctx, fx.applicant); err == nil {
|
||||
t.Fatal("a non-verifier resolved as one")
|
||||
}
|
||||
|
||||
// The page bound is honoured.
|
||||
page, err := store.ListBotVerifiers(ctx, false, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("bounded list: %v", err)
|
||||
}
|
||||
if len(page) != 1 {
|
||||
t.Fatalf("bounded page len=%d", len(page))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotVerificationReadStoreIcons(t *testing.T) {
|
||||
store, pool := verificationReadStore(t)
|
||||
fx := seedBotVerificationFixture(t, pool)
|
||||
ctx := context.Background()
|
||||
|
||||
rows, err := store.ListVerificationIcons(ctx, false, 200)
|
||||
if err != nil {
|
||||
t.Fatalf("list icons: %v", err)
|
||||
}
|
||||
byID := map[int64]VerificationIconRow{}
|
||||
for _, row := range rows {
|
||||
byID[row.ID] = row
|
||||
}
|
||||
shared, ok := byID[fx.sharedIcon]
|
||||
if !ok {
|
||||
t.Fatal("shared icon missing from the catalogue listing")
|
||||
}
|
||||
if shared.OwnerBotID != 0 || shared.OwnerBotUsername != "" || !shared.Active {
|
||||
t.Fatalf("shared icon = %+v, want no owner", shared)
|
||||
}
|
||||
// Both seeded verifiers point at the shared document, so retiring it is a
|
||||
// decision the operator has to make knowingly.
|
||||
if shared.UsedByVerifiers != 2 {
|
||||
t.Fatalf("shared icon used_by_verifiers = %d, want 2", shared.UsedByVerifiers)
|
||||
}
|
||||
reserved, ok := byID[fx.reservedIcon]
|
||||
if !ok {
|
||||
t.Fatal("reserved icon missing from the catalogue listing")
|
||||
}
|
||||
if reserved.OwnerBotID != fx.verifierBot || reserved.OwnerBotUsername != "verifierbot"+fx.suffix {
|
||||
t.Fatalf("reserved icon = %+v, want the owner resolved", reserved)
|
||||
}
|
||||
if reserved.Active || reserved.UsedByVerifiers != 0 {
|
||||
t.Fatalf("reserved icon = %+v", reserved)
|
||||
}
|
||||
|
||||
// active_only hides the retired entry.
|
||||
active, err := store.ListVerificationIcons(ctx, true, 200)
|
||||
if err != nil {
|
||||
t.Fatalf("list active icons: %v", err)
|
||||
}
|
||||
for _, row := range active {
|
||||
if !row.Active {
|
||||
t.Fatalf("active_only leaked %+v", row)
|
||||
}
|
||||
if row.ID == fx.reservedIcon {
|
||||
t.Fatal("active_only returned the retired entry")
|
||||
}
|
||||
}
|
||||
// Newest first.
|
||||
if len(rows) >= 2 && rows[0].ID < rows[1].ID {
|
||||
t.Fatalf("catalogue is not ordered newest first: %d before %d", rows[0].ID, rows[1].ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotVerificationReadStoreMarks(t *testing.T) {
|
||||
store, pool := verificationReadStore(t)
|
||||
fx := seedBotVerificationFixture(t, pool)
|
||||
ctx := context.Background()
|
||||
|
||||
rows, _, err := store.ListCustomVerifications(ctx, fx.verifierBot, "", "", 0, 200)
|
||||
if err != nil {
|
||||
t.Fatalf("list marks: %v", err)
|
||||
}
|
||||
byID := map[int64]CustomVerificationRow{}
|
||||
for _, row := range rows {
|
||||
byID[row.ID] = row
|
||||
}
|
||||
// A user peer resolves through users; the verifier's company comes from its
|
||||
// settings row.
|
||||
userMark, ok := byID[fx.userMark]
|
||||
if !ok {
|
||||
t.Fatal("user mark missing from the listing")
|
||||
}
|
||||
if userMark.PeerType != "user" || userMark.PeerID != fx.userPeer ||
|
||||
userMark.PeerUsername != "markeduser"+fx.suffix {
|
||||
t.Fatalf("user mark peer = %+v", userMark)
|
||||
}
|
||||
if userMark.PeerTitle == "" || userMark.VerifierBotUsername != "verifierbot"+fx.suffix ||
|
||||
userMark.CompanyName != "Fixture Trust "+fx.suffix {
|
||||
t.Fatalf("user mark projection = %+v", userMark)
|
||||
}
|
||||
if userMark.IconDocumentID != fx.sharedDoc || userMark.Description != "verified individual" ||
|
||||
userMark.Version != 2 {
|
||||
t.Fatalf("user mark = %+v", userMark)
|
||||
}
|
||||
// A channel peer resolves through channels: the CASE picks the right namespace.
|
||||
channelMark, ok := byID[fx.channelMark]
|
||||
if !ok {
|
||||
t.Fatal("channel mark missing from the listing")
|
||||
}
|
||||
if channelMark.PeerType != "channel" || channelMark.PeerTitle != "Fixture Marked News" ||
|
||||
channelMark.PeerUsername != "markednews"+fx.suffix {
|
||||
t.Fatalf("channel mark peer = %+v", channelMark)
|
||||
}
|
||||
|
||||
// Filters.
|
||||
typed, _, err := store.ListCustomVerifications(ctx, fx.verifierBot, "channel", "", 0, 50)
|
||||
if err != nil {
|
||||
t.Fatalf("peer_type list: %v", err)
|
||||
}
|
||||
for _, row := range typed {
|
||||
if row.PeerType != "channel" {
|
||||
t.Fatalf("peer_type filter leaked %+v", row)
|
||||
}
|
||||
}
|
||||
other, _, err := store.ListCustomVerifications(ctx, fx.disabledBot, "", "", 0, 50)
|
||||
if err != nil {
|
||||
t.Fatalf("verifier filter list: %v", err)
|
||||
}
|
||||
for _, row := range other {
|
||||
if row.VerifierBotID != fx.disabledBot {
|
||||
t.Fatalf("verifier filter leaked %+v", row)
|
||||
}
|
||||
}
|
||||
|
||||
// q matches a mark id, a peer id, a verifier id and a username or title prefix.
|
||||
for _, query := range []string{
|
||||
strconv.FormatInt(fx.channelMark, 10),
|
||||
strconv.FormatInt(fx.channel, 10),
|
||||
strconv.FormatInt(fx.verifierBot, 10),
|
||||
"markednews" + fx.suffix,
|
||||
"@markeduser" + fx.suffix,
|
||||
"fixture marked",
|
||||
} {
|
||||
found, _, err := store.ListCustomVerifications(ctx, 0, "", query, 0, 50)
|
||||
if err != nil {
|
||||
t.Fatalf("search %q: %v", query, err)
|
||||
}
|
||||
if len(found) == 0 {
|
||||
t.Fatalf("search %q returned nothing", query)
|
||||
}
|
||||
}
|
||||
|
||||
// The keyset cursor excludes the row it points at.
|
||||
after, _, err := store.ListCustomVerifications(ctx, fx.verifierBot, "", "", fx.channelMark, 50)
|
||||
if err != nil {
|
||||
t.Fatalf("keyset list: %v", err)
|
||||
}
|
||||
for _, row := range after {
|
||||
if row.ID >= fx.channelMark {
|
||||
t.Fatalf("keyset page leaked id %d at or after the cursor %d", row.ID, fx.channelMark)
|
||||
}
|
||||
}
|
||||
// The page bound is honoured and reports more.
|
||||
page, more, err := store.ListCustomVerifications(ctx, fx.verifierBot, "", "", 0, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("bounded list: %v", err)
|
||||
}
|
||||
if len(page) != 1 || !more {
|
||||
t.Fatalf("bounded page len=%d hasMore=%v", len(page), more)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotVerificationReadStoreRequestsAndDetail(t *testing.T) {
|
||||
store, pool := verificationReadStore(t)
|
||||
fx := seedBotVerificationFixture(t, pool)
|
||||
ctx := context.Background()
|
||||
|
||||
rows, _, err := store.ListCustomVerificationRequests(ctx, "", fx.verifierBot, "", "", 0, 200)
|
||||
if err != nil {
|
||||
t.Fatalf("list requests: %v", err)
|
||||
}
|
||||
byID := map[int64]CustomVerificationRequestRow{}
|
||||
for _, row := range rows {
|
||||
byID[row.ID] = row
|
||||
}
|
||||
pending, ok := byID[fx.pendingReq]
|
||||
if !ok {
|
||||
t.Fatal("pending application missing from the queue")
|
||||
}
|
||||
if pending.ApplicantUserID != fx.applicant || pending.ApplicantUsername != "bvapplicant"+fx.suffix ||
|
||||
pending.VerifierBotUsername != "verifierbot"+fx.suffix {
|
||||
t.Fatalf("applicant/verifier projection = %+v", pending)
|
||||
}
|
||||
// The peer is read live, not from the snapshot columns: an operator has to see
|
||||
// the peer as it is now.
|
||||
if pending.PeerTitle != "Fixture Marked News" || pending.PeerUsername != "markednews"+fx.suffix {
|
||||
t.Fatalf("live peer projection = %+v, want the channel as it is now", pending)
|
||||
}
|
||||
if pending.Status != "pending" || pending.InternalNote != "operator only" ||
|
||||
pending.CorrelationID != "bvcorr-pending" || pending.Version != 3 {
|
||||
t.Fatalf("operator fields = %+v", pending)
|
||||
}
|
||||
if !pending.ApprovedAt.IsZero() || !pending.RejectedAt.IsZero() {
|
||||
t.Fatalf("timestamps approved=%v rejected=%v, want an undecided application",
|
||||
pending.ApprovedAt, pending.RejectedAt)
|
||||
}
|
||||
if approved := byID[fx.approvedReq]; approved.ApprovedAt.IsZero() || approved.DecidedBy != "alice" {
|
||||
t.Fatalf("approved application = %+v", approved)
|
||||
}
|
||||
if rejected := byID[fx.rejectedReq]; rejected.RejectedAt.IsZero() || rejected.DecisionReason == "" {
|
||||
t.Fatalf("rejected application = %+v", rejected)
|
||||
}
|
||||
// A peer that does not exist falls back to the snapshot the applicant filed
|
||||
// with, so the row still renders as something the reviewer recognises.
|
||||
if gone := byID[fx.rejectedReq]; gone.PeerTitle != "Snapshot user" ||
|
||||
gone.PeerUsername != "snapshotuser"+fx.suffix {
|
||||
t.Fatalf("missing peer = %+v, want the snapshot fallback", gone)
|
||||
}
|
||||
|
||||
// Filters.
|
||||
filtered, _, err := store.ListCustomVerificationRequests(ctx, "pending", 0, "", "", 0, 50)
|
||||
if err != nil {
|
||||
t.Fatalf("status list: %v", err)
|
||||
}
|
||||
for _, row := range filtered {
|
||||
if row.Status != "pending" {
|
||||
t.Fatalf("status filter leaked %+v", row)
|
||||
}
|
||||
}
|
||||
typed, _, err := store.ListCustomVerificationRequests(ctx, "", 0, "channel", "", 0, 50)
|
||||
if err != nil {
|
||||
t.Fatalf("peer_type list: %v", err)
|
||||
}
|
||||
for _, row := range typed {
|
||||
if row.PeerType != "channel" {
|
||||
t.Fatalf("peer_type filter leaked %+v", row)
|
||||
}
|
||||
}
|
||||
// q matches an application id, a peer id, the applicant id, and username or
|
||||
// title prefixes on both the live peer and the snapshot.
|
||||
for _, query := range []string{
|
||||
strconv.FormatInt(fx.pendingReq, 10),
|
||||
strconv.FormatInt(fx.channel, 10),
|
||||
strconv.FormatInt(fx.applicant, 10),
|
||||
"markednews" + fx.suffix,
|
||||
"snapshotchannel" + fx.suffix,
|
||||
"@bvapplicant" + fx.suffix,
|
||||
"snapshot ",
|
||||
} {
|
||||
found, _, err := store.ListCustomVerificationRequests(ctx, "", 0, "", query, 0, 50)
|
||||
if err != nil {
|
||||
t.Fatalf("search %q: %v", query, err)
|
||||
}
|
||||
if len(found) == 0 {
|
||||
t.Fatalf("search %q returned nothing", query)
|
||||
}
|
||||
}
|
||||
|
||||
// The keyset cursor excludes the row it points at, and the bound reports more.
|
||||
after, _, err := store.ListCustomVerificationRequests(ctx, "", fx.verifierBot, "", "", fx.pendingReq, 50)
|
||||
if err != nil {
|
||||
t.Fatalf("keyset list: %v", err)
|
||||
}
|
||||
for _, row := range after {
|
||||
if row.ID >= fx.pendingReq {
|
||||
t.Fatalf("keyset page leaked id %d at or after the cursor %d", row.ID, fx.pendingReq)
|
||||
}
|
||||
}
|
||||
page, more, err := store.ListCustomVerificationRequests(ctx, "", fx.verifierBot, "", "", 0, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("bounded list: %v", err)
|
||||
}
|
||||
if len(page) != 1 || !more {
|
||||
t.Fatalf("bounded page len=%d hasMore=%v", len(page), more)
|
||||
}
|
||||
|
||||
// The detail read carries the verifier and the live mark state.
|
||||
detail, err := store.CustomVerificationRequestDetail(ctx, fx.approvedReq)
|
||||
if err != nil {
|
||||
t.Fatalf("detail: %v", err)
|
||||
}
|
||||
if detail.Request.ID != fx.approvedReq || detail.Verifier.BotID != fx.verifierBot ||
|
||||
detail.Verifier.CompanyName != "Fixture Trust "+fx.suffix {
|
||||
t.Fatalf("detail = %+v verifier=%+v", detail.Request, detail.Verifier)
|
||||
}
|
||||
// The approved application's peer really carries the mark.
|
||||
if !detail.MarkActive {
|
||||
t.Fatal("mark_active did not follow the granted mark")
|
||||
}
|
||||
// The pending application names the channel, which the fixture also marks, so
|
||||
// the channel side of the EXISTS probe is covered too.
|
||||
pendingDetail, err := store.CustomVerificationRequestDetail(ctx, fx.pendingReq)
|
||||
if err != nil {
|
||||
t.Fatalf("pending detail: %v", err)
|
||||
}
|
||||
if !pendingDetail.MarkActive {
|
||||
t.Fatal("the channel mark was not seen by the detail read")
|
||||
}
|
||||
// The rejected one names a peer nobody marked: mark_active must say so, which is
|
||||
// what tells "approved" apart from "approved and since stripped".
|
||||
goneDetail, err := store.CustomVerificationRequestDetail(ctx, fx.rejectedReq)
|
||||
if err != nil {
|
||||
t.Fatalf("rejected detail: %v", err)
|
||||
}
|
||||
if goneDetail.MarkActive {
|
||||
t.Fatal("an unmarked peer was reported as carrying a mark")
|
||||
}
|
||||
|
||||
if _, err := store.CustomVerificationRequestDetail(ctx, 0); err == nil {
|
||||
t.Fatal("detail of a missing application succeeded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotVerificationReadStoreCounts(t *testing.T) {
|
||||
store, pool := verificationReadStore(t)
|
||||
fx := seedBotVerificationFixture(t, pool)
|
||||
ctx := context.Background()
|
||||
|
||||
counts, err := store.CustomVerificationRequestCounts(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("counts: %v", err)
|
||||
}
|
||||
// Every modelled status is present, so the panel never tells "none" from
|
||||
// "missing".
|
||||
for _, status := range []string{"pending", "approved", "rejected", "revoked"} {
|
||||
if _, ok := counts[status]; !ok {
|
||||
t.Fatalf("counts %+v missing %q", counts, status)
|
||||
}
|
||||
}
|
||||
if counts["pending"] == "0" || counts["approved"] == "0" || counts["rejected"] == "0" {
|
||||
t.Fatalf("counts %+v did not see the seeded applications (%d/%d/%d)",
|
||||
counts, fx.pendingReq, fx.approvedReq, fx.rejectedReq)
|
||||
}
|
||||
}
|
||||
354
cmd/telesrv-admin/readstore_verification_integration_test.go
Normal file
354
cmd/telesrv-admin/readstore_verification_integration_test.go
Normal file
|
|
@ -0,0 +1,354 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store/postgres"
|
||||
)
|
||||
|
||||
// The verification review queue is read with hand-written SQL, so the only thing
|
||||
// that can prove the column names, the array and nullable-timestamp scans, and the
|
||||
// ownership predicates are right is running them against the real schema. Gated on
|
||||
// TELESRV_TEST_POSTGRES_DSN, like every other integration test in the repo.
|
||||
|
||||
func verificationReadStore(t *testing.T) (*readStore, *pgxpool.Pool) {
|
||||
t.Helper()
|
||||
dsn := os.Getenv("TELESRV_TEST_POSTGRES_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("set TELESRV_TEST_POSTGRES_DSN to run postgres integration test")
|
||||
}
|
||||
parsed, err := pgxpool.ParseConfig(dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("parse TELESRV_TEST_POSTGRES_DSN: %v", err)
|
||||
}
|
||||
if !strings.Contains(strings.ToLower(parsed.ConnConfig.Database), "test") {
|
||||
t.Fatalf("TELESRV_TEST_POSTGRES_DSN must name a dedicated test database, got %q", parsed.ConnConfig.Database)
|
||||
}
|
||||
if err := postgres.Migrate(dsn); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
pool, err := pgxpool.New(context.Background(), dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("open pool: %v", err)
|
||||
}
|
||||
t.Cleanup(pool.Close)
|
||||
return newReadStore(pool), pool
|
||||
}
|
||||
|
||||
// verificationFixture seeds one applicant who owns a bot and administers a public
|
||||
// channel, plus one unrelated channel nobody controls, and files an application
|
||||
// against each. It returns the applicant id and the three application ids.
|
||||
type verificationFixture struct {
|
||||
applicant int64
|
||||
bot int64
|
||||
channel int64
|
||||
foreign int64
|
||||
botApp int64
|
||||
channelApp int64
|
||||
rejectedApp int64
|
||||
searchSuffix string
|
||||
}
|
||||
|
||||
func seedVerificationFixture(t *testing.T, pool *pgxpool.Pool) verificationFixture {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
var fx verificationFixture
|
||||
now := time.Now().UTC().Truncate(time.Microsecond)
|
||||
// Usernames and channel ids are globally unique, so every run needs its own
|
||||
// suffix; tests in this package may run against a database another run left
|
||||
// rows in.
|
||||
unique := now.UnixNano() & 0x7fffffff
|
||||
suffix := strconv.FormatInt(unique, 10)
|
||||
// channels.id carries no sequence: the caller assigns it.
|
||||
nextChannelID := 1_000_000_000 + unique%100_000_000
|
||||
|
||||
insertUser := func(name, username string, isBot, verified bool) int64 {
|
||||
var id int64
|
||||
if err := pool.QueryRow(ctx, `
|
||||
INSERT INTO users (access_hash, phone, first_name, last_name, username, is_bot, verified)
|
||||
VALUES ($1, $2, $3, 'Reviewer', $4, $5, $6)
|
||||
RETURNING id`, unique, "70"+strconv.FormatInt(unique, 10), name, username, isBot, verified).Scan(&id); err != nil {
|
||||
t.Fatalf("insert user %s: %v", name, err)
|
||||
}
|
||||
unique++
|
||||
return id
|
||||
}
|
||||
insertChannel := func(title, username string, verified bool) int64 {
|
||||
id := nextChannelID
|
||||
nextChannelID++
|
||||
if _, err := pool.Exec(ctx, `
|
||||
INSERT INTO channels (
|
||||
id, access_hash, creator_user_id, title, username, broadcast, megagroup,
|
||||
participants_count, admins_count, top_message_id, pts, date, verified
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, true, false, 1, 1, 1, 1, $6, $7)`,
|
||||
id, unique, fx.applicant, title, username, int32(now.Unix()), verified); err != nil {
|
||||
t.Fatalf("insert channel %s: %v", title, err)
|
||||
}
|
||||
unique++
|
||||
return id
|
||||
}
|
||||
|
||||
fx.applicant = insertUser("Applicant", "applicant"+suffix, false, false)
|
||||
fx.bot = insertUser("Fixturebot", "fixturebot"+suffix, true, false)
|
||||
if _, err := pool.Exec(ctx, `
|
||||
INSERT INTO bots (bot_user_id, owner_user_id, token_secret) VALUES ($1, $2, 'secret')`,
|
||||
fx.bot, fx.applicant); err != nil {
|
||||
t.Fatalf("insert bot: %v", err)
|
||||
}
|
||||
fx.channel = insertChannel("Fixture News", "fixturenews"+suffix, true)
|
||||
fx.foreign = insertChannel("Foreign Channel", "foreignchannel"+suffix, false)
|
||||
if _, err := pool.Exec(ctx, `
|
||||
INSERT INTO user_channel_member_index (user_id, channel_id, status, role, broadcast, public_username)
|
||||
VALUES ($1, $2, 'active', 'creator', true, true)`, fx.applicant, fx.channel); err != nil {
|
||||
t.Fatalf("insert member index: %v", err)
|
||||
}
|
||||
|
||||
insertApplication := func(
|
||||
targetType string, targetID int64, status, reviewer, reason string,
|
||||
reviewed bool,
|
||||
) int64 {
|
||||
var reviewedAt *time.Time
|
||||
if reviewed {
|
||||
reviewedAt = &now
|
||||
}
|
||||
var id int64
|
||||
if err := pool.QueryRow(ctx, `
|
||||
INSERT INTO verification_applications (
|
||||
applicant_user_id, target_type, target_id, target_title, target_username,
|
||||
category, description, official_website, social_links, press_links, additional_note,
|
||||
status, reviewer_admin_id, decision_reason, internal_note, correlation_id,
|
||||
created_at, updated_at, submitted_at, reviewed_at, version
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5,
|
||||
'media', 'a description long enough to satisfy the domain bar for submission',
|
||||
'https://example.test', $6, $7, 'note',
|
||||
$8, $9, $10, 'operator only', $11,
|
||||
$12, $12, $12, $13, 3
|
||||
) RETURNING id`,
|
||||
fx.applicant, targetType, targetID, "Snapshot "+targetType, "snapshot"+targetType+suffix,
|
||||
[]string{"https://social.example.test/a"},
|
||||
[]string{"https://press.example.test/a", "https://press.example.test/b"},
|
||||
status, reviewer, reason, "corr-"+status,
|
||||
now, reviewedAt,
|
||||
).Scan(&id); err != nil {
|
||||
t.Fatalf("insert %s application: %v", status, err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
fx.channelApp = insertApplication("channel", fx.channel, "submitted", "", "", false)
|
||||
fx.botApp = insertApplication("bot", fx.bot, "in_review", "alice", "", false)
|
||||
// Filed as a user target against an id the applicant is not, so the ownership
|
||||
// predicate has a negative case to answer.
|
||||
fx.rejectedApp = insertApplication("user", fx.foreign, "rejected", "bob", "press links are self-published", true)
|
||||
fx.searchSuffix = suffix
|
||||
|
||||
if _, err := pool.Exec(ctx, `
|
||||
INSERT INTO verification_application_events
|
||||
(application_id, kind, from_status, to_status, actor, reason, note, correlation_id, created_at)
|
||||
VALUES
|
||||
($1, 'submitted', 'draft', 'submitted', '', '', '', 'corr-submitted', $2),
|
||||
($1, 'claimed', 'submitted', 'in_review', 'alice', '', 'handover note', 'corr-claimed', $2)`,
|
||||
fx.channelApp, now); err != nil {
|
||||
t.Fatalf("insert events: %v", err)
|
||||
}
|
||||
|
||||
t.Cleanup(func() {
|
||||
ids := []int64{fx.channelApp, fx.botApp, fx.rejectedApp}
|
||||
_, _ = pool.Exec(ctx, "DELETE FROM verification_notification_outbox WHERE application_id = ANY($1::bigint[])", ids)
|
||||
_, _ = pool.Exec(ctx, "DELETE FROM verification_application_events WHERE application_id = ANY($1::bigint[])", ids)
|
||||
_, _ = pool.Exec(ctx, "DELETE FROM verification_applications WHERE id = ANY($1::bigint[])", ids)
|
||||
_, _ = pool.Exec(ctx, "DELETE FROM user_channel_member_index WHERE user_id = $1", fx.applicant)
|
||||
_, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = ANY($1::bigint[])", []int64{fx.channel, fx.foreign})
|
||||
_, _ = pool.Exec(ctx, "DELETE FROM bots WHERE bot_user_id = $1", fx.bot)
|
||||
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = ANY($1::bigint[])", []int64{fx.applicant, fx.bot})
|
||||
})
|
||||
return fx
|
||||
}
|
||||
|
||||
func TestVerificationReadStoreQueue(t *testing.T) {
|
||||
store, pool := verificationReadStore(t)
|
||||
fx := seedVerificationFixture(t, pool)
|
||||
ctx := context.Background()
|
||||
|
||||
rows, hasMore, err := store.ListVerificationApplications(ctx, "", "", "", "", 0, 200)
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
byID := map[int64]VerificationApplicationRow{}
|
||||
for _, row := range rows {
|
||||
byID[row.ID] = row
|
||||
}
|
||||
channelRow, ok := byID[fx.channelApp]
|
||||
if !ok {
|
||||
t.Fatalf("submitted application missing from the queue (hasMore=%v)", hasMore)
|
||||
}
|
||||
// The applicant is resolved through the join, the arrays survive the scan, and
|
||||
// the live target badge is read from the peer rather than the snapshot.
|
||||
if channelRow.ApplicantUserID != fx.applicant || channelRow.ApplicantUsername != "applicant"+fx.searchSuffix {
|
||||
t.Fatalf("applicant projection = %+v", channelRow)
|
||||
}
|
||||
if !strings.Contains(channelRow.ApplicantName, "Applicant") {
|
||||
t.Fatalf("applicant name = %q", channelRow.ApplicantName)
|
||||
}
|
||||
if len(channelRow.SocialLinks) != 1 || len(channelRow.PressLinks) != 2 {
|
||||
t.Fatalf("link arrays = %+v / %+v", channelRow.SocialLinks, channelRow.PressLinks)
|
||||
}
|
||||
if !channelRow.TargetVerified {
|
||||
t.Fatal("target_verified did not follow the live channel record")
|
||||
}
|
||||
if channelRow.InternalNote != "operator only" || channelRow.CorrelationID != "corr-submitted" {
|
||||
t.Fatalf("operator fields = %+v", channelRow)
|
||||
}
|
||||
if channelRow.SubmittedAt.IsZero() || !channelRow.ReviewedAt.IsZero() {
|
||||
t.Fatalf("timestamps submitted=%v reviewed=%v, want an undecided application", channelRow.SubmittedAt, channelRow.ReviewedAt)
|
||||
}
|
||||
if decided := byID[fx.rejectedApp]; decided.ReviewedAt.IsZero() || decided.DecisionReason == "" {
|
||||
t.Fatalf("decided application = %+v", decided)
|
||||
}
|
||||
|
||||
// Filters.
|
||||
filtered, _, err := store.ListVerificationApplications(ctx, "in_review", "", "alice", "", 0, 50)
|
||||
if err != nil {
|
||||
t.Fatalf("filtered list: %v", err)
|
||||
}
|
||||
for _, row := range filtered {
|
||||
if row.Status != "in_review" || row.ReviewerAdminID != "alice" {
|
||||
t.Fatalf("status/reviewer filter leaked %+v", row)
|
||||
}
|
||||
}
|
||||
typed, _, err := store.ListVerificationApplications(ctx, "", "bot", "", "", 0, 50)
|
||||
if err != nil {
|
||||
t.Fatalf("target_type list: %v", err)
|
||||
}
|
||||
for _, row := range typed {
|
||||
if row.TargetType != "bot" {
|
||||
t.Fatalf("target_type filter leaked %+v", row)
|
||||
}
|
||||
}
|
||||
// q matches the application id, the target id and a username prefix.
|
||||
for _, query := range []string{
|
||||
strconv.FormatInt(fx.channelApp, 10),
|
||||
strconv.FormatInt(fx.channel, 10),
|
||||
"snapshotchannel" + fx.searchSuffix,
|
||||
"@applicant" + fx.searchSuffix,
|
||||
} {
|
||||
found, _, err := store.ListVerificationApplications(ctx, "", "", "", query, 0, 50)
|
||||
if err != nil {
|
||||
t.Fatalf("search %q: %v", query, err)
|
||||
}
|
||||
if len(found) == 0 {
|
||||
t.Fatalf("search %q returned nothing", query)
|
||||
}
|
||||
}
|
||||
// The keyset cursor excludes the row it points at.
|
||||
after, _, err := store.ListVerificationApplications(ctx, "", "", "", "", fx.channelApp, 50)
|
||||
if err != nil {
|
||||
t.Fatalf("keyset list: %v", err)
|
||||
}
|
||||
for _, row := range after {
|
||||
if row.ID >= fx.channelApp {
|
||||
t.Fatalf("keyset page leaked id %d at or after the cursor %d", row.ID, fx.channelApp)
|
||||
}
|
||||
}
|
||||
// The page bound is honoured and reports more.
|
||||
page, more, err := store.ListVerificationApplications(ctx, "", "", "", "", 0, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("bounded list: %v", err)
|
||||
}
|
||||
if len(page) != 1 || !more {
|
||||
t.Fatalf("bounded page len=%d hasMore=%v", len(page), more)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerificationReadStoreDetailAndOwnership(t *testing.T) {
|
||||
store, pool := verificationReadStore(t)
|
||||
fx := seedVerificationFixture(t, pool)
|
||||
ctx := context.Background()
|
||||
|
||||
detail, err := store.VerificationApplicationDetail(ctx, fx.channelApp)
|
||||
if err != nil {
|
||||
t.Fatalf("detail: %v", err)
|
||||
}
|
||||
if detail.Application.ID != fx.channelApp || len(detail.Events) != 2 {
|
||||
t.Fatalf("detail = %+v events=%d", detail.Application, len(detail.Events))
|
||||
}
|
||||
// Newest first, and the operator-only note travels with the event.
|
||||
if detail.Events[0].Kind != string(domain.VerificationEventClaimed) ||
|
||||
detail.Events[0].Note != "handover note" || detail.Events[0].Actor != "alice" {
|
||||
t.Fatalf("events = %+v", detail.Events)
|
||||
}
|
||||
if !detail.ApplicantControlsTarget {
|
||||
t.Fatal("channel creator was not recognised as controlling the target")
|
||||
}
|
||||
|
||||
// A bot the applicant owns.
|
||||
botDetail, err := store.VerificationApplicationDetail(ctx, fx.botApp)
|
||||
if err != nil {
|
||||
t.Fatalf("bot detail: %v", err)
|
||||
}
|
||||
if !botDetail.ApplicantControlsTarget {
|
||||
t.Fatal("bot owner was not recognised as controlling the target")
|
||||
}
|
||||
|
||||
// A target the applicant has nothing to do with: the application was filed as
|
||||
// a user target against a foreign channel id, so identity does not match.
|
||||
foreignDetail, err := store.VerificationApplicationDetail(ctx, fx.rejectedApp)
|
||||
if err != nil {
|
||||
t.Fatalf("foreign detail: %v", err)
|
||||
}
|
||||
if foreignDetail.ApplicantControlsTarget {
|
||||
t.Fatal("an unrelated target was reported as controlled")
|
||||
}
|
||||
|
||||
if _, err := store.VerificationApplicationDetail(ctx, 0); err == nil {
|
||||
t.Fatal("detail of a missing application succeeded")
|
||||
}
|
||||
|
||||
// A user target that is the applicant themself is controlled by definition.
|
||||
controls, err := store.applicantControlsVerificationTarget(ctx, fx.applicant, "user", fx.applicant)
|
||||
if err != nil || !controls {
|
||||
t.Fatalf("self target controls=%v err=%v", controls, err)
|
||||
}
|
||||
// BotFather is owned by nobody, whatever the bots table says.
|
||||
controls, err = store.applicantControlsVerificationTarget(ctx, fx.applicant, "bot", domain.BotFatherUserID)
|
||||
if err != nil || controls {
|
||||
t.Fatalf("botfather controls=%v err=%v", controls, err)
|
||||
}
|
||||
// An unmodelled target type is never controlled.
|
||||
controls, err = store.applicantControlsVerificationTarget(ctx, fx.applicant, "group", fx.channel)
|
||||
if err != nil || controls {
|
||||
t.Fatalf("unmodelled target controls=%v err=%v", controls, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerificationReadStoreCounts(t *testing.T) {
|
||||
store, pool := verificationReadStore(t)
|
||||
fx := seedVerificationFixture(t, pool)
|
||||
ctx := context.Background()
|
||||
|
||||
counts, err := store.VerificationStatusCounts(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("counts: %v", err)
|
||||
}
|
||||
// Every modelled status is present, so the panel never tells "none" from
|
||||
// "missing".
|
||||
for _, status := range []string{"draft", "submitted", "in_review", "approved", "rejected", "cancelled"} {
|
||||
if _, ok := counts[status]; !ok {
|
||||
t.Fatalf("counts %+v missing %q", counts, status)
|
||||
}
|
||||
}
|
||||
if counts["submitted"] == "0" || counts["in_review"] == "0" || counts["rejected"] == "0" {
|
||||
t.Fatalf("counts %+v did not see the seeded applications (%d/%d/%d)",
|
||||
counts, fx.channelApp, fx.botApp, fx.rejectedApp)
|
||||
}
|
||||
}
|
||||
210
cmd/telesrv-admin/security.go
Normal file
210
cmd/telesrv-admin/security.go
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Panel session authorisation and CSRF.
|
||||
//
|
||||
// The panel authenticates with a cookie, which is what makes it a CSRF target:
|
||||
// a request forged by any other origin arrives with the operator's session
|
||||
// attached. Two independent checks close that.
|
||||
//
|
||||
// 1. Double-submit token. At login the server mints a random token, publishes it
|
||||
// in a readable cookie (telesrv_admin_csrf) and requires the same value in the
|
||||
// X-CSRF-Token header of every mutating request. A cross-origin page can make
|
||||
// the browser *send* the cookie but cannot read it, so it cannot produce the
|
||||
// header. Double-submit is the right shape here specifically because this
|
||||
// process keeps no server-side session store: the session lives entirely in a
|
||||
// signed cookie, so there is nowhere to park a per-session token, and the
|
||||
// stateless variant is the one that survives a restart and a second replica.
|
||||
// The token is additionally bound into the signed session claims, so a
|
||||
// cookie-writing neighbour (a sibling subdomain) cannot supply a matching
|
||||
// cookie/header pair of its own choosing either.
|
||||
//
|
||||
// 2. Origin agreement. When the browser states an Origin, it must be this host.
|
||||
// That catches a forged request from a page that somehow does hold a token.
|
||||
//
|
||||
// Both comparisons are constant time, for the same reason the session MAC is.
|
||||
|
||||
// Panel permission names. They match the strings an operator configures in
|
||||
// TELESRV_ADMIN_UI_PERMISSIONS and the ones the admin API enforces.
|
||||
const (
|
||||
permissionAll = "*"
|
||||
permissionVerificationReview = "verification.review"
|
||||
permissionVerificationRevoke = "verification.revoke"
|
||||
// Third-party bot verification. Deliberately not implied by the official
|
||||
// verification rights above: the two are separate mechanisms over separate
|
||||
// tables, so a session trusted with one queue is not thereby trusted with the
|
||||
// other. review reads and decides applications; manage appoints verifiers,
|
||||
// curates the icon catalogue and strips granted marks.
|
||||
permissionBotVerificationReview = "botverification.review"
|
||||
permissionBotVerificationManage = "botverification.manage"
|
||||
)
|
||||
|
||||
type permissionsKey struct{}
|
||||
|
||||
// requireAuthAPI is the gate on every authenticated API route: a valid session,
|
||||
// and -- for a mutating request -- a valid CSRF token.
|
||||
func (s *server) requireAuthAPI(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
cookie, err := r.Cookie(sessionCookieName)
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
claims, ok := verifySession(s.cfg.SessionKey, cookie.Value, time.Now())
|
||||
if !ok {
|
||||
clearSessionCookie(w)
|
||||
writeAPIError(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
if !checkMutationSafety(w, r, claims) {
|
||||
return
|
||||
}
|
||||
ctx := context.WithValue(r.Context(), actorKey{}, claims.Actor)
|
||||
ctx = context.WithValue(ctx, permissionsKey{}, newPanelPermissions(claims.Permissions))
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
// requirePermission refuses a session that was not granted the right, before the
|
||||
// request ever reaches the admin API. The panel is the only caller that can be
|
||||
// driven by a browser, so the check belongs here as well as upstream: a 403 from
|
||||
// this process costs no round trip and cannot be confused with a domain failure.
|
||||
func (s *server) requirePermission(permission string, next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !permissionsFromContext(r.Context()).Has(permission) {
|
||||
writeJSON(w, http.StatusForbidden, map[string]any{
|
||||
"error": "permission " + permission + " is required",
|
||||
"code": "FORBIDDEN",
|
||||
"permission": permission,
|
||||
})
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// checkMutationSafety enforces the CSRF contract on a mutating request.
|
||||
func checkMutationSafety(w http.ResponseWriter, r *http.Request, claims sessionClaims) bool {
|
||||
if !mutatingMethod(r.Method) {
|
||||
return true
|
||||
}
|
||||
if !sameOriginRequest(r) {
|
||||
writeAPIError(w, http.StatusForbidden, "origin is not allowed")
|
||||
return false
|
||||
}
|
||||
cookie, err := r.Cookie(csrfCookieName)
|
||||
if err != nil || cookie.Value == "" {
|
||||
writeAPIError(w, http.StatusForbidden, "missing "+csrfCookieName+" cookie; sign in again")
|
||||
return false
|
||||
}
|
||||
header := strings.TrimSpace(r.Header.Get(csrfHeaderName))
|
||||
if header == "" {
|
||||
writeAPIError(w, http.StatusForbidden, "missing "+csrfHeaderName+" header")
|
||||
return false
|
||||
}
|
||||
if subtle.ConstantTimeCompare([]byte(header), []byte(cookie.Value)) != 1 {
|
||||
writeAPIError(w, http.StatusForbidden, csrfHeaderName+" does not match the "+csrfCookieName+" cookie")
|
||||
return false
|
||||
}
|
||||
// The signed session is the third leg: it pins the pair to the session this
|
||||
// server issued. A session minted before the token existed carries no CSRF
|
||||
// claim and is refused, which forces one re-login rather than leaving a
|
||||
// half-protected session running.
|
||||
if claims.CSRF == "" || subtle.ConstantTimeCompare([]byte(header), []byte(claims.CSRF)) != 1 {
|
||||
writeAPIError(w, http.StatusForbidden, "csrf token is not bound to this session; sign in again")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// mutatingMethod reports whether the method changes state. GET/HEAD/OPTIONS are
|
||||
// the safe ones; everything else has to carry a token.
|
||||
func mutatingMethod(method string) bool {
|
||||
switch strings.ToUpper(method) {
|
||||
case http.MethodGet, http.MethodHead, http.MethodOptions:
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// sameOriginRequest checks the Origin header against the request host.
|
||||
//
|
||||
// An absent Origin is accepted: browsers omit it on same-origin requests and
|
||||
// non-browser callers (curl, tests) never send it, so requiring it would break
|
||||
// the panel without adding protection the token does not already give. A present
|
||||
// Origin must be this host -- including the literal "null" a sandboxed or
|
||||
// privacy-stripped context sends, which is by definition not this host.
|
||||
//
|
||||
// This compares against r.Host, so a reverse proxy in front of the panel has to
|
||||
// preserve it (nginx: proxy_set_header Host $host).
|
||||
func sameOriginRequest(r *http.Request) bool {
|
||||
origin := strings.TrimSpace(r.Header.Get("Origin"))
|
||||
if origin == "" {
|
||||
return true
|
||||
}
|
||||
parsed, err := url.Parse(origin)
|
||||
if err != nil || parsed.Host == "" {
|
||||
return false
|
||||
}
|
||||
return strings.EqualFold(parsed.Host, r.Host)
|
||||
}
|
||||
|
||||
// panelPermissions is a resolved session permission set.
|
||||
type panelPermissions struct {
|
||||
all bool
|
||||
names map[string]struct{}
|
||||
list []string
|
||||
}
|
||||
|
||||
func newPanelPermissions(permissions []string) panelPermissions {
|
||||
set := panelPermissions{names: make(map[string]struct{}, len(permissions))}
|
||||
for _, permission := range permissions {
|
||||
permission = strings.TrimSpace(permission)
|
||||
if permission == "" {
|
||||
continue
|
||||
}
|
||||
if _, dup := set.names[permission]; dup {
|
||||
continue
|
||||
}
|
||||
if permission == permissionAll {
|
||||
set.all = true
|
||||
}
|
||||
set.names[permission] = struct{}{}
|
||||
set.list = append(set.list, permission)
|
||||
}
|
||||
return set
|
||||
}
|
||||
|
||||
// Has reports whether the session was granted the permission.
|
||||
func (p panelPermissions) Has(permission string) bool {
|
||||
if p.all {
|
||||
return true
|
||||
}
|
||||
_, ok := p.names[permission]
|
||||
return ok
|
||||
}
|
||||
|
||||
// List is what the panel is told about itself, so the UI can hide a section the
|
||||
// session may not use instead of rendering it into a 403.
|
||||
func (p panelPermissions) List() []string {
|
||||
if p.list == nil {
|
||||
return []string{}
|
||||
}
|
||||
return p.list
|
||||
}
|
||||
|
||||
func permissionsFromContext(ctx context.Context) panelPermissions {
|
||||
if permissions, ok := ctx.Value(permissionsKey{}).(panelPermissions); ok {
|
||||
return permissions
|
||||
}
|
||||
return panelPermissions{}
|
||||
}
|
||||
|
|
@ -47,7 +47,10 @@ func newServer(cfg uiConfig, read *readStore) (*server, error) {
|
|||
func (s *server) routes() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("POST /api/login", s.handleAPILogin)
|
||||
mux.HandleFunc("POST /api/logout", s.handleAPILogout)
|
||||
// Logout goes through the same gate as every other mutating route: a forced
|
||||
// logout is a state change, and an invalid session is cleared by the gate
|
||||
// itself, so nothing is stranded by protecting it.
|
||||
mux.Handle("POST /api/logout", s.requireAuthAPI(http.HandlerFunc(s.handleAPILogout)))
|
||||
mux.Handle("GET /api/session", s.requireAuthAPI(http.HandlerFunc(s.handleSession)))
|
||||
mux.Handle("GET /api/accounts", s.requireAuthAPI(http.HandlerFunc(s.handleAccountsAPI)))
|
||||
mux.Handle("GET /api/accounts/{id}", s.requireAuthAPI(http.HandlerFunc(s.handleAccountDetailAPI)))
|
||||
|
|
@ -67,6 +70,10 @@ func (s *server) routes() http.Handler {
|
|||
mux.Handle("GET /api/gifts/{id}/animation", s.requireAuthAPI(http.HandlerFunc(s.handleStarGiftAnimationAPI)))
|
||||
mux.Handle("GET /api/gifts/{id}/collectibles", s.requireAuthAPI(http.HandlerFunc(s.handleStarGiftCollectiblesAPI)))
|
||||
mux.Handle("GET /api/gifts/{id}/collectibles/{kind}/{attribute_id}/animation", s.requireAuthAPI(http.HandlerFunc(s.handleStarGiftCollectibleAnimationAPI)))
|
||||
mux.Handle("GET /api/collectible-usernames", s.requireAuthAPI(http.HandlerFunc(s.handleCollectibleUsernamesAPI)))
|
||||
mux.Handle("GET /api/collectible-usernames/{id}", s.requireAuthAPI(http.HandlerFunc(s.handleCollectibleUsernameDetailAPI)))
|
||||
mux.Handle("GET /api/account-ratings", s.requireAuthAPI(http.HandlerFunc(s.handleAccountRatingsAPI)))
|
||||
mux.Handle("GET /api/account-ratings/{user_id}", s.requireAuthAPI(http.HandlerFunc(s.handleAccountRatingDetailAPI)))
|
||||
mux.Handle("GET /api/moderation/cases", s.requireAuthAPI(http.HandlerFunc(s.handleModerationCasesAPI)))
|
||||
mux.Handle("GET /api/moderation/cases/{id}", s.requireAuthAPI(http.HandlerFunc(s.handleModerationCaseAPI)))
|
||||
mux.Handle("GET /api/moderation/reports/{id}", s.requireAuthAPI(http.HandlerFunc(s.handleModerationReportAPI)))
|
||||
|
|
@ -99,6 +106,43 @@ func (s *server) routes() http.Handler {
|
|||
mux.Handle("POST /api/actions/set-gift-enabled", s.requireAuthAPI(http.HandlerFunc(s.handleSetStarGiftEnabledAPI)))
|
||||
mux.Handle("POST /api/actions/set-gift-sort-order", s.requireAuthAPI(http.HandlerFunc(s.handleSetStarGiftSortOrderAPI)))
|
||||
mux.Handle("POST /api/actions/give-gift", s.requireAuthAPI(http.HandlerFunc(s.handleGiveGiftAPI)))
|
||||
mux.Handle("POST /api/actions/mint-collectible-username", s.requireAuthAPI(http.HandlerFunc(s.handleMintCollectibleUsernameAPI)))
|
||||
mux.Handle("POST /api/actions/transfer-collectible-username", s.requireAuthAPI(http.HandlerFunc(s.handleTransferCollectibleUsernameAPI)))
|
||||
mux.Handle("POST /api/actions/revoke-collectible-username", s.requireAuthAPI(http.HandlerFunc(s.handleRevokeCollectibleUsernameAPI)))
|
||||
mux.Handle("POST /api/actions/delete-collectible-username", s.requireAuthAPI(http.HandlerFunc(s.handleDeleteCollectibleUsernameAPI)))
|
||||
mux.Handle("POST /api/actions/recompute-account-rating", s.requireAuthAPI(http.HandlerFunc(s.handleRecomputeAccountRatingAPI)))
|
||||
mux.Handle("POST /api/actions/adjust-account-rating", s.requireAuthAPI(http.HandlerFunc(s.handleAdjustAccountRatingAPI)))
|
||||
// Official platform verification. Every route needs verification.review;
|
||||
// clearing an existing badge needs verification.revoke on top of it.
|
||||
mux.Handle("GET /api/verification/applications", s.verificationRead(s.handleVerificationApplicationsAPI))
|
||||
mux.Handle("GET /api/verification/applications/{id}", s.verificationRead(s.handleVerificationApplicationDetailAPI))
|
||||
mux.Handle("GET /api/verification/counts", s.verificationRead(s.handleVerificationCountsAPI))
|
||||
mux.Handle("POST /api/verification/applications/{id}/claim", s.verificationRead(s.handleClaimVerificationAPI))
|
||||
mux.Handle("POST /api/verification/applications/{id}/approve", s.verificationRead(s.handleApproveVerificationAPI))
|
||||
mux.Handle("POST /api/verification/applications/{id}/reject", s.verificationRead(s.handleRejectVerificationAPI))
|
||||
mux.Handle("POST /api/actions/revoke-verification", s.requireAuthAPI(
|
||||
s.requirePermission(permissionVerificationReview,
|
||||
s.requirePermission(permissionVerificationRevoke, http.HandlerFunc(s.handleRevokeVerificationAPI)))))
|
||||
// Third-party bot verification. A separate section from the official
|
||||
// verification block above -- separate tables, separate rights, separate routes.
|
||||
// Reads and queue decisions need botverification.review; appointing verifiers,
|
||||
// curating the icon catalogue and stripping a granted mark need
|
||||
// botverification.manage.
|
||||
mux.Handle("GET /api/botverification/verifiers", s.botVerificationRead(s.handleBotVerifiersAPI))
|
||||
mux.Handle("GET /api/botverification/icons", s.botVerificationRead(s.handleVerificationIconsAPI))
|
||||
mux.Handle("GET /api/botverification/marks", s.botVerificationRead(s.handleCustomVerificationsAPI))
|
||||
mux.Handle("GET /api/botverification/requests", s.botVerificationRead(s.handleCustomVerificationRequestsAPI))
|
||||
mux.Handle("GET /api/botverification/requests/{id}", s.botVerificationRead(s.handleCustomVerificationRequestDetailAPI))
|
||||
mux.Handle("GET /api/botverification/counts", s.botVerificationRead(s.handleCustomVerificationCountsAPI))
|
||||
mux.Handle("POST /api/botverification/requests/{id}/approve", s.botVerificationRead(s.handleApproveBotVerificationAPI))
|
||||
mux.Handle("POST /api/botverification/requests/{id}/reject", s.botVerificationRead(s.handleRejectBotVerificationAPI))
|
||||
mux.Handle("POST /api/botverification/requests/{id}/revoke", s.botVerificationRead(s.handleRevokeBotVerificationAPI))
|
||||
mux.Handle("POST /api/actions/grant-bot-verifier", s.botVerificationManage(s.handleGrantBotVerifierAPI))
|
||||
mux.Handle("POST /api/actions/set-bot-verifier-enabled", s.botVerificationManage(s.handleSetBotVerifierEnabledAPI))
|
||||
mux.Handle("POST /api/actions/revoke-bot-verifier", s.botVerificationManage(s.handleRevokeBotVerifierAPI))
|
||||
mux.Handle("POST /api/actions/upsert-verification-icon", s.botVerificationManage(s.handleUpsertVerificationIconAPI))
|
||||
mux.Handle("POST /api/actions/set-verification-icon-active", s.botVerificationManage(s.handleSetVerificationIconActiveAPI))
|
||||
mux.Handle("POST /api/actions/revoke-custom-verification", s.botVerificationManage(s.handleRevokeCustomVerificationAPI))
|
||||
mux.HandleFunc("/api/", func(w http.ResponseWriter, _ *http.Request) {
|
||||
writeAPIError(w, http.StatusNotFound, "api route not found")
|
||||
})
|
||||
|
|
@ -108,23 +152,6 @@ func (s *server) routes() http.Handler {
|
|||
|
||||
type actorKey struct{}
|
||||
|
||||
func (s *server) requireAuthAPI(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
cookie, err := r.Cookie(sessionCookieName)
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
claims, ok := verifySession(s.cfg.SessionKey, cookie.Value, time.Now())
|
||||
if !ok {
|
||||
clearSessionCookie(w)
|
||||
writeAPIError(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), actorKey{}, claims.Actor)))
|
||||
})
|
||||
}
|
||||
|
||||
func actorFromContext(ctx context.Context) string {
|
||||
if actor, ok := ctx.Value(actorKey{}).(string); ok && actor != "" {
|
||||
return actor
|
||||
|
|
@ -149,7 +176,18 @@ type loginRequest struct {
|
|||
Secret string `json:"secret"`
|
||||
}
|
||||
|
||||
// sessionTTL bounds a signed panel session and the CSRF cookie that goes with it,
|
||||
// so the two never outlive each other.
|
||||
const sessionTTL = 12 * time.Hour
|
||||
|
||||
func (s *server) handleAPILogin(w http.ResponseWriter, r *http.Request) {
|
||||
// Login is the one mutating route without a CSRF token, because no session
|
||||
// exists yet to bind one to. The Origin check still applies, and the request
|
||||
// carries the operator credential, which a forging page does not have.
|
||||
if !sameOriginRequest(r) {
|
||||
writeAPIError(w, http.StatusForbidden, "origin is not allowed")
|
||||
return
|
||||
}
|
||||
var req loginRequest
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
writeAPIError(w, http.StatusBadRequest, err.Error())
|
||||
|
|
@ -159,10 +197,18 @@ func (s *server) handleAPILogin(w http.ResponseWriter, r *http.Request) {
|
|||
writeAPIError(w, http.StatusUnauthorized, "invalid credential")
|
||||
return
|
||||
}
|
||||
csrfToken, err := newCSRFToken()
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
permissions := newPanelPermissions(s.cfg.Permissions)
|
||||
value, err := signSession(s.cfg.SessionKey, sessionClaims{
|
||||
Actor: "admin",
|
||||
Exp: time.Now().Add(12 * time.Hour).Unix(),
|
||||
Nonce: newCommandID("sess"),
|
||||
Actor: "admin",
|
||||
Exp: time.Now().Add(sessionTTL).Unix(),
|
||||
Nonce: newCommandID("sess"),
|
||||
Permissions: permissions.List(),
|
||||
CSRF: csrfToken,
|
||||
})
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusInternalServerError, err.Error())
|
||||
|
|
@ -172,11 +218,16 @@ func (s *server) handleAPILogin(w http.ResponseWriter, r *http.Request) {
|
|||
Name: sessionCookieName,
|
||||
Value: value,
|
||||
Path: "/",
|
||||
MaxAge: int((12 * time.Hour).Seconds()),
|
||||
MaxAge: int(sessionTTL.Seconds()),
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"actor": "admin"})
|
||||
setCSRFCookie(w, csrfToken, sessionTTL)
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"actor": "admin",
|
||||
"permissions": permissions.List(),
|
||||
"csrf_token": csrfToken,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *server) validSecret(secret string) bool {
|
||||
|
|
@ -194,8 +245,14 @@ func (s *server) handleAPILogout(w http.ResponseWriter, _ *http.Request) {
|
|||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
// handleSession is what the panel asks on load. It reports the permissions the
|
||||
// session carries, so the UI can hide a section the operator may not use rather
|
||||
// than letting them walk into a 403.
|
||||
func (s *server) handleSession(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"actor": actorFromContext(r.Context())})
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"actor": actorFromContext(r.Context()),
|
||||
"permissions": permissionsFromContext(r.Context()).List(),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *server) handleStarGiftsAPI(w http.ResponseWriter, r *http.Request) {
|
||||
|
|
@ -1482,12 +1539,12 @@ func (s *server) handleSetStarGiftSortOrderAPI(w http.ResponseWriter, r *http.Re
|
|||
}
|
||||
|
||||
type giveGiftAPIRequest struct {
|
||||
CommandID string `json:"command_id"`
|
||||
Reason string `json:"reason"`
|
||||
Confirm bool `json:"confirm"`
|
||||
SenderUserID int64 `json:"sender_user_id"`
|
||||
UserID int64 `json:"user_id"`
|
||||
ChannelID int64 `json:"channel_id"`
|
||||
CommandID string `json:"command_id"`
|
||||
Reason string `json:"reason"`
|
||||
Confirm bool `json:"confirm"`
|
||||
SenderUserID int64 `json:"sender_user_id"`
|
||||
UserID int64 `json:"user_id"`
|
||||
ChannelID int64 `json:"channel_id"`
|
||||
GiftID int64 `json:"gift_id,string"`
|
||||
HideName bool `json:"hide_name"`
|
||||
Message string `json:"message"`
|
||||
|
|
@ -1519,6 +1576,367 @@ func (s *server) handleGiveGiftAPI(w http.ResponseWriter, r *http.Request) {
|
|||
writeCommandResultAPI(w, result, err)
|
||||
}
|
||||
|
||||
// flexInt64 decodes an int64 the panel may send either as a JSON number or as a
|
||||
// decimal string. Ids and nanoton amounts are sent as strings to stay exact past
|
||||
// 2^53, while a picker-supplied peer id arrives as a plain number; an empty
|
||||
// string and null both mean "unset", which is how an untouched form field looks.
|
||||
type flexInt64 int64
|
||||
|
||||
// Int64 returns the decoded value.
|
||||
func (v flexInt64) Int64() int64 { return int64(v) }
|
||||
|
||||
func (v *flexInt64) UnmarshalJSON(raw []byte) error {
|
||||
text, empty := flexScalarText(raw)
|
||||
if empty {
|
||||
*v = 0
|
||||
return nil
|
||||
}
|
||||
parsed, err := strconv.ParseInt(text, 10, 64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid integer %s", string(raw))
|
||||
}
|
||||
*v = flexInt64(parsed)
|
||||
return nil
|
||||
}
|
||||
|
||||
// flexUnix decodes an optional timestamp as a Unix second count. A date input
|
||||
// produces an RFC3339 string and a scripted call a plain number, so both are
|
||||
// accepted; empty means "unset", which the mint command stamps with its clock.
|
||||
type flexUnix int64
|
||||
|
||||
// Unix returns the decoded timestamp in seconds, or zero when unset.
|
||||
func (v flexUnix) Unix() int64 { return int64(v) }
|
||||
|
||||
func (v *flexUnix) UnmarshalJSON(raw []byte) error {
|
||||
text, empty := flexScalarText(raw)
|
||||
if empty {
|
||||
*v = 0
|
||||
return nil
|
||||
}
|
||||
if parsed, err := strconv.ParseInt(text, 10, 64); err == nil {
|
||||
*v = flexUnix(parsed)
|
||||
return nil
|
||||
}
|
||||
for _, layout := range []string{time.RFC3339, "2006-01-02"} {
|
||||
if parsed, err := time.Parse(layout, text); err == nil {
|
||||
*v = flexUnix(parsed.UTC().Unix())
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("invalid timestamp %s", string(raw))
|
||||
}
|
||||
|
||||
// flexScalarText unwraps a JSON scalar to its textual form and reports whether
|
||||
// it carries no value at all (null, empty string, blank).
|
||||
func flexScalarText(raw []byte) (string, bool) {
|
||||
text := strings.TrimSpace(string(raw))
|
||||
if text == "" || text == "null" {
|
||||
return "", true
|
||||
}
|
||||
if unquoted, err := strconv.Unquote(text); err == nil {
|
||||
text = strings.TrimSpace(unquoted)
|
||||
}
|
||||
if text == "" {
|
||||
return "", true
|
||||
}
|
||||
return text, false
|
||||
}
|
||||
|
||||
type mintCollectibleUsernameAPIRequest struct {
|
||||
CommandID string `json:"command_id"`
|
||||
Reason string `json:"reason"`
|
||||
Confirm bool `json:"confirm"`
|
||||
Username string `json:"username"`
|
||||
OwnerUserID flexInt64 `json:"owner_user_id"`
|
||||
OwnerChannelID flexInt64 `json:"owner_channel_id"`
|
||||
Currency string `json:"currency"`
|
||||
Amount flexInt64 `json:"amount"`
|
||||
CryptoCurrency string `json:"crypto_currency"`
|
||||
CryptoAmount flexInt64 `json:"crypto_amount"`
|
||||
URL string `json:"url"`
|
||||
PurchaseDate flexUnix `json:"purchase_date"`
|
||||
}
|
||||
|
||||
func (s *server) handleMintCollectibleUsernameAPI(w http.ResponseWriter, r *http.Request) {
|
||||
var body mintCollectibleUsernameAPIRequest
|
||||
if !decodeAction(w, r, &body) {
|
||||
return
|
||||
}
|
||||
req := admin.MintCollectibleUsernameRequest{
|
||||
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "mint-collectible-username"),
|
||||
Username: body.Username,
|
||||
OwnerUserID: body.OwnerUserID.Int64(),
|
||||
OwnerChannelID: body.OwnerChannelID.Int64(),
|
||||
Currency: body.Currency,
|
||||
Amount: body.Amount.Int64(),
|
||||
CryptoCurrency: body.CryptoCurrency,
|
||||
CryptoAmount: body.CryptoAmount.Int64(),
|
||||
URL: body.URL,
|
||||
PurchaseDate: body.PurchaseDate.Unix(),
|
||||
}
|
||||
result, err := s.callAdminAPI(r.Context(), "/v1/collectible-usernames/mint", req)
|
||||
writeCommandResultAPI(w, result, err)
|
||||
}
|
||||
|
||||
type transferCollectibleUsernameAPIRequest struct {
|
||||
CommandID string `json:"command_id"`
|
||||
Reason string `json:"reason"`
|
||||
Confirm bool `json:"confirm"`
|
||||
Username string `json:"username"`
|
||||
ToUserID flexInt64 `json:"to_user_id"`
|
||||
ToChannelID flexInt64 `json:"to_channel_id"`
|
||||
}
|
||||
|
||||
func (s *server) handleTransferCollectibleUsernameAPI(w http.ResponseWriter, r *http.Request) {
|
||||
var body transferCollectibleUsernameAPIRequest
|
||||
if !decodeAction(w, r, &body) {
|
||||
return
|
||||
}
|
||||
req := admin.TransferCollectibleUsernameRequest{
|
||||
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "transfer-collectible-username"),
|
||||
Username: body.Username,
|
||||
ToUserID: body.ToUserID.Int64(),
|
||||
ToChannelID: body.ToChannelID.Int64(),
|
||||
}
|
||||
result, err := s.callAdminAPI(r.Context(), "/v1/collectible-usernames/transfer", req)
|
||||
writeCommandResultAPI(w, result, err)
|
||||
}
|
||||
|
||||
type revokeCollectibleUsernameAPIRequest struct {
|
||||
CommandID string `json:"command_id"`
|
||||
Reason string `json:"reason"`
|
||||
Confirm bool `json:"confirm"`
|
||||
Username string `json:"username"`
|
||||
Burn bool `json:"burn"`
|
||||
}
|
||||
|
||||
func (s *server) handleRevokeCollectibleUsernameAPI(w http.ResponseWriter, r *http.Request) {
|
||||
var body revokeCollectibleUsernameAPIRequest
|
||||
if !decodeAction(w, r, &body) {
|
||||
return
|
||||
}
|
||||
prefix := "revoke-collectible-username"
|
||||
if body.Burn {
|
||||
prefix = "burn-collectible-username"
|
||||
}
|
||||
req := admin.RevokeCollectibleUsernameRequest{
|
||||
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, prefix),
|
||||
Username: body.Username,
|
||||
Burn: body.Burn,
|
||||
}
|
||||
result, err := s.callAdminAPI(r.Context(), "/v1/collectible-usernames/revoke", req)
|
||||
writeCommandResultAPI(w, result, err)
|
||||
}
|
||||
|
||||
type deleteCollectibleUsernameAPIRequest struct {
|
||||
CommandID string `json:"command_id"`
|
||||
Reason string `json:"reason"`
|
||||
Confirm bool `json:"confirm"`
|
||||
Username string `json:"username"`
|
||||
}
|
||||
|
||||
// handleDeleteCollectibleUsernameAPI erases an asset and its provenance. The
|
||||
// panel gates it behind the same reason + dry-run + confirm flow as a burn, but
|
||||
// the outcome differs: the name becomes issuable again from scratch.
|
||||
func (s *server) handleDeleteCollectibleUsernameAPI(w http.ResponseWriter, r *http.Request) {
|
||||
var body deleteCollectibleUsernameAPIRequest
|
||||
if !decodeAction(w, r, &body) {
|
||||
return
|
||||
}
|
||||
req := admin.DeleteCollectibleUsernameRequest{
|
||||
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "delete-collectible-username"),
|
||||
Username: body.Username,
|
||||
}
|
||||
result, err := s.callAdminAPI(r.Context(), "/v1/collectible-usernames/delete", req)
|
||||
writeCommandResultAPI(w, result, err)
|
||||
}
|
||||
|
||||
type recomputeAccountRatingAPIRequest struct {
|
||||
CommandID string `json:"command_id"`
|
||||
Reason string `json:"reason"`
|
||||
Confirm bool `json:"confirm"`
|
||||
UserID flexInt64 `json:"user_id"`
|
||||
}
|
||||
|
||||
func (s *server) handleRecomputeAccountRatingAPI(w http.ResponseWriter, r *http.Request) {
|
||||
var body recomputeAccountRatingAPIRequest
|
||||
if !decodeAction(w, r, &body) {
|
||||
return
|
||||
}
|
||||
req := admin.RecomputeAccountRatingRequest{
|
||||
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "recompute-account-rating"),
|
||||
UserID: body.UserID.Int64(),
|
||||
}
|
||||
result, err := s.callAdminAPI(r.Context(), "/v1/account-ratings/recompute", req)
|
||||
writeCommandResultAPI(w, result, err)
|
||||
}
|
||||
|
||||
type adjustAccountRatingAPIRequest struct {
|
||||
CommandID string `json:"command_id"`
|
||||
Reason string `json:"reason"`
|
||||
Confirm bool `json:"confirm"`
|
||||
UserID flexInt64 `json:"user_id"`
|
||||
Amount flexInt64 `json:"amount"`
|
||||
}
|
||||
|
||||
func (s *server) handleAdjustAccountRatingAPI(w http.ResponseWriter, r *http.Request) {
|
||||
var body adjustAccountRatingAPIRequest
|
||||
if !decodeAction(w, r, &body) {
|
||||
return
|
||||
}
|
||||
req := admin.AdjustAccountRatingRequest{
|
||||
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "adjust-account-rating"),
|
||||
UserID: body.UserID.Int64(),
|
||||
Amount: body.Amount.Int64(),
|
||||
}
|
||||
result, err := s.callAdminAPI(r.Context(), "/v1/account-ratings/adjust", req)
|
||||
writeCommandResultAPI(w, result, err)
|
||||
}
|
||||
|
||||
// handleCollectibleUsernamesAPI pages the collectible asset table straight from
|
||||
// PostgreSQL, like every other table view, and echoes the keyset cursor as a
|
||||
// decimal string so an int64 id survives the round trip through the browser.
|
||||
func (s *server) handleCollectibleUsernamesAPI(w http.ResponseWriter, r *http.Request) {
|
||||
if s.read == nil {
|
||||
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")
|
||||
return
|
||||
}
|
||||
query := r.URL.Query()
|
||||
status := strings.TrimSpace(query.Get("status"))
|
||||
switch status {
|
||||
case "", string(domain.CollectibleUsernameStatusVault),
|
||||
string(domain.CollectibleUsernameStatusOwned),
|
||||
string(domain.CollectibleUsernameStatusBurned):
|
||||
default:
|
||||
writeAPIError(w, http.StatusBadRequest, "invalid status")
|
||||
return
|
||||
}
|
||||
ownerUserID, err := parseInt64(query.Get("owner_user_id"))
|
||||
if err != nil || ownerUserID < 0 {
|
||||
writeAPIError(w, http.StatusBadRequest, "invalid owner_user_id")
|
||||
return
|
||||
}
|
||||
beforeID, err := parseInt64(query.Get("before_id"))
|
||||
if err != nil || beforeID < 0 {
|
||||
writeAPIError(w, http.StatusBadRequest, "invalid before_id")
|
||||
return
|
||||
}
|
||||
limit, err := parseInt(query.Get("limit"))
|
||||
if err != nil || limit < 0 {
|
||||
writeAPIError(w, http.StatusBadRequest, "invalid limit")
|
||||
return
|
||||
}
|
||||
rows, hasMore, err := s.read.ListCollectibleUsernames(r.Context(), status, ownerUserID, beforeID, query.Get("q"), limit)
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
nextBeforeID := ""
|
||||
if hasMore && len(rows) > 0 {
|
||||
nextBeforeID = strconv.FormatInt(rows[len(rows)-1].ID, 10)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"rows": rows,
|
||||
"has_more": hasMore,
|
||||
"next_before_id": nextBeforeID,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *server) handleCollectibleUsernameDetailAPI(w http.ResponseWriter, r *http.Request) {
|
||||
if s.read == nil {
|
||||
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")
|
||||
return
|
||||
}
|
||||
id, err := parseInt64(r.PathValue("id"))
|
||||
if err != nil || id <= 0 {
|
||||
writeAPIError(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
detail, err := s.read.CollectibleUsernameDetail(r.Context(), id)
|
||||
if err != nil {
|
||||
if errors.Is(err, errReadNotFound) {
|
||||
writeAPIError(w, http.StatusNotFound, "collectible username not found")
|
||||
return
|
||||
}
|
||||
writeAPIError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"asset": detail.Asset,
|
||||
"transfers": detail.Transfers,
|
||||
})
|
||||
}
|
||||
|
||||
// handleAccountRatingsAPI pages the leaderboard. next_before_id is the last
|
||||
// user id: the keyset predicate resolves the full (level, stars, user_id) cursor
|
||||
// from it, so one opaque-looking value is enough to continue the page.
|
||||
func (s *server) handleAccountRatingsAPI(w http.ResponseWriter, r *http.Request) {
|
||||
if s.read == nil {
|
||||
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")
|
||||
return
|
||||
}
|
||||
query := r.URL.Query()
|
||||
minLevel, err := parseInt(query.Get("min_level"))
|
||||
if err != nil || minLevel < 0 {
|
||||
writeAPIError(w, http.StatusBadRequest, "invalid min_level")
|
||||
return
|
||||
}
|
||||
userID, err := parseInt64(query.Get("user_id"))
|
||||
if err != nil || userID < 0 {
|
||||
writeAPIError(w, http.StatusBadRequest, "invalid user_id")
|
||||
return
|
||||
}
|
||||
beforeID, err := parseInt64(query.Get("before_id"))
|
||||
if err != nil || beforeID < 0 {
|
||||
writeAPIError(w, http.StatusBadRequest, "invalid before_id")
|
||||
return
|
||||
}
|
||||
limit, err := parseInt(query.Get("limit"))
|
||||
if err != nil || limit < 0 {
|
||||
writeAPIError(w, http.StatusBadRequest, "invalid limit")
|
||||
return
|
||||
}
|
||||
rows, hasMore, err := s.read.ListAccountRatings(r.Context(), minLevel, userID, beforeID, limit, query.Get("q"))
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
nextBeforeID := ""
|
||||
if hasMore && len(rows) > 0 {
|
||||
nextBeforeID = strconv.FormatInt(rows[len(rows)-1].UserID, 10)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"rows": rows,
|
||||
"has_more": hasMore,
|
||||
"next_before_id": nextBeforeID,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *server) handleAccountRatingDetailAPI(w http.ResponseWriter, r *http.Request) {
|
||||
if s.read == nil {
|
||||
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")
|
||||
return
|
||||
}
|
||||
userID, err := parseInt64(r.PathValue("user_id"))
|
||||
if err != nil || userID <= 0 {
|
||||
writeAPIError(w, http.StatusBadRequest, "invalid user_id")
|
||||
return
|
||||
}
|
||||
detail, err := s.read.AccountRatingDetail(r.Context(), userID)
|
||||
if err != nil {
|
||||
if errors.Is(err, errReadNotFound) {
|
||||
writeAPIError(w, http.StatusNotFound, "account rating not found")
|
||||
return
|
||||
}
|
||||
writeAPIError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"rating": detail.Rating,
|
||||
"events": detail.Events,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *server) commandMetaFromAPI(r *http.Request, commandID, reason string, confirm bool, prefix string) admin.CommandMeta {
|
||||
commandID = strings.TrimSpace(commandID)
|
||||
if confirm && strings.HasPrefix(commandID, "dry-") {
|
||||
|
|
@ -1570,6 +1988,42 @@ func (s *server) callAdminAPI(ctx context.Context, apiPath string, payload any)
|
|||
return result, nil
|
||||
}
|
||||
|
||||
// callAdminCommand is callAdminAPI with the upstream status preserved.
|
||||
//
|
||||
// callAdminAPI deliberately loses it: every caller it has answers 502 for any
|
||||
// failure. A verification decision needs the distinction, so this variant returns
|
||||
// the HTTP status alongside the result and lets the handler map it. A status of 0
|
||||
// means no HTTP answer was obtained at all.
|
||||
func (s *server) callAdminCommand(ctx context.Context, apiPath string, payload any) (admin.CommandResult, int, error) {
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return admin.CommandResult{}, 0, err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.cfg.AdminAPIURL+apiPath, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return admin.CommandResult{}, 0, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+s.cfg.AdminAPIToken)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return admin.CommandResult{}, 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
var result admin.CommandResult
|
||||
if err := json.Unmarshal(raw, &result); err != nil {
|
||||
return result, 0, fmt.Errorf("admin api %s: status=%d body=%s", apiPath, resp.StatusCode, string(raw))
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
if result.Error == "" {
|
||||
result.Error = resp.Status
|
||||
}
|
||||
return result, resp.StatusCode, errors.New(result.Error)
|
||||
}
|
||||
return result, resp.StatusCode, nil
|
||||
}
|
||||
|
||||
func (s *server) callAdminMultipart(ctx context.Context, apiPath string, metadata any, fileName string, data []byte) (admin.CommandResult, error) {
|
||||
var body bytes.Buffer
|
||||
writer := multipart.NewWriter(&body)
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package main
|
|||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
|
|
@ -13,10 +14,29 @@ import (
|
|||
|
||||
const sessionCookieName = "telesrv_admin_session"
|
||||
|
||||
// csrfCookieName is the double-submit cookie. It is deliberately NOT HttpOnly:
|
||||
// the panel's own JavaScript has to read it back to echo it in the X-CSRF-Token
|
||||
// header, which is the whole mechanism.
|
||||
const csrfCookieName = "telesrv_admin_csrf"
|
||||
|
||||
// csrfHeaderName is the header the panel echoes the cookie in.
|
||||
const csrfHeaderName = "X-CSRF-Token"
|
||||
|
||||
type sessionClaims struct {
|
||||
Actor string `json:"actor"`
|
||||
Exp int64 `json:"exp"`
|
||||
Nonce string `json:"nonce"`
|
||||
// Permissions is the right set granted to this session, taken from
|
||||
// TELESRV_ADMIN_UI_PERMISSIONS at login. It travels inside the signed cookie
|
||||
// rather than being re-read per request, so a session keeps the rights it was
|
||||
// issued with, and it cannot be edited by the browser: the HMAC covers it.
|
||||
Permissions []string `json:"permissions,omitempty"`
|
||||
// CSRF is the double-submit token bound to this session. Binding it into the
|
||||
// signed claims is what makes the cookie/header pair unforgeable by a sibling
|
||||
// origin that can only *write* cookies (a subdomain, say): such an attacker
|
||||
// can set both the cookie and the header to a value they know, but they cannot
|
||||
// produce a session cookie that agrees with it.
|
||||
CSRF string `json:"csrf,omitempty"`
|
||||
}
|
||||
|
||||
func signSession(key []byte, claims sessionClaims) (string, error) {
|
||||
|
|
@ -56,6 +76,28 @@ func verifySession(key []byte, value string, now time.Time) (sessionClaims, bool
|
|||
return claims, true
|
||||
}
|
||||
|
||||
// newCSRFToken mints a fresh double-submit token.
|
||||
func newCSRFToken() (string, error) {
|
||||
var raw [32]byte
|
||||
if _, err := rand.Read(raw[:]); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(raw[:]), nil
|
||||
}
|
||||
|
||||
// setCSRFCookie publishes the token to the browser.
|
||||
func setCSRFCookie(w http.ResponseWriter, token string, ttl time.Duration) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: csrfCookieName,
|
||||
Value: token,
|
||||
Path: "/",
|
||||
MaxAge: int(ttl.Seconds()),
|
||||
// Readable by the panel's script on purpose; see csrfCookieName.
|
||||
HttpOnly: false,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
}
|
||||
|
||||
func clearSessionCookie(w http.ResponseWriter) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: sessionCookieName,
|
||||
|
|
@ -65,4 +107,12 @@ func clearSessionCookie(w http.ResponseWriter) {
|
|||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: csrfCookieName,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
MaxAge: -1,
|
||||
HttpOnly: false,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -216,3 +216,214 @@ func TestSetStarGiftEnabledBFFForwardsExactInt64(t *testing.T) {
|
|||
t.Fatalf("forwarded gift request = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMintCollectibleUsernameBFFForwardsActorAndTolerantScalars(t *testing.T) {
|
||||
const maxInt64 = int64(9223372036854775807)
|
||||
var got admin.MintCollectibleUsernameRequest
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v1/collectible-usernames/mint" || r.Header.Get("Authorization") != "Bearer secret" {
|
||||
t.Fatalf("upstream request path=%q authorization=%q", r.URL.Path, r.Header.Get("Authorization"))
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(admin.CommandResult{CommandID: got.CommandID, Status: "completed", DryRun: got.DryRun})
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
srv := &server{cfg: uiConfig{AdminAPIURL: upstream.URL, AdminAPIToken: "secret"}}
|
||||
// The panel sends a picker id as a number, a nanoton amount as a string and an
|
||||
// RFC3339 purchase date; all three have to survive the hop unchanged.
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/actions/mint-collectible-username", strings.NewReader(`{
|
||||
"reason":"fragment import","confirm":false,
|
||||
"username":"@Durov","owner_user_id":1001,"currency":"TON",
|
||||
"amount":"9223372036854775807","crypto_currency":"TON","crypto_amount":"250000000000",
|
||||
"url":"https://fragment.example/durov","purchase_date":"2026-07-26T00:00:00Z"
|
||||
}`))
|
||||
req = req.WithContext(context.WithValue(req.Context(), actorKey{}, "operator"))
|
||||
rec := httptest.NewRecorder()
|
||||
srv.handleMintCollectibleUsernameAPI(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if got.Actor != "operator" || !got.DryRun || got.CommandID == "" {
|
||||
t.Fatalf("forwarded command meta = %+v", got.CommandMeta)
|
||||
}
|
||||
if got.Username != "@Durov" || got.OwnerUserID != 1001 || got.Amount != maxInt64 ||
|
||||
got.CryptoAmount != 250000000000 || got.PurchaseDate != time.Date(2026, 7, 26, 0, 0, 0, 0, time.UTC).Unix() {
|
||||
t.Fatalf("forwarded mint request = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdjustAccountRatingBFFForwardsNumericPayload(t *testing.T) {
|
||||
var got admin.AdjustAccountRatingRequest
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v1/account-ratings/adjust" {
|
||||
t.Fatalf("upstream path=%q", r.URL.Path)
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(admin.CommandResult{CommandID: got.CommandID, Status: "completed"})
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
srv := &server{cfg: uiConfig{AdminAPIURL: upstream.URL, AdminAPIToken: "secret"}}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/actions/adjust-account-rating", strings.NewReader(
|
||||
`{"reason":"manual penalty","confirm":true,"user_id":1001,"amount":-2500}`))
|
||||
req = req.WithContext(context.WithValue(req.Context(), actorKey{}, "operator"))
|
||||
rec := httptest.NewRecorder()
|
||||
srv.handleAdjustAccountRatingAPI(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if got.Actor != "operator" || got.UserID != 1001 || got.Amount != -2500 || got.DryRun {
|
||||
t.Fatalf("forwarded adjust request = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevokeCollectibleUsernameBFFRejectsUnknownFields(t *testing.T) {
|
||||
srv := &server{cfg: uiConfig{AdminAPIURL: "http://127.0.0.1:1", AdminAPIToken: "secret"}}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/actions/revoke-collectible-username", strings.NewReader(
|
||||
`{"reason":"fraud","confirm":true,"username":"durov","burn":true,"actor":"attacker"}`))
|
||||
req = req.WithContext(context.WithValue(req.Context(), actorKey{}, "operator"))
|
||||
rec := httptest.NewRecorder()
|
||||
srv.handleRevokeCollectibleUsernameAPI(rec, req)
|
||||
if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "actor") {
|
||||
t.Fatalf("status=%d body=%s, want 400 rejecting the unknown actor field", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectibleUsernameAndRatingRowsJSONPreserveInt64AsDecimalStrings(t *testing.T) {
|
||||
const maxInt64 = int64(9223372036854775807)
|
||||
raw, err := json.Marshal(CollectibleUsernameRow{
|
||||
ID: maxInt64, OwnerPeerID: maxInt64, Amount: maxInt64, CryptoAmount: maxInt64,
|
||||
OriginalOwnerPeerID: maxInt64, Version: maxInt64,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal collectible username row: %v", err)
|
||||
}
|
||||
var asset map[string]any
|
||||
if err := json.Unmarshal(raw, &asset); err != nil {
|
||||
t.Fatalf("unmarshal collectible username row: %v", err)
|
||||
}
|
||||
for _, field := range []string{"ID", "OwnerPeerID", "Amount", "CryptoAmount", "OriginalOwnerPeerID", "Version"} {
|
||||
if asset[field] != "9223372036854775807" {
|
||||
t.Fatalf("asset %s = %#v, want exact decimal string", field, asset[field])
|
||||
}
|
||||
}
|
||||
|
||||
raw, err = json.Marshal(AccountRatingRow{
|
||||
UserID: maxInt64, Stars: maxInt64, CurrentLevelStars: maxInt64, NextLevelStars: maxInt64,
|
||||
StarsComponent: maxInt64, ActivityComponent: maxInt64, PenaltyComponent: maxInt64,
|
||||
ManualComponent: -maxInt64, PendingStars: maxInt64, Version: maxInt64,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal account rating row: %v", err)
|
||||
}
|
||||
var rating map[string]any
|
||||
if err := json.Unmarshal(raw, &rating); err != nil {
|
||||
t.Fatalf("unmarshal account rating row: %v", err)
|
||||
}
|
||||
for _, field := range []string{
|
||||
"UserID", "Stars", "CurrentLevelStars", "NextLevelStars",
|
||||
"StarsComponent", "ActivityComponent", "PenaltyComponent", "PendingStars", "Version",
|
||||
} {
|
||||
if rating[field] != "9223372036854775807" {
|
||||
t.Fatalf("rating %s = %#v, want exact decimal string", field, rating[field])
|
||||
}
|
||||
}
|
||||
if rating["ManualComponent"] != "-9223372036854775807" {
|
||||
t.Fatalf("rating ManualComponent = %#v, want signed decimal string", rating["ManualComponent"])
|
||||
}
|
||||
|
||||
transfer, err := json.Marshal(CollectibleUsernameTransferRow{
|
||||
ID: maxInt64, CollectibleID: maxInt64, FromPeerID: maxInt64, ToPeerID: maxInt64, Amount: maxInt64,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal transfer row: %v", err)
|
||||
}
|
||||
var log map[string]any
|
||||
if err := json.Unmarshal(transfer, &log); err != nil {
|
||||
t.Fatalf("unmarshal transfer row: %v", err)
|
||||
}
|
||||
for _, field := range []string{"ID", "CollectibleID", "FromPeerID", "ToPeerID", "Amount"} {
|
||||
if log[field] != "9223372036854775807" {
|
||||
t.Fatalf("transfer %s = %#v, want exact decimal string", field, log[field])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlexScalarsAcceptNumbersStringsAndBlanks(t *testing.T) {
|
||||
var body mintCollectibleUsernameAPIRequest
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/actions/mint-collectible-username", strings.NewReader(`{
|
||||
"username":"durov","currency":"XTR","amount":"","owner_user_id":null,
|
||||
"crypto_amount":"9223372036854775807","purchase_date":"2026-07-26"
|
||||
}`))
|
||||
if err := decodeJSON(req, &body); err != nil {
|
||||
t.Fatalf("decode mint action: %v", err)
|
||||
}
|
||||
if body.Amount.Int64() != 0 || body.OwnerUserID.Int64() != 0 ||
|
||||
body.CryptoAmount.Int64() != 9223372036854775807 ||
|
||||
body.PurchaseDate.Unix() != time.Date(2026, 7, 26, 0, 0, 0, 0, time.UTC).Unix() {
|
||||
t.Fatalf("decoded mint action = %+v", body)
|
||||
}
|
||||
|
||||
var rating adjustAccountRatingAPIRequest
|
||||
numeric := httptest.NewRequest(http.MethodPost, "/api/actions/adjust-account-rating", strings.NewReader(
|
||||
`{"user_id":1001,"amount":-2500}`))
|
||||
if err := decodeJSON(numeric, &rating); err != nil {
|
||||
t.Fatalf("decode adjust action: %v", err)
|
||||
}
|
||||
if rating.UserID.Int64() != 1001 || rating.Amount.Int64() != -2500 {
|
||||
t.Fatalf("decoded adjust action = %+v", rating)
|
||||
}
|
||||
|
||||
var broken adjustAccountRatingAPIRequest
|
||||
invalid := httptest.NewRequest(http.MethodPost, "/api/actions/adjust-account-rating", strings.NewReader(
|
||||
`{"user_id":"not-a-number"}`))
|
||||
if err := decodeJSON(invalid, &broken); err == nil {
|
||||
t.Fatal("decoded a non-numeric user_id")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewCollectibleAndRatingRoutesRequireSession(t *testing.T) {
|
||||
srv, err := newServer(uiConfig{SessionKey: []byte("01234567890123456789012345678901")}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("newServer: %v", err)
|
||||
}
|
||||
cases := []struct {
|
||||
method string
|
||||
path string
|
||||
}{
|
||||
{http.MethodGet, "/api/collectible-usernames"},
|
||||
{http.MethodGet, "/api/collectible-usernames/7"},
|
||||
{http.MethodGet, "/api/account-ratings"},
|
||||
{http.MethodGet, "/api/account-ratings/7"},
|
||||
{http.MethodPost, "/api/actions/mint-collectible-username"},
|
||||
{http.MethodPost, "/api/actions/transfer-collectible-username"},
|
||||
{http.MethodPost, "/api/actions/revoke-collectible-username"},
|
||||
{http.MethodPost, "/api/actions/recompute-account-rating"},
|
||||
{http.MethodPost, "/api/actions/adjust-account-rating"},
|
||||
}
|
||||
for _, item := range cases {
|
||||
req := httptest.NewRequest(item.method, item.path, strings.NewReader(`{}`))
|
||||
rec := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("%s %s status=%d, want 401", item.method, item.path, rec.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEscapeLikePatternKeepsUsernameSearchLiteral(t *testing.T) {
|
||||
if got := escapeLikePattern("crypto_king"); got != `crypto\_king` {
|
||||
t.Fatalf("escapeLikePattern underscore = %q", got)
|
||||
}
|
||||
if got := escapeLikePattern(`100%_\x`); got != `100\%\_\\x` {
|
||||
t.Fatalf("escapeLikePattern metacharacters = %q", got)
|
||||
}
|
||||
if got := escapeLikePattern(""); got != "" {
|
||||
t.Fatalf("escapeLikePattern empty = %q", got)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
262
cmd/telesrv-admin/verification.go
Normal file
262
cmd/telesrv-admin/verification.go
Normal file
|
|
@ -0,0 +1,262 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"telesrv/internal/admin"
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// Official platform verification in the panel BFF.
|
||||
//
|
||||
// Reads come straight from PostgreSQL, like every other table view, so the queue
|
||||
// pages without a hop through the admin API and the applicant can be resolved by
|
||||
// a join. Decisions go the other way -- always through the admin API, so the
|
||||
// command journal, the status machine and the optimistic lock are enforced in one
|
||||
// place and a panel action is indistinguishable from an API one in the audit
|
||||
// trail.
|
||||
|
||||
// verificationRead mounts a route behind a session and the verification.review
|
||||
// right.
|
||||
func (s *server) verificationRead(handler http.HandlerFunc) http.Handler {
|
||||
return s.requireAuthAPI(s.requirePermission(permissionVerificationReview, handler))
|
||||
}
|
||||
|
||||
// handleVerificationApplicationsAPI pages the review queue. The filter is
|
||||
// validated before the read store is consulted: a malformed query is a 400
|
||||
// whether or not the database happens to be reachable.
|
||||
func (s *server) handleVerificationApplicationsAPI(w http.ResponseWriter, r *http.Request) {
|
||||
query := r.URL.Query()
|
||||
status := strings.TrimSpace(query.Get("status"))
|
||||
if status != "" && !domain.VerificationStatus(status).Valid() {
|
||||
writeAPIError(w, http.StatusBadRequest, "invalid status")
|
||||
return
|
||||
}
|
||||
targetType := strings.TrimSpace(query.Get("target_type"))
|
||||
if targetType != "" && !domain.VerificationTargetType(targetType).Valid() {
|
||||
writeAPIError(w, http.StatusBadRequest, "invalid target_type")
|
||||
return
|
||||
}
|
||||
beforeID, err := parseInt64(query.Get("before_id"))
|
||||
if err != nil || beforeID < 0 {
|
||||
writeAPIError(w, http.StatusBadRequest, "invalid before_id")
|
||||
return
|
||||
}
|
||||
limit, err := parseInt(query.Get("limit"))
|
||||
if err != nil || limit < 0 {
|
||||
writeAPIError(w, http.StatusBadRequest, "invalid limit")
|
||||
return
|
||||
}
|
||||
if s.read == nil {
|
||||
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")
|
||||
return
|
||||
}
|
||||
rows, hasMore, err := s.read.ListVerificationApplications(
|
||||
r.Context(), status, targetType, strings.TrimSpace(query.Get("reviewer")), query.Get("q"), beforeID, limit,
|
||||
)
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
nextBeforeID := ""
|
||||
if hasMore && len(rows) > 0 {
|
||||
nextBeforeID = strconv.FormatInt(rows[len(rows)-1].ID, 10)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"rows": rows,
|
||||
"has_more": hasMore,
|
||||
"next_before_id": nextBeforeID,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *server) handleVerificationApplicationDetailAPI(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := parseInt64(r.PathValue("id"))
|
||||
if err != nil || id <= 0 {
|
||||
writeAPIError(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
if s.read == nil {
|
||||
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")
|
||||
return
|
||||
}
|
||||
detail, err := s.read.VerificationApplicationDetail(r.Context(), id)
|
||||
if err != nil {
|
||||
if errors.Is(err, errReadNotFound) {
|
||||
writeAPIError(w, http.StatusNotFound, "verification application not found")
|
||||
return
|
||||
}
|
||||
writeAPIError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"application": detail.Application,
|
||||
"events": detail.Events,
|
||||
// Both flags describe the target as it is now, not as it was at
|
||||
// submission: a reviewer has to see that the applicant lost control of the
|
||||
// peer, or that the badge is already on, before deciding.
|
||||
"applicant_controls_target": detail.ApplicantControlsTarget,
|
||||
"target_verified": detail.Application.TargetVerified,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *server) handleVerificationCountsAPI(w http.ResponseWriter, r *http.Request) {
|
||||
if s.read == nil {
|
||||
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")
|
||||
return
|
||||
}
|
||||
counts, err := s.read.VerificationStatusCounts(r.Context())
|
||||
if err != nil {
|
||||
writeAPIError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"counts": counts})
|
||||
}
|
||||
|
||||
// verificationDecisionAPIRequest is the decision payload shared by all three
|
||||
// per-application actions. version is the optimistic-locking token the reviewer
|
||||
// read; internal_note is operator-only and is not part of what the applicant is
|
||||
// told. It is optional everywhere, including on a claim, so one panel form can
|
||||
// drive all three actions without tripping the strict decoder.
|
||||
type verificationDecisionAPIRequest struct {
|
||||
CommandID string `json:"command_id"`
|
||||
Reason string `json:"reason"`
|
||||
Confirm bool `json:"confirm"`
|
||||
Version flexInt64 `json:"version"`
|
||||
InternalNote string `json:"internal_note"`
|
||||
}
|
||||
|
||||
func (s *server) handleClaimVerificationAPI(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := verificationPathID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var body verificationDecisionAPIRequest
|
||||
if !decodeAction(w, r, &body) {
|
||||
return
|
||||
}
|
||||
req := admin.ClaimVerificationRequest{
|
||||
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "claim-verification"),
|
||||
ApplicationID: id,
|
||||
Version: body.Version.Int64(),
|
||||
InternalNote: body.InternalNote,
|
||||
}
|
||||
result, status, err := s.callAdminCommand(r.Context(), verificationDecisionPath(id, "claim"), req)
|
||||
writeVerificationResultAPI(w, result, status, err)
|
||||
}
|
||||
|
||||
func (s *server) handleApproveVerificationAPI(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := verificationPathID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var body verificationDecisionAPIRequest
|
||||
if !decodeAction(w, r, &body) {
|
||||
return
|
||||
}
|
||||
req := admin.ApproveVerificationRequest{
|
||||
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "approve-verification"),
|
||||
ApplicationID: id,
|
||||
Version: body.Version.Int64(),
|
||||
InternalNote: body.InternalNote,
|
||||
}
|
||||
result, status, err := s.callAdminCommand(r.Context(), verificationDecisionPath(id, "approve"), req)
|
||||
writeVerificationResultAPI(w, result, status, err)
|
||||
}
|
||||
|
||||
func (s *server) handleRejectVerificationAPI(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := verificationPathID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var body verificationDecisionAPIRequest
|
||||
if !decodeAction(w, r, &body) {
|
||||
return
|
||||
}
|
||||
req := admin.RejectVerificationRequest{
|
||||
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "reject-verification"),
|
||||
ApplicationID: id,
|
||||
Version: body.Version.Int64(),
|
||||
InternalNote: body.InternalNote,
|
||||
}
|
||||
result, status, err := s.callAdminCommand(r.Context(), verificationDecisionPath(id, "reject"), req)
|
||||
writeVerificationResultAPI(w, result, status, err)
|
||||
}
|
||||
|
||||
// revokeVerificationAPIRequest clears a badge. It addresses the target, not an
|
||||
// application: the approved application stays approved as history.
|
||||
type revokeVerificationAPIRequest struct {
|
||||
CommandID string `json:"command_id"`
|
||||
Reason string `json:"reason"`
|
||||
Confirm bool `json:"confirm"`
|
||||
TargetType string `json:"target_type"`
|
||||
TargetID flexInt64 `json:"target_id"`
|
||||
InternalNote string `json:"internal_note"`
|
||||
}
|
||||
|
||||
func (s *server) handleRevokeVerificationAPI(w http.ResponseWriter, r *http.Request) {
|
||||
var body revokeVerificationAPIRequest
|
||||
if !decodeAction(w, r, &body) {
|
||||
return
|
||||
}
|
||||
targetType := domain.VerificationTargetType(strings.TrimSpace(body.TargetType))
|
||||
if !targetType.Valid() {
|
||||
writeAPIError(w, http.StatusBadRequest, "invalid target_type")
|
||||
return
|
||||
}
|
||||
if body.TargetID.Int64() <= 0 {
|
||||
writeAPIError(w, http.StatusBadRequest, "invalid target_id")
|
||||
return
|
||||
}
|
||||
req := admin.RevokeVerificationRequest{
|
||||
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "revoke-verification"),
|
||||
TargetType: targetType,
|
||||
TargetID: body.TargetID.Int64(),
|
||||
InternalNote: body.InternalNote,
|
||||
}
|
||||
result, status, err := s.callAdminCommand(r.Context(), "/v1/verification/revoke", req)
|
||||
writeVerificationResultAPI(w, result, status, err)
|
||||
}
|
||||
|
||||
func verificationPathID(w http.ResponseWriter, r *http.Request) (int64, bool) {
|
||||
id, err := parseInt64(r.PathValue("id"))
|
||||
if err != nil || id <= 0 {
|
||||
writeAPIError(w, http.StatusBadRequest, "invalid id")
|
||||
return 0, false
|
||||
}
|
||||
return id, true
|
||||
}
|
||||
|
||||
func verificationDecisionPath(applicationID int64, action string) string {
|
||||
return "/v1/verification/applications/" + strconv.FormatInt(applicationID, 10) + "/" + action
|
||||
}
|
||||
|
||||
// writeVerificationResultAPI relays the admin API's own status to the browser.
|
||||
//
|
||||
// The other action handlers flatten every upstream failure into 502, which is
|
||||
// fine when the only failure mode is "bad request". A verification decision has
|
||||
// one more: 409 when another reviewer decided first. That has to reach the panel
|
||||
// as 409, because it is the single case the panel resolves by reloading the
|
||||
// application rather than by asking the operator to change something.
|
||||
func writeVerificationResultAPI(w http.ResponseWriter, result admin.CommandResult, status int, err error) {
|
||||
if err == nil {
|
||||
writeJSON(w, http.StatusOK, result)
|
||||
return
|
||||
}
|
||||
if result.Status == "" {
|
||||
result.Status = "failed"
|
||||
}
|
||||
if result.Message == "" {
|
||||
result.Message = "command failed"
|
||||
}
|
||||
if result.Error == "" {
|
||||
result.Error = err.Error()
|
||||
}
|
||||
if status < 400 {
|
||||
// No HTTP answer at all: the admin API was unreachable or unparsable.
|
||||
status = http.StatusBadGateway
|
||||
}
|
||||
writeJSON(w, status, result)
|
||||
}
|
||||
690
cmd/telesrv-admin/verification_test.go
Normal file
690
cmd/telesrv-admin/verification_test.go
Normal file
|
|
@ -0,0 +1,690 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/admin"
|
||||
)
|
||||
|
||||
const testSessionKey = "01234567890123456789012345678901"
|
||||
|
||||
// panelServer builds a BFF whose sessions carry the given permissions.
|
||||
func panelServer(t *testing.T, permissions ...string) *server {
|
||||
t.Helper()
|
||||
srv, err := newServer(uiConfig{
|
||||
SessionKey: []byte(testSessionKey),
|
||||
Password: "letmein",
|
||||
Permissions: permissions,
|
||||
}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("newServer: %v", err)
|
||||
}
|
||||
return srv
|
||||
}
|
||||
|
||||
// signIn performs a real login against the routed server and returns the cookies
|
||||
// plus the CSRF token the panel would echo, so the tests exercise the same pairing
|
||||
// the browser gets.
|
||||
func signIn(t *testing.T, srv *server) ([]*http.Cookie, string) {
|
||||
t.Helper()
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/login", strings.NewReader(`{"secret":"letmein"}`))
|
||||
srv.routes().ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("login status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var body struct {
|
||||
Actor string `json:"actor"`
|
||||
Permissions []string `json:"permissions"`
|
||||
CSRFToken string `json:"csrf_token"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("decode login: %v", err)
|
||||
}
|
||||
if body.CSRFToken == "" {
|
||||
t.Fatal("login did not mint a csrf token")
|
||||
}
|
||||
cookies := rec.Result().Cookies()
|
||||
var sawCSRFCookie bool
|
||||
for _, cookie := range cookies {
|
||||
if cookie.Name != csrfCookieName {
|
||||
continue
|
||||
}
|
||||
sawCSRFCookie = true
|
||||
if cookie.HttpOnly {
|
||||
t.Fatal("csrf cookie is HttpOnly; the panel could not read it back")
|
||||
}
|
||||
if cookie.Value != body.CSRFToken || cookie.Path != "/" || cookie.SameSite != http.SameSiteLaxMode {
|
||||
t.Fatalf("csrf cookie=%+v", cookie)
|
||||
}
|
||||
}
|
||||
if !sawCSRFCookie {
|
||||
t.Fatal("login did not set the csrf cookie")
|
||||
}
|
||||
return cookies, body.CSRFToken
|
||||
}
|
||||
|
||||
func withCookies(req *http.Request, cookies []*http.Cookie) *http.Request {
|
||||
for _, cookie := range cookies {
|
||||
req.AddCookie(cookie)
|
||||
}
|
||||
return req
|
||||
}
|
||||
|
||||
func TestPanelSessionReportsPermissions(t *testing.T) {
|
||||
srv := panelServer(t, permissionVerificationReview)
|
||||
cookies, _ := signIn(t, srv)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, withCookies(httptest.NewRequest(http.MethodGet, "/api/session", nil), cookies))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("session status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var body struct {
|
||||
Actor string `json:"actor"`
|
||||
Permissions []string `json:"permissions"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("decode session: %v", err)
|
||||
}
|
||||
if body.Actor != "admin" || len(body.Permissions) != 1 || body.Permissions[0] != permissionVerificationReview {
|
||||
t.Fatalf("session=%+v, want the granted permissions reported to the panel", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPanelSessionReportsTheWildcardDefault(t *testing.T) {
|
||||
// The shipped default is the wildcard, so an operator upgrading into the
|
||||
// permission model keeps every section.
|
||||
srv := panelServer(t, permissionAll)
|
||||
cookies, _ := signIn(t, srv)
|
||||
rec := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, withCookies(httptest.NewRequest(http.MethodGet, "/api/session", nil), cookies))
|
||||
if !strings.Contains(rec.Body.String(), `"*"`) {
|
||||
t.Fatalf("session body=%s, want the wildcard reported", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMutatingRequestsRequireTheCSRFHeader(t *testing.T) {
|
||||
srv := panelServer(t, permissionAll)
|
||||
cookies, token := signIn(t, srv)
|
||||
const path = "/api/actions/set-verified"
|
||||
const payload = `{"reason":"official","confirm":false,"user_id":1001,"verified":true}`
|
||||
|
||||
// No header at all.
|
||||
rec := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, withCookies(httptest.NewRequest(http.MethodPost, path, strings.NewReader(payload)), cookies))
|
||||
if rec.Code != http.StatusForbidden || !strings.Contains(rec.Body.String(), csrfHeaderName) {
|
||||
t.Fatalf("missing header status=%d body=%s, want 403", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// A header that does not match the cookie.
|
||||
rec = httptest.NewRecorder()
|
||||
req := withCookies(httptest.NewRequest(http.MethodPost, path, strings.NewReader(payload)), cookies)
|
||||
req.Header.Set(csrfHeaderName, token+"-tampered")
|
||||
srv.routes().ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("mismatched header status=%d body=%s, want 403", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// A matching header from a different session's token: it agrees with the
|
||||
// cookie the attacker planted but not with the signed session.
|
||||
otherSrv := panelServer(t, permissionAll)
|
||||
_, otherToken := signIn(t, otherSrv)
|
||||
rec = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodPost, path, strings.NewReader(payload))
|
||||
for _, cookie := range cookies {
|
||||
if cookie.Name == sessionCookieName {
|
||||
req.AddCookie(cookie)
|
||||
}
|
||||
}
|
||||
req.AddCookie(&http.Cookie{Name: csrfCookieName, Value: otherToken})
|
||||
req.Header.Set(csrfHeaderName, otherToken)
|
||||
srv.routes().ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusForbidden || !strings.Contains(rec.Body.String(), "not bound to this session") {
|
||||
t.Fatalf("foreign token status=%d body=%s, want 403", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// A session minted before the CSRF token existed is refused rather than left
|
||||
// half protected.
|
||||
legacy, err := signSession([]byte(testSessionKey), sessionClaims{
|
||||
Actor: "admin", Exp: time.Now().Add(time.Hour).Unix(), Nonce: "n",
|
||||
Permissions: []string{permissionAll},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("signSession: %v", err)
|
||||
}
|
||||
rec = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodPost, path, strings.NewReader(payload))
|
||||
req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: legacy})
|
||||
req.AddCookie(&http.Cookie{Name: csrfCookieName, Value: "anything"})
|
||||
req.Header.Set(csrfHeaderName, "anything")
|
||||
srv.routes().ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("pre-CSRF session status=%d body=%s, want 403", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCSRFProtectionCoversEveryExistingMutatingRoute(t *testing.T) {
|
||||
srv := panelServer(t, permissionAll)
|
||||
cookies, _ := signIn(t, srv)
|
||||
// A representative slice of the routes that predate CSRF: they must all be
|
||||
// closed, not just the new ones.
|
||||
for _, path := range []string{
|
||||
"/api/logout",
|
||||
"/api/actions/set-frozen",
|
||||
"/api/actions/grant-stars",
|
||||
"/api/actions/delete-bot",
|
||||
"/api/actions/revoke-collectible-username",
|
||||
"/api/actions/adjust-account-rating",
|
||||
"/api/moderation/cases/7/claim",
|
||||
"/api/verification/applications/7/approve",
|
||||
"/api/actions/revoke-verification",
|
||||
} {
|
||||
rec := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, withCookies(httptest.NewRequest(http.MethodPost, path, strings.NewReader(`{}`)), cookies))
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("%s status=%d body=%s, want 403 without a csrf header", path, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadRequestsDoNotNeedTheCSRFHeader(t *testing.T) {
|
||||
srv := panelServer(t, permissionAll)
|
||||
cookies, _ := signIn(t, srv)
|
||||
rec := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, withCookies(httptest.NewRequest(http.MethodGet, "/api/session", nil), cookies))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("GET status=%d body=%s, want a token-free read", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestForeignOriginIsRefusedEvenWithAValidToken(t *testing.T) {
|
||||
srv := panelServer(t, permissionAll)
|
||||
cookies, token := signIn(t, srv)
|
||||
req := withCookies(httptest.NewRequest(http.MethodPost, "/api/actions/set-verified", strings.NewReader(
|
||||
`{"reason":"official","confirm":false,"user_id":1001,"verified":true}`)), cookies)
|
||||
req.Header.Set(csrfHeaderName, token)
|
||||
req.Header.Set("Origin", "https://evil.example")
|
||||
rec := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusForbidden || !strings.Contains(rec.Body.String(), "origin") {
|
||||
t.Fatalf("foreign origin status=%d body=%s, want 403", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// The panel's own origin is accepted.
|
||||
if !sameOriginRequest(originRequest("https://panel.example", "panel.example")) {
|
||||
t.Fatal("same origin refused")
|
||||
}
|
||||
// A missing Origin is accepted: browsers omit it and non-browser callers never
|
||||
// send it, and the token check still applies.
|
||||
if !sameOriginRequest(originRequest("", "panel.example")) {
|
||||
t.Fatal("absent origin refused")
|
||||
}
|
||||
// An opaque origin is not this host.
|
||||
if sameOriginRequest(originRequest("null", "panel.example")) {
|
||||
t.Fatal("opaque origin accepted")
|
||||
}
|
||||
if sameOriginRequest(originRequest("not a url", "panel.example")) {
|
||||
t.Fatal("unparsable origin accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func originRequest(origin, host string) *http.Request {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/actions/set-verified", nil)
|
||||
req.Host = host
|
||||
if origin != "" {
|
||||
req.Header.Set("Origin", origin)
|
||||
}
|
||||
return req
|
||||
}
|
||||
|
||||
func TestLoginRefusesAForeignOrigin(t *testing.T) {
|
||||
srv := panelServer(t, permissionAll)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/login", strings.NewReader(`{"secret":"letmein"}`))
|
||||
req.Header.Set("Origin", "https://evil.example")
|
||||
rec := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("cross-origin login status=%d body=%s, want 403", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerificationRoutesRefuseASessionWithoutTheReviewRight(t *testing.T) {
|
||||
srv := panelServer(t, "gifts.import")
|
||||
cookies, token := signIn(t, srv)
|
||||
cases := []struct {
|
||||
method string
|
||||
path string
|
||||
body string
|
||||
}{
|
||||
{http.MethodGet, "/api/verification/applications", ""},
|
||||
{http.MethodGet, "/api/verification/applications/7", ""},
|
||||
{http.MethodGet, "/api/verification/counts", ""},
|
||||
{http.MethodPost, "/api/verification/applications/7/claim", `{}`},
|
||||
{http.MethodPost, "/api/verification/applications/7/approve", `{}`},
|
||||
{http.MethodPost, "/api/verification/applications/7/reject", `{}`},
|
||||
{http.MethodPost, "/api/actions/revoke-verification", `{}`},
|
||||
}
|
||||
for _, item := range cases {
|
||||
var req *http.Request
|
||||
if item.body == "" {
|
||||
req = httptest.NewRequest(item.method, item.path, nil)
|
||||
} else {
|
||||
req = httptest.NewRequest(item.method, item.path, strings.NewReader(item.body))
|
||||
req.Header.Set(csrfHeaderName, token)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, withCookies(req, cookies))
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("%s %s status=%d body=%s, want 403", item.method, item.path, rec.Code, rec.Body.String())
|
||||
}
|
||||
var body map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("decode 403 body: %v", err)
|
||||
}
|
||||
if body["code"] != "FORBIDDEN" || body["permission"] != permissionVerificationReview {
|
||||
t.Fatalf("%s 403 body=%+v, want the missing permission named", item.path, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevokeVerificationNeedsTheRevokeRightOnTopOfReview(t *testing.T) {
|
||||
srv := panelServer(t, permissionVerificationReview)
|
||||
cookies, token := signIn(t, srv)
|
||||
req := withCookies(httptest.NewRequest(http.MethodPost, "/api/actions/revoke-verification", strings.NewReader(
|
||||
`{"reason":"impersonation","confirm":true,"target_type":"channel","target_id":5005}`)), cookies)
|
||||
req.Header.Set(csrfHeaderName, token)
|
||||
rec := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("status=%d body=%s, want 403", rec.Code, rec.Body.String())
|
||||
}
|
||||
var body map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("decode 403 body: %v", err)
|
||||
}
|
||||
if body["permission"] != permissionVerificationRevoke {
|
||||
t.Fatalf("403 body=%+v, want verification.revoke named", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerificationRoutesRequireASession(t *testing.T) {
|
||||
srv := panelServer(t, permissionAll)
|
||||
cases := []struct {
|
||||
method string
|
||||
path string
|
||||
}{
|
||||
{http.MethodGet, "/api/verification/applications"},
|
||||
{http.MethodGet, "/api/verification/applications/7"},
|
||||
{http.MethodGet, "/api/verification/counts"},
|
||||
{http.MethodPost, "/api/verification/applications/7/claim"},
|
||||
{http.MethodPost, "/api/verification/applications/7/approve"},
|
||||
{http.MethodPost, "/api/verification/applications/7/reject"},
|
||||
{http.MethodPost, "/api/actions/revoke-verification"},
|
||||
}
|
||||
for _, item := range cases {
|
||||
rec := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, httptest.NewRequest(item.method, item.path, strings.NewReader(`{}`)))
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("%s %s status=%d, want 401", item.method, item.path, rec.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// verificationUpstream stands in for the admin API and records what the BFF sent.
|
||||
type verificationUpstream struct {
|
||||
path string
|
||||
raw []byte
|
||||
status int
|
||||
body any
|
||||
}
|
||||
|
||||
func (u *verificationUpstream) handler(t *testing.T) http.HandlerFunc {
|
||||
t.Helper()
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") != "Bearer api-secret" {
|
||||
t.Fatalf("upstream authorization=%q", r.Header.Get("Authorization"))
|
||||
}
|
||||
u.path = r.URL.Path
|
||||
defer r.Body.Close()
|
||||
raw, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("read upstream body: %v", err)
|
||||
}
|
||||
u.raw = raw
|
||||
status := u.status
|
||||
if status == 0 {
|
||||
status = http.StatusOK
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(u.body)
|
||||
}
|
||||
}
|
||||
|
||||
// requestWithActor stands in for the session middleware, which is what puts the
|
||||
// signed-in operator into the request context.
|
||||
func requestWithActor(r *http.Request, actor string) *http.Request {
|
||||
return r.WithContext(context.WithValue(r.Context(), actorKey{}, actor))
|
||||
}
|
||||
|
||||
func TestApproveVerificationBFFForwardsActorVersionAndNote(t *testing.T) {
|
||||
upstream := &verificationUpstream{body: admin.CommandResult{CommandID: "c1", Status: "completed"}}
|
||||
api := httptest.NewServer(upstream.handler(t))
|
||||
defer api.Close()
|
||||
|
||||
srv := &server{cfg: uiConfig{AdminAPIURL: api.URL, AdminAPIToken: "api-secret"}}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/verification/applications/77/approve", strings.NewReader(`{
|
||||
"reason":"press coverage verified","confirm":true,"version":"9223372036854775807",
|
||||
"internal_note":"contact came through the press office"
|
||||
}`))
|
||||
req.SetPathValue("id", "77")
|
||||
req = requestWithActor(req, "operator")
|
||||
rec := httptest.NewRecorder()
|
||||
srv.handleApproveVerificationAPI(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if upstream.path != "/v1/verification/applications/77/approve" {
|
||||
t.Fatalf("upstream path=%q", upstream.path)
|
||||
}
|
||||
var got admin.ApproveVerificationRequest
|
||||
if err := json.Unmarshal(upstream.raw, &got); err != nil {
|
||||
t.Fatalf("decode forwarded approval: %v (%s)", err, upstream.raw)
|
||||
}
|
||||
if got.Actor != "operator" {
|
||||
t.Fatalf("actor=%q, want the signed-in operator", got.Actor)
|
||||
}
|
||||
if got.ApplicationID != 77 || got.Version != 9223372036854775807 {
|
||||
t.Fatalf("forwarded approval=%+v, want the exact int64 version", got)
|
||||
}
|
||||
if got.InternalNote != "contact came through the press office" || got.DryRun {
|
||||
t.Fatalf("forwarded approval=%+v", got)
|
||||
}
|
||||
if got.CommandID == "" {
|
||||
t.Fatal("no command id was minted for the idempotency key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaimVerificationBFFDefaultsToADryRun(t *testing.T) {
|
||||
upstream := &verificationUpstream{body: admin.CommandResult{CommandID: "c1", Status: "completed", DryRun: true}}
|
||||
api := httptest.NewServer(upstream.handler(t))
|
||||
defer api.Close()
|
||||
|
||||
srv := &server{cfg: uiConfig{AdminAPIURL: api.URL, AdminAPIToken: "api-secret"}}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/verification/applications/77/claim", strings.NewReader(
|
||||
`{"reason":"queue sweep","confirm":false,"version":3}`))
|
||||
req.SetPathValue("id", "77")
|
||||
req = requestWithActor(req, "operator")
|
||||
rec := httptest.NewRecorder()
|
||||
srv.handleClaimVerificationAPI(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var got admin.ClaimVerificationRequest
|
||||
if err := json.Unmarshal(upstream.raw, &got); err != nil {
|
||||
t.Fatalf("decode forwarded claim: %v", err)
|
||||
}
|
||||
// confirm=false is a rehearsal: nothing may be written until the operator
|
||||
// confirms.
|
||||
if !got.DryRun || got.Version != 3 || got.ApplicationID != 77 {
|
||||
t.Fatalf("forwarded claim=%+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevokeVerificationBFFForwardsTargetAndRejectsBadShapes(t *testing.T) {
|
||||
upstream := &verificationUpstream{body: admin.CommandResult{CommandID: "c1", Status: "completed"}}
|
||||
api := httptest.NewServer(upstream.handler(t))
|
||||
defer api.Close()
|
||||
|
||||
srv := &server{cfg: uiConfig{AdminAPIURL: api.URL, AdminAPIToken: "api-secret"}}
|
||||
req := requestWithActor(httptest.NewRequest(http.MethodPost, "/api/actions/revoke-verification", strings.NewReader(`{
|
||||
"reason":"impersonation confirmed","confirm":true,"target_type":"channel",
|
||||
"target_id":"9223372036854775807","internal_note":"legal asked for it"
|
||||
}`)), "operator")
|
||||
rec := httptest.NewRecorder()
|
||||
srv.handleRevokeVerificationAPI(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if upstream.path != "/v1/verification/revoke" {
|
||||
t.Fatalf("upstream path=%q", upstream.path)
|
||||
}
|
||||
var got admin.RevokeVerificationRequest
|
||||
if err := json.Unmarshal(upstream.raw, &got); err != nil {
|
||||
t.Fatalf("decode forwarded revocation: %v", err)
|
||||
}
|
||||
if got.TargetID != 9223372036854775807 || got.TargetType != "channel" ||
|
||||
got.Actor != "operator" || got.InternalNote != "legal asked for it" || got.DryRun {
|
||||
t.Fatalf("forwarded revocation=%+v", got)
|
||||
}
|
||||
|
||||
for _, payload := range []string{
|
||||
`{"reason":"x","confirm":true,"target_type":"group","target_id":5}`,
|
||||
`{"reason":"x","confirm":true,"target_type":"channel","target_id":0}`,
|
||||
} {
|
||||
rec := httptest.NewRecorder()
|
||||
srv.handleRevokeVerificationAPI(rec, requestWithActor(
|
||||
httptest.NewRequest(http.MethodPost, "/api/actions/revoke-verification", strings.NewReader(payload)), "operator"))
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("payload %s status=%d body=%s, want 400", payload, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerificationDecisionRejectsUnknownFields(t *testing.T) {
|
||||
srv := &server{cfg: uiConfig{AdminAPIURL: "http://127.0.0.1:1", AdminAPIToken: "api-secret"}}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/verification/applications/77/approve", strings.NewReader(
|
||||
`{"reason":"ok","confirm":true,"version":3,"actor":"attacker"}`))
|
||||
req.SetPathValue("id", "77")
|
||||
req = requestWithActor(req, "operator")
|
||||
rec := httptest.NewRecorder()
|
||||
srv.handleApproveVerificationAPI(rec, req)
|
||||
if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "actor") {
|
||||
t.Fatalf("status=%d body=%s, want 400 rejecting the injected actor", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerificationVersionConflictReachesThePanelAs409(t *testing.T) {
|
||||
upstream := &verificationUpstream{
|
||||
status: http.StatusConflict,
|
||||
body: admin.CommandResult{
|
||||
CommandID: "c1", Status: "failed",
|
||||
Error: admin.CodeVerificationConflict + ": verification application changed concurrently",
|
||||
Message: "another reviewer changed this application first; reload it and decide again",
|
||||
},
|
||||
}
|
||||
api := httptest.NewServer(upstream.handler(t))
|
||||
defer api.Close()
|
||||
|
||||
srv := &server{cfg: uiConfig{AdminAPIURL: api.URL, AdminAPIToken: "api-secret"}}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/verification/applications/77/approve", strings.NewReader(
|
||||
`{"reason":"ok","confirm":true,"version":3}`))
|
||||
req.SetPathValue("id", "77")
|
||||
req = requestWithActor(req, "operator")
|
||||
rec := httptest.NewRecorder()
|
||||
srv.handleApproveVerificationAPI(rec, req)
|
||||
// A flattened 502 would hide the one failure the panel resolves by reloading.
|
||||
if rec.Code != http.StatusConflict {
|
||||
t.Fatalf("status=%d body=%s, want 409", rec.Code, rec.Body.String())
|
||||
}
|
||||
var result admin.CommandResult
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &result); err != nil {
|
||||
t.Fatalf("decode conflict: %v", err)
|
||||
}
|
||||
if !strings.Contains(result.Error, admin.CodeVerificationConflict) || !strings.Contains(result.Message, "reload") {
|
||||
t.Fatalf("relayed result=%+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerificationUnreachableAdminAPIIsABadGateway(t *testing.T) {
|
||||
srv := &server{cfg: uiConfig{AdminAPIURL: "http://127.0.0.1:1", AdminAPIToken: "api-secret"}}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/verification/applications/77/reject", strings.NewReader(
|
||||
`{"reason":"press links are self-published","confirm":true,"version":3}`))
|
||||
req.SetPathValue("id", "77")
|
||||
req = requestWithActor(req, "operator")
|
||||
rec := httptest.NewRecorder()
|
||||
srv.handleRejectVerificationAPI(rec, req)
|
||||
if rec.Code != http.StatusBadGateway {
|
||||
t.Fatalf("status=%d body=%s, want 502", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerificationRowsJSONPreserveInt64AsDecimalStrings(t *testing.T) {
|
||||
const maxInt64 = int64(9223372036854775807)
|
||||
raw, err := json.Marshal(VerificationApplicationRow{
|
||||
ID: maxInt64, ApplicantUserID: maxInt64, TargetID: maxInt64, Version: maxInt64,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal verification row: %v", err)
|
||||
}
|
||||
var application map[string]any
|
||||
if err := json.Unmarshal(raw, &application); err != nil {
|
||||
t.Fatalf("unmarshal verification row: %v", err)
|
||||
}
|
||||
for _, field := range []string{"ID", "ApplicantUserID", "TargetID", "Version"} {
|
||||
if application[field] != "9223372036854775807" {
|
||||
t.Fatalf("application %s = %#v, want an exact decimal string", field, application[field])
|
||||
}
|
||||
}
|
||||
|
||||
raw, err = json.Marshal(VerificationEventRow{ID: maxInt64})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal verification event row: %v", err)
|
||||
}
|
||||
var event map[string]any
|
||||
if err := json.Unmarshal(raw, &event); err != nil {
|
||||
t.Fatalf("unmarshal verification event row: %v", err)
|
||||
}
|
||||
if event["ID"] != "9223372036854775807" {
|
||||
t.Fatalf("event ID = %#v, want an exact decimal string", event["ID"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerificationQueryValidationRejectsUnmodelledFilters(t *testing.T) {
|
||||
srv := panelServer(t, permissionVerificationReview)
|
||||
cookies, _ := signIn(t, srv)
|
||||
for _, query := range []string{"?status=pending", "?target_type=group", "?before_id=-1", "?limit=abc"} {
|
||||
rec := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, withCookies(
|
||||
httptest.NewRequest(http.MethodGet, "/api/verification/applications"+query, nil), cookies))
|
||||
// The read store is absent in this fixture, so a rejected filter is a 400
|
||||
// and an accepted one would be a 503: either way the validation is proven.
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("%s status=%d body=%s, want 400", query, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, withCookies(
|
||||
httptest.NewRequest(http.MethodGet, "/api/verification/applications?status=submitted&target_type=channel", nil), cookies))
|
||||
if rec.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("valid filter status=%d body=%s, want the read store to be reached", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPanelPermissionsWildcardAndMembership(t *testing.T) {
|
||||
all := newPanelPermissions([]string{permissionAll})
|
||||
if !all.Has(permissionVerificationReview) || !all.Has(permissionVerificationRevoke) {
|
||||
t.Fatal("wildcard session refused a permission")
|
||||
}
|
||||
bounded := newPanelPermissions([]string{" verification.review ", "", "verification.review"})
|
||||
if !bounded.Has(permissionVerificationReview) || bounded.Has(permissionVerificationRevoke) {
|
||||
t.Fatalf("bounded session = %+v", bounded.List())
|
||||
}
|
||||
if len(bounded.List()) != 1 {
|
||||
t.Fatalf("bounded list=%+v, want the duplicate collapsed", bounded.List())
|
||||
}
|
||||
if got := newPanelPermissions(nil).List(); got == nil || len(got) != 0 {
|
||||
t.Fatalf("empty list=%#v, want an empty array rather than null", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The CSRF gate must let a correctly-tokened request through -- including on the
|
||||
// routes that predate it -- or the panel is simply broken rather than protected.
|
||||
func TestExistingMutatingRoutesStillWorkWithAValidToken(t *testing.T) {
|
||||
upstream := &verificationUpstream{body: admin.CommandResult{CommandID: "c1", Status: "completed", DryRun: true}}
|
||||
api := httptest.NewServer(upstream.handler(t))
|
||||
defer api.Close()
|
||||
|
||||
srv := panelServer(t, permissionAll)
|
||||
srv.cfg.AdminAPIURL = api.URL
|
||||
srv.cfg.AdminAPIToken = "api-secret"
|
||||
cookies, token := signIn(t, srv)
|
||||
|
||||
req := withCookies(httptest.NewRequest(http.MethodPost, "/api/actions/set-verified", strings.NewReader(
|
||||
`{"reason":"official","confirm":false,"user_id":1001,"verified":true}`)), cookies)
|
||||
req.Header.Set(csrfHeaderName, token)
|
||||
rec := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("tokened legacy action status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if upstream.path != "/v1/accounts/set-verified" {
|
||||
t.Fatalf("upstream path=%q", upstream.path)
|
||||
}
|
||||
|
||||
// And logout, which is now behind the same gate.
|
||||
req = withCookies(httptest.NewRequest(http.MethodPost, "/api/logout", nil), cookies)
|
||||
req.Header.Set(csrfHeaderName, token)
|
||||
rec = httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("tokened logout status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
// Both cookies are cleared, so the browser cannot keep replaying either half.
|
||||
cleared := map[string]bool{}
|
||||
for _, cookie := range rec.Result().Cookies() {
|
||||
if cookie.MaxAge < 0 {
|
||||
cleared[cookie.Name] = true
|
||||
}
|
||||
}
|
||||
if !cleared[sessionCookieName] || !cleared[csrfCookieName] {
|
||||
t.Fatalf("logout cleared=%+v, want both cookies expired", cleared)
|
||||
}
|
||||
}
|
||||
|
||||
// The panel drives claim, approve and reject from one form, so a claim carrying an
|
||||
// internal note must not be rejected by the strict decoder.
|
||||
func TestClaimVerificationAcceptsAnOptionalInternalNote(t *testing.T) {
|
||||
upstream := &verificationUpstream{body: admin.CommandResult{CommandID: "c1", Status: "completed"}}
|
||||
api := httptest.NewServer(upstream.handler(t))
|
||||
defer api.Close()
|
||||
|
||||
srv := &server{cfg: uiConfig{AdminAPIURL: api.URL, AdminAPIToken: "api-secret"}}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/verification/applications/77/claim", strings.NewReader(
|
||||
`{"reason":"queue sweep","confirm":true,"version":3,"internal_note":"waiting on legal"}`))
|
||||
req.SetPathValue("id", "77")
|
||||
req = requestWithActor(req, "operator")
|
||||
rec := httptest.NewRecorder()
|
||||
srv.handleClaimVerificationAPI(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var got admin.ClaimVerificationRequest
|
||||
if err := json.Unmarshal(upstream.raw, &got); err != nil {
|
||||
t.Fatalf("decode forwarded claim: %v", err)
|
||||
}
|
||||
if got.InternalNote != "waiting on legal" {
|
||||
t.Fatalf("forwarded claim=%+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMutatingMethodClassification(t *testing.T) {
|
||||
for _, method := range []string{http.MethodGet, http.MethodHead, http.MethodOptions, "get"} {
|
||||
if mutatingMethod(method) {
|
||||
t.Fatalf("%s classified as mutating", method)
|
||||
}
|
||||
}
|
||||
for _, method := range []string{http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete} {
|
||||
if !mutatingMethod(method) {
|
||||
t.Fatalf("%s classified as safe", method)
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
9
cmd/telesrv-admin/web/dist/assets/index-D_BLAfeq.js
vendored
Normal file
9
cmd/telesrv-admin/web/dist/assets/index-D_BLAfeq.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
cmd/telesrv-admin/web/dist/assets/index-KZOn7Xwd.css
vendored
Normal file
1
cmd/telesrv-admin/web/dist/assets/index-KZOn7Xwd.css
vendored
Normal file
File diff suppressed because one or more lines are too long
4
cmd/telesrv-admin/web/dist/index.html
vendored
4
cmd/telesrv-admin/web/dist/index.html
vendored
|
|
@ -21,8 +21,8 @@
|
|||
}
|
||||
})();
|
||||
</script>
|
||||
<script type="module" crossorigin src="/assets/index-DJw3UpEg.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-D5Lc7N2D.css">
|
||||
<script type="module" crossorigin src="/assets/index-D_BLAfeq.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-KZOn7Xwd.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
|
|
|||
14
cmd/telesrv-admin/web/package-lock.json
generated
14
cmd/telesrv-admin/web/package-lock.json
generated
|
|
@ -758,9 +758,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.15",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz",
|
||||
"integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==",
|
||||
"version": "3.3.16",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
|
||||
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
|
|
@ -797,9 +797,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.16",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz",
|
||||
"integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==",
|
||||
"version": "8.5.23",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz",
|
||||
"integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
|
|
@ -817,7 +817,7 @@
|
|||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.12",
|
||||
"nanoid": "^3.3.16",
|
||||
"picocolors": "^1.1.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,12 +1,16 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { api, APIError } from "./api";
|
||||
import { api } from "./api";
|
||||
import { BootScreen, Shell } from "./components/Layout";
|
||||
import { LoginPage } from "./pages/LoginPage";
|
||||
import { PermissionsProvider } from "./permissions";
|
||||
import { Routes } from "./pages/Routes";
|
||||
import { currentRoute, type RouteState } from "./routing";
|
||||
import type { AdminSession } from "./types";
|
||||
|
||||
export function App() {
|
||||
const [actor, setActor] = useState<string | null | undefined>(undefined);
|
||||
// One GET /api/session at boot carries both the actor and the permission set the
|
||||
// signed session was issued with.
|
||||
const [session, setSession] = useState<AdminSession | null | undefined>(undefined);
|
||||
const [route, setRoute] = useState<RouteState>(() => currentRoute());
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -17,14 +21,10 @@ export function App() {
|
|||
|
||||
useEffect(() => {
|
||||
api.session()
|
||||
.then((session) => setActor(session.actor))
|
||||
.catch((error) => {
|
||||
if (error instanceof APIError && error.status === 401) {
|
||||
setActor(null);
|
||||
return;
|
||||
}
|
||||
setActor(null);
|
||||
});
|
||||
.then((next) => setSession(next))
|
||||
// A 401 and an unreachable backend both end at the login screen; there is
|
||||
// nothing the panel can render without a session.
|
||||
.catch(() => setSession(null));
|
||||
}, []);
|
||||
|
||||
const navigate = (href: string) => {
|
||||
|
|
@ -32,17 +32,19 @@ export function App() {
|
|||
setRoute(currentRoute());
|
||||
};
|
||||
|
||||
if (actor === undefined) {
|
||||
if (session === undefined) {
|
||||
return <BootScreen />;
|
||||
}
|
||||
|
||||
if (actor === null) {
|
||||
return <LoginPage onLogin={setActor} />;
|
||||
if (session === null) {
|
||||
return <LoginPage onLogin={setSession} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Shell actor={actor} route={route} navigate={navigate} onLogout={() => setActor(null)}>
|
||||
<Routes route={route} navigate={navigate} />
|
||||
</Shell>
|
||||
<PermissionsProvider permissions={session.permissions ?? []}>
|
||||
<Shell actor={session.actor} route={route} navigate={navigate} onLogout={() => setSession(null)}>
|
||||
<Routes route={route} navigate={navigate} />
|
||||
</Shell>
|
||||
</PermissionsProvider>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,23 @@
|
|||
import type {
|
||||
AccountDetail,
|
||||
AccountListResponse,
|
||||
AccountRatingDetail,
|
||||
AccountRatingListResponse,
|
||||
AdminLoginResult,
|
||||
AdminSession,
|
||||
BotDetail,
|
||||
BotListResponse,
|
||||
BotVerificationCountsResponse,
|
||||
BotVerifierListResponse,
|
||||
ChannelDetail,
|
||||
CustomVerificationListResponse,
|
||||
CustomVerificationRequestDetail,
|
||||
CustomVerificationRequestListResponse,
|
||||
VerificationIconListResponse,
|
||||
EmojiListResponse,
|
||||
ChannelListResponse,
|
||||
CollectibleUsernameDetail,
|
||||
CollectibleUsernameListResponse,
|
||||
CommandResult,
|
||||
GroupMessageDetail,
|
||||
GroupMessageListResponse,
|
||||
|
|
@ -16,7 +28,10 @@ import type {
|
|||
ModerationReport,
|
||||
OfficialStarGiftListResponse,
|
||||
StarGiftCollectiblePreview,
|
||||
StarGiftListResponse
|
||||
StarGiftListResponse,
|
||||
VerificationApplicationDetail,
|
||||
VerificationApplicationListResponse,
|
||||
VerificationCountsResponse
|
||||
} from "./types";
|
||||
|
||||
export class APIError extends Error {
|
||||
|
|
@ -28,12 +43,81 @@ export class APIError extends Error {
|
|||
}
|
||||
}
|
||||
|
||||
// The backend publishes the CSRF token in a deliberately readable cookie and
|
||||
// refuses every mutating request whose X-CSRF-Token header does not repeat it
|
||||
// (cmd/telesrv-admin/security.go). Echoing it here — inside request<T> — is what
|
||||
// keeps a new endpoint from silently shipping without the header.
|
||||
const csrfCookieName = "telesrv_admin_csrf";
|
||||
const csrfHeaderName = "X-CSRF-Token";
|
||||
|
||||
// Login answers with the token in the body as well as in Set-Cookie. Keeping the
|
||||
// body value is the fallback for the window where the browser has not applied
|
||||
// the cookie yet, or where the cookie is not readable back to the script.
|
||||
let issuedCSRFToken = "";
|
||||
|
||||
export function rememberCSRFToken(token: string | undefined): void {
|
||||
issuedCSRFToken = (token ?? "").trim();
|
||||
}
|
||||
|
||||
function readCSRFCookie(): string {
|
||||
if (typeof document === "undefined") return "";
|
||||
for (const chunk of document.cookie.split(";")) {
|
||||
const entry = chunk.trim();
|
||||
const separator = entry.indexOf("=");
|
||||
if (separator <= 0 || entry.slice(0, separator) !== csrfCookieName) continue;
|
||||
try {
|
||||
return decodeURIComponent(entry.slice(separator + 1));
|
||||
} catch {
|
||||
return entry.slice(separator + 1);
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
// The cookie wins: it is the value the server compares against, and it survives
|
||||
// a page reload that the in-memory copy does not.
|
||||
export function csrfToken(): string {
|
||||
return readCSRFCookie() || issuedCSRFToken;
|
||||
}
|
||||
|
||||
// Same classification the backend uses: GET/HEAD/OPTIONS are safe, everything
|
||||
// else carries a token.
|
||||
function mutatingMethod(method: string | undefined): boolean {
|
||||
const verb = (method ?? "GET").toUpperCase();
|
||||
return verb !== "GET" && verb !== "HEAD" && verb !== "OPTIONS";
|
||||
}
|
||||
|
||||
function plainHeaders(source: HeadersInit | undefined): Record<string, string> {
|
||||
if (!source) return {};
|
||||
if (source instanceof Headers) {
|
||||
const out: Record<string, string> = {};
|
||||
source.forEach((value, key) => {
|
||||
out[key] = value;
|
||||
});
|
||||
return out;
|
||||
}
|
||||
if (Array.isArray(source)) {
|
||||
return Object.fromEntries(source);
|
||||
}
|
||||
return { ...source };
|
||||
}
|
||||
|
||||
async function request<T>(url: string, init: RequestInit = {}): Promise<T> {
|
||||
const isForm = typeof FormData !== "undefined" && init.body instanceof FormData;
|
||||
// A multipart body must keep the boundary the browser generates, so its
|
||||
// Content-Type is left alone; the CSRF header is added either way.
|
||||
const headers: Record<string, string> = isForm ? {} : { "Content-Type": "application/json" };
|
||||
Object.assign(headers, plainHeaders(init.headers));
|
||||
if (mutatingMethod(init.method)) {
|
||||
const token = csrfToken();
|
||||
if (token) {
|
||||
headers[csrfHeaderName] = token;
|
||||
}
|
||||
}
|
||||
const response = await fetch(url, {
|
||||
credentials: "same-origin",
|
||||
headers: isForm ? init.headers : { "Content-Type": "application/json", ...(init.headers ?? {}) },
|
||||
...init
|
||||
...init,
|
||||
headers
|
||||
});
|
||||
const text = await response.text();
|
||||
const data = text ? JSON.parse(text) : null;
|
||||
|
|
@ -52,11 +136,16 @@ export function errorMessage(error: unknown): string {
|
|||
}
|
||||
|
||||
export const api = {
|
||||
session: () => request<{ actor: string }>("/api/session"),
|
||||
login: (secret: string) => request<{ actor: string }>("/api/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ secret })
|
||||
}),
|
||||
session: () => request<AdminSession>("/api/session"),
|
||||
login: async (secret: string) => {
|
||||
const result = await request<AdminLoginResult>("/api/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ secret })
|
||||
});
|
||||
// Stashed here rather than in the caller so no login path can forget it.
|
||||
rememberCSRFToken(result.csrf_token);
|
||||
return result;
|
||||
},
|
||||
logout: () => request<{ ok: boolean }>("/api/logout", { method: "POST", body: "{}" }),
|
||||
accounts: (params: URLSearchParams) => request<AccountListResponse>(`/api/accounts?${params.toString()}`),
|
||||
account: (id: number) => request<AccountDetail>(`/api/accounts/${id}`),
|
||||
|
|
@ -64,6 +153,36 @@ export const api = {
|
|||
channel: (id: number) => request<ChannelDetail>(`/api/channels/${id}`),
|
||||
bots: (params: URLSearchParams) => request<BotListResponse>(`/api/bots?${params.toString()}`),
|
||||
bot: (id: number) => request<BotDetail>(`/api/bots/${id}`),
|
||||
collectibleUsernames: (params: URLSearchParams) =>
|
||||
request<CollectibleUsernameListResponse>(`/api/collectible-usernames?${params.toString()}`),
|
||||
collectibleUsername: (id: string) =>
|
||||
request<CollectibleUsernameDetail>(`/api/collectible-usernames/${encodeURIComponent(id)}`),
|
||||
accountRatings: (params: URLSearchParams) =>
|
||||
request<AccountRatingListResponse>(`/api/account-ratings?${params.toString()}`),
|
||||
accountRating: (userID: string) =>
|
||||
request<AccountRatingDetail>(`/api/account-ratings/${encodeURIComponent(userID)}`),
|
||||
verificationApplications: (params: URLSearchParams) =>
|
||||
request<VerificationApplicationListResponse>(`/api/verification/applications?${params.toString()}`),
|
||||
// The application id is an int64 decimal string end to end, so it is never
|
||||
// parsed into a number on the way to the URL.
|
||||
verificationApplication: (id: string) =>
|
||||
request<VerificationApplicationDetail>(`/api/verification/applications/${encodeURIComponent(id)}`),
|
||||
verificationCounts: () => request<VerificationCountsResponse>("/api/verification/counts"),
|
||||
// Third-party verification lives under its own prefix: the two mechanisms share
|
||||
// no state, so they share no route either.
|
||||
botVerifiers: (params: URLSearchParams) =>
|
||||
request<BotVerifierListResponse>(`/api/botverification/verifiers?${params.toString()}`),
|
||||
verificationIcons: (params: URLSearchParams) =>
|
||||
request<VerificationIconListResponse>(`/api/botverification/icons?${params.toString()}`),
|
||||
customVerifications: (params: URLSearchParams) =>
|
||||
request<CustomVerificationListResponse>(`/api/botverification/marks?${params.toString()}`),
|
||||
customVerificationRequests: (params: URLSearchParams) =>
|
||||
request<CustomVerificationRequestListResponse>(`/api/botverification/requests?${params.toString()}`),
|
||||
// The application id is an int64 decimal string end to end, so it is never parsed
|
||||
// into a number on the way to the URL.
|
||||
customVerificationRequest: (id: string) =>
|
||||
request<CustomVerificationRequestDetail>(`/api/botverification/requests/${encodeURIComponent(id)}`),
|
||||
botVerificationCounts: () => request<BotVerificationCountsResponse>("/api/botverification/counts"),
|
||||
emoji: (params: URLSearchParams) => request<EmojiListResponse>(`/api/emoji?${params.toString()}`),
|
||||
emojiAnimation: (documentID: string) => request<Record<string, unknown>>(`/api/emoji/${encodeURIComponent(documentID)}/animation`),
|
||||
messages: (params: URLSearchParams) => request<MessageListResponse>(`/api/messages?${params.toString()}`),
|
||||
|
|
|
|||
|
|
@ -16,7 +16,9 @@ export function ActionButton({
|
|||
icon,
|
||||
compact = false,
|
||||
tone = "danger",
|
||||
onDone
|
||||
disabled = false,
|
||||
onDone,
|
||||
onError
|
||||
}: {
|
||||
label: string;
|
||||
path: string;
|
||||
|
|
@ -24,7 +26,15 @@ export function ActionButton({
|
|||
icon?: ReactNode;
|
||||
compact?: boolean;
|
||||
tone?: ActionTone;
|
||||
// disabled keeps a form from opening the confirm flow at all while its own
|
||||
// validation is unhappy, so the operator fixes the field instead of reading a
|
||||
// backend rejection.
|
||||
disabled?: boolean;
|
||||
onDone?: () => void;
|
||||
// onError lets a page react to a failure the operator cannot fix by editing the
|
||||
// form — an optimistic-locking 409, say — and replace the raw backend text with
|
||||
// an explanation by returning it.
|
||||
onError?: (error: unknown) => string | undefined;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
|
@ -54,7 +64,7 @@ export function ActionButton({
|
|||
onDone?.();
|
||||
}
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
setError(onError?.(err) || errorMessage(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
|
|
@ -75,6 +85,7 @@ export function ActionButton({
|
|||
<button
|
||||
className={triggerClass}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
reset();
|
||||
setOpen(true);
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { useEffect, useState } from "react";
|
|||
import { api, errorMessage } from "../api";
|
||||
import { useI18n } from "../i18n";
|
||||
import { channelKind, displayName, displayPhone, displayUsername } from "../lib/format";
|
||||
import type { AccountRow, ChannelRow } from "../types";
|
||||
import type { AccountRow, BotRow, ChannelRow } from "../types";
|
||||
import { Badge } from "./ui";
|
||||
|
||||
export function UserPicker({
|
||||
|
|
@ -100,6 +100,103 @@ export function UserPicker({
|
|||
);
|
||||
}
|
||||
|
||||
// BotPicker is the same widget over /api/bots. Verifier status is granted to a bot
|
||||
// account, and an operator knows the handle rather than the id, so the grant form
|
||||
// resolves it here instead of asking for a raw number.
|
||||
export function BotPicker({
|
||||
label,
|
||||
value,
|
||||
onChange
|
||||
}: {
|
||||
label: string;
|
||||
value: BotRow | null;
|
||||
onChange: (row: BotRow | null) => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const [query, setQuery] = useState("");
|
||||
const [rows, setRows] = useState<BotRow[]>([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
async function search() {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
const params = new URLSearchParams({ limit: "20" });
|
||||
if (query.trim()) {
|
||||
params.set("q", query.trim().replace(/^@/, ""));
|
||||
}
|
||||
try {
|
||||
const result = await api.bots(params);
|
||||
setRows(result.rows ?? []);
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void search();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="entity-picker">
|
||||
<div className="picker-head">
|
||||
<span>{label}</span>
|
||||
{value ? (
|
||||
<button className="link-button" type="button" onClick={() => onChange(null)}>
|
||||
<X size={13} /> {t("common.clear")}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
{value ? (
|
||||
<div className="selected-entity">
|
||||
<Check size={15} />
|
||||
<div>
|
||||
<strong>{value.FirstName || "-"}</strong>
|
||||
<span className="mono">{value.ID}</span>
|
||||
</div>
|
||||
<span>{displayUsername(value.Username) || "-"}</span>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="picker-search">
|
||||
<Search size={15} />
|
||||
<input
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
void search();
|
||||
}
|
||||
}}
|
||||
placeholder={t("picker.botPlaceholder")}
|
||||
/>
|
||||
<button className="btn compact-btn" type="button" onClick={search} disabled={busy}>
|
||||
{busy ? <Loader2 size={14} className="spin" /> : t("common.search")}
|
||||
</button>
|
||||
</div>
|
||||
{error && <div className="picker-error">{error}</div>}
|
||||
<div className="picker-results">
|
||||
{rows.map((row) => (
|
||||
<button
|
||||
key={row.ID}
|
||||
className={`picker-row ${value?.ID === row.ID ? "selected" : ""}`}
|
||||
type="button"
|
||||
onClick={() => onChange(row)}
|
||||
>
|
||||
<span className="mono">{row.ID}</span>
|
||||
<strong>{row.FirstName || "-"}</strong>
|
||||
<span>{displayUsername(row.Username) || "-"}</span>
|
||||
{row.System ? <Badge tone="warn">{t("picker.system")}</Badge> : <Badge>{t("picker.regular")}</Badge>}
|
||||
</button>
|
||||
))}
|
||||
{rows.length === 0 && !busy ? <div className="picker-empty">{t("common.noResults")}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChannelPicker({
|
||||
label,
|
||||
value,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import {
|
||||
AtSign,
|
||||
BadgeCheck,
|
||||
Bot,
|
||||
ChevronDown,
|
||||
Database,
|
||||
|
|
@ -10,6 +12,8 @@ import {
|
|||
ShieldAlert,
|
||||
ShieldCheck,
|
||||
Smile,
|
||||
Stamp,
|
||||
Trophy,
|
||||
Users,
|
||||
Gift,
|
||||
Send
|
||||
|
|
@ -17,6 +21,7 @@ import {
|
|||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { api } from "../api";
|
||||
import { LanguageSwitch, useI18n } from "../i18n";
|
||||
import { permissionBotVerificationReview, permissionVerificationReview, useCan } from "../permissions";
|
||||
import { type Navigate, type RouteState, routeSubtitle, routeTitle } from "../routing";
|
||||
import { ThemeSwitch } from "../theme";
|
||||
import { AppLink } from "./AppLink";
|
||||
|
|
@ -51,6 +56,12 @@ export function Shell({
|
|||
children: ReactNode;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
// The verification queue is hidden for a session without verification.review:
|
||||
// the entry would only lead to a 403 (and the route itself is gated as well).
|
||||
const canReviewVerification = useCan(permissionVerificationReview);
|
||||
// Same reasoning for the third-party queue, which has its own right: the two
|
||||
// sections are granted independently, so one entry can be visible without the other.
|
||||
const canReviewBotVerification = useCan(permissionBotVerificationReview);
|
||||
const messagesActive = route.path.startsWith("/messages");
|
||||
const [messagesOpen, setMessagesOpen] = useState(messagesActive);
|
||||
|
||||
|
|
@ -82,6 +93,14 @@ export function Shell({
|
|||
<NavLink icon={<ShieldCheck size={16} />} href="/channels" route={route} navigate={navigate}>{t("layout.channels")}</NavLink>
|
||||
<NavLink icon={<Bot size={16} />} href="/bots" route={route} navigate={navigate}>{t("layout.bots")}</NavLink>
|
||||
<NavLink icon={<ShieldAlert size={16} />} href="/moderation" route={route} navigate={navigate}>{t("layout.moderation")}</NavLink>
|
||||
{canReviewVerification && (
|
||||
<NavLink icon={<BadgeCheck size={16} />} href="/verification" route={route} navigate={navigate}>{t("layout.verification")}</NavLink>
|
||||
)}
|
||||
{canReviewBotVerification && (
|
||||
<NavLink icon={<Stamp size={16} />} href="/bot-verification" route={route} navigate={navigate}>{t("layout.botVerification")}</NavLink>
|
||||
)}
|
||||
<NavLink icon={<AtSign size={16} />} href="/collectible-usernames" route={route} navigate={navigate}>{t("layout.collectibleUsernames")}</NavLink>
|
||||
<NavLink icon={<Trophy size={16} />} href="/account-ratings" route={route} navigate={navigate}>{t("layout.accountRatings")}</NavLink>
|
||||
<NavLink icon={<Gift size={16} />} href="/gifts" route={route} navigate={navigate}>{t("layout.gifts")}</NavLink>
|
||||
<NavLink icon={<Send size={16} />} href="/give-gifts" route={route} navigate={navigate}>{t("layout.giveGifts")}</NavLink>
|
||||
<NavLink icon={<Smile size={16} />} href="/emoji" route={route} navigate={navigate}>{t("layout.emoji")}</NavLink>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { CircleAlert } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useI18n } from "../i18n";
|
||||
import { formatDate } from "../lib/format";
|
||||
import type { AuditLogRow } from "../types";
|
||||
import { displayUsername, formatDate } from "../lib/format";
|
||||
import type { AccountUsername, AuditLogRow } from "../types";
|
||||
|
||||
type Tone = "neutral" | "good" | "danger" | "warn";
|
||||
|
||||
|
|
@ -129,3 +129,33 @@ export function LoadingSurface({ label }: { label: string }) {
|
|||
export function JsonBlock({ value }: { value: string }) {
|
||||
return <pre className="json-block">{value || "{}"}</pre>;
|
||||
}
|
||||
|
||||
// UsernameCell renders a peer's editable username with its collectible usernames
|
||||
// branching off underneath, in the order clients project them.
|
||||
//
|
||||
// An inactive collectible is shown rather than hidden: the peer still owns it, it
|
||||
// just does not resolve publicly, and an operator looking for "where did that name
|
||||
// go" needs to see it. It is marked instead of dropped.
|
||||
// Pass an empty username to render the branch on its own, which is what the
|
||||
// detail header does: it already shows the editable slot on the line above.
|
||||
export function UsernameCell({ username, collectibles }: { username?: string; collectibles?: AccountUsername[] | null }) {
|
||||
const { t } = useI18n();
|
||||
const main = displayUsername(username ?? "");
|
||||
const branch = collectibles ?? [];
|
||||
if (branch.length === 0) {
|
||||
return <>{main || "-"}</>;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{main}
|
||||
<ul className="username-branch">
|
||||
{branch.map((item) => (
|
||||
<li key={item.Username} className={item.Active ? "" : "inactive"}>
|
||||
<span>{displayUsername(item.Username)}</span>
|
||||
{!item.Active && <em>{t("usernames.inactive")}</em>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -44,12 +44,131 @@ export function formatUnix(value: number): string {
|
|||
return date.toLocaleString();
|
||||
}
|
||||
|
||||
// safeHttpURL vets a link an applicant typed. Only http(s) is turned into an
|
||||
// anchor: a submitted string may just as well be javascript:, data: or a bare
|
||||
// word, and must stay inert text in that case. The parsed href is returned so a
|
||||
// malformed authority cannot slip through the prefix test.
|
||||
export function safeHttpURL(value: string): string {
|
||||
const raw = (value ?? "").trim();
|
||||
if (!/^https?:\/\//i.test(raw)) return "";
|
||||
try {
|
||||
const parsed = new URL(raw);
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return "";
|
||||
return parsed.href;
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
export function toInt(value: string): number {
|
||||
if (!value.trim()) return 0;
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
// int64 values arrive as JSON strings; keep parsing tolerant so an unexpected
|
||||
// empty string or "null" never renders as NaN.
|
||||
export function toNumeric(value: string): number {
|
||||
const raw = (value ?? "").trim();
|
||||
if (!raw) return 0;
|
||||
const parsed = Number(raw);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
export function formatQuantity(value: string): string {
|
||||
const raw = (value ?? "").trim();
|
||||
if (!raw) return "0";
|
||||
const parsed = Number(raw);
|
||||
return Number.isFinite(parsed) ? parsed.toLocaleString() : raw;
|
||||
}
|
||||
|
||||
// Currency scaling for fragment.collectibleInfo.
|
||||
//
|
||||
// The wire format is integer smallest units: core.telegram.org says amount is
|
||||
// "Total price in the smallest units of the currency (integer, not
|
||||
// float/double)" -- $1.45 is 145 -- and crypto_amount likewise, so TON is
|
||||
// nanotons (1 TON = 1e9). Clients divide by that exponent before drawing the
|
||||
// price, which is why a panel that both stores and shows the raw integer makes an
|
||||
// operator type 900 for "900 TON" and Telegram Desktop then renders 0.0000009.
|
||||
//
|
||||
// Everything the operator reads or types in the panel is therefore in whole
|
||||
// currency units, and these helpers are the only conversion boundary.
|
||||
const currencyExponents: Record<string, number> = {
|
||||
// Stars have no subunit: an XTR amount is a count of stars.
|
||||
XTR: 0,
|
||||
// Nanotons.
|
||||
TON: 9,
|
||||
// Fiat minor units.
|
||||
USD: 2,
|
||||
EUR: 2,
|
||||
RUB: 2
|
||||
};
|
||||
|
||||
export function currencyExponent(currency: string): number {
|
||||
const key = (currency ?? "").trim().toUpperCase();
|
||||
// Two decimals is the ISO 4217 default, and it is what an unknown fiat code
|
||||
// most likely is; guessing 0 would silently multiply a price by 100.
|
||||
return key in currencyExponents ? currencyExponents[key] : 2;
|
||||
}
|
||||
|
||||
// formatCurrencyAmount renders smallest units as whole currency units. It works
|
||||
// on the decimal string rather than a JS number so a nanoton amount beyond
|
||||
// Number.MAX_SAFE_INTEGER is not rounded on the way to the screen.
|
||||
export function formatCurrencyAmount(value: string, currency: string): string {
|
||||
const raw = (value ?? "").trim();
|
||||
if (!raw) return "0";
|
||||
if (!/^-?\d+$/.test(raw)) return raw;
|
||||
const exponent = currencyExponent(currency);
|
||||
const negative = raw.startsWith("-");
|
||||
const digits = (negative ? raw.slice(1) : raw).replace(/^0+(?=\d)/, "");
|
||||
const padded = digits.padStart(exponent + 1, "0");
|
||||
const whole = padded.slice(0, padded.length - exponent) || "0";
|
||||
let fraction = exponent > 0 ? padded.slice(padded.length - exponent) : "";
|
||||
// Fiat keeps its two decimals the way a client draws them ($10.00); a
|
||||
// nine-decimal crypto amount would just be a wall of zeros, so trim those.
|
||||
if (exponent > 2) fraction = fraction.replace(/0+$/, "");
|
||||
const sign = negative ? "-" : "";
|
||||
return fraction ? `${sign}${groupDigits(whole)}.${fraction}` : `${sign}${groupDigits(whole)}`;
|
||||
}
|
||||
|
||||
// groupDigits inserts thousands separators without going through a JS number, so
|
||||
// a value past Number.MAX_SAFE_INTEGER keeps every digit.
|
||||
function groupDigits(digits: string): string {
|
||||
return digits.replace(/\B(?=(\d{3})+(?!\d))/g, " ");
|
||||
}
|
||||
|
||||
// formatCurrency is formatCurrencyAmount with the code appended, which is the
|
||||
// shape every price cell in the panel wants.
|
||||
export function formatCurrency(value: string, currency: string): string {
|
||||
const code = (currency ?? "").trim().toUpperCase();
|
||||
const amount = formatCurrencyAmount(value, code);
|
||||
return code ? `${amount} ${code}` : amount;
|
||||
}
|
||||
|
||||
// toSmallestUnits turns what the operator typed -- whole currency units, with an
|
||||
// optional fraction -- into the integer decimal string the API expects. It
|
||||
// returns null for anything that is not a plain non-negative amount, or that
|
||||
// carries more decimals than the currency has, so the form can refuse instead of
|
||||
// silently truncating a price.
|
||||
export function toSmallestUnits(value: string, currency: string): string | null {
|
||||
const raw = (value ?? "").trim().replace(/\s+/g, "").replace(",", ".");
|
||||
if (!raw) return "0";
|
||||
if (!/^\d*(\.\d*)?$/.test(raw) || raw === "." ) return null;
|
||||
const exponent = currencyExponent(currency);
|
||||
const [wholePart, fractionPart = ""] = raw.split(".");
|
||||
if (fractionPart.length > exponent) return null;
|
||||
const digits = `${wholePart || "0"}${fractionPart.padEnd(exponent, "0")}`.replace(/^0+(?=\d)/, "");
|
||||
return digits === "" ? "0" : digits;
|
||||
}
|
||||
|
||||
export function formatSigned(value: string): string {
|
||||
const raw = (value ?? "").trim();
|
||||
if (!raw) return "0";
|
||||
const parsed = Number(raw);
|
||||
if (!Number.isFinite(parsed)) return raw;
|
||||
return parsed > 0 ? `+${parsed.toLocaleString()}` : parsed.toLocaleString();
|
||||
}
|
||||
|
||||
export function parseIDs(value: string, invalidMessage = "msg ids invalid"): number[] {
|
||||
const ids = value
|
||||
.split(/[\s,]+/)
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { useEffect, useState } from "react";
|
|||
import { api, errorMessage } from "../api";
|
||||
import { ActionButton } from "../components/ActionButton";
|
||||
import { AuthorizationTable } from "../components/AuthorizationTable";
|
||||
import { Alert, AuditTable, Badge, LoadingSurface, PageFrame, SectionHead, SplitLayout, Summary } from "../components/ui";
|
||||
import { Alert, AuditTable, Badge, LoadingSurface, PageFrame, SectionHead, SplitLayout, Summary, UsernameCell } from "../components/ui";
|
||||
import { ScamFakeActions, ScamFakeBadges } from "../components/flags";
|
||||
import { ColorAction, EmojiStatusAction, SupportAction, UsernameAction } from "../components/attributes";
|
||||
import { useI18n } from "../i18n";
|
||||
|
|
@ -65,6 +65,11 @@ export function AccountDetailPage({ id, navigate }: { id: number; navigate: Navi
|
|||
<div>
|
||||
<div className="entity-title">{displayName(account)}</div>
|
||||
<div className="entity-subtitle">{displayUsername(account.Username) || t("account.noUsername")} · {displayPhone(account.Phone) || t("account.noPhone")}</div>
|
||||
{account.Collectibles?.length > 0 && (
|
||||
<div className="entity-subtitle">
|
||||
<UsernameCell username="" collectibles={account.Collectibles} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="entity-badges">
|
||||
{account.PremiumUntil > 0 ? <Badge tone="good">{t("account.premium")}</Badge> : <Badge>{t("account.notPremium")}</Badge>}
|
||||
|
|
|
|||
261
cmd/telesrv-admin/web/src/pages/AccountRatingDetailPage.tsx
Normal file
261
cmd/telesrv-admin/web/src/pages/AccountRatingDetailPage.tsx
Normal file
|
|
@ -0,0 +1,261 @@
|
|||
import { ArrowLeft, Calculator, RefreshCw, SlidersHorizontal, User } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { api, errorMessage } from "../api";
|
||||
import { ActionButton } from "../components/ActionButton";
|
||||
import { Alert, Badge, EmptyRow, LoadingSurface, Metric, PageFrame, SectionHead, SplitLayout, Summary } from "../components/ui";
|
||||
import { useI18n } from "../i18n";
|
||||
import { displayUsername, formatDate, formatQuantity, formatSigned, toNumeric } from "../lib/format";
|
||||
import type { Navigate } from "../routing";
|
||||
import type { AccountRatingDetail, AccountRatingEventKind, AccountRatingRow } from "../types";
|
||||
import { LevelBadge, RatingProgress, levelProgress } from "./AccountRatingsPage";
|
||||
|
||||
export function AccountRatingDetailPage({ userID, navigate }: { userID: string; navigate: Navigate }) {
|
||||
const { t } = useI18n();
|
||||
const [detail, setDetail] = useState<AccountRatingDetail | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [adjustment, setAdjustment] = useState("");
|
||||
|
||||
async function load() {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
setDetail(await api.accountRating(userID));
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [userID]);
|
||||
|
||||
if (error && !detail) {
|
||||
return <Alert>{error}</Alert>;
|
||||
}
|
||||
if (!detail) {
|
||||
return <LoadingSurface label={busy ? t("rating.loadingDetail") : t("account.waitingData")} />;
|
||||
}
|
||||
|
||||
const rating = detail.rating;
|
||||
const events = detail.events ?? [];
|
||||
const pending = toNumeric(rating.PendingStars);
|
||||
const progress = levelProgress(rating);
|
||||
// user_id / amount are `,string` int64 fields on the backend, so they stay
|
||||
// decimal strings and never pass through a float.
|
||||
const payloadUserID = rating.UserID || userID;
|
||||
|
||||
return (
|
||||
<PageFrame
|
||||
title={t("rating.detailTitle", { user: displayUsername(rating.Username) || rating.FirstName || rating.UserID })}
|
||||
eyebrow={t("rating.detailEyebrow")}
|
||||
actions={
|
||||
<>
|
||||
<button className="btn icon-text" type="button" onClick={() => navigate("/account-ratings")}>
|
||||
<ArrowLeft size={15} /> {t("common.backToList")}
|
||||
</button>
|
||||
<button className="btn icon-text" type="button" onClick={load} disabled={busy}>
|
||||
<RefreshCw size={15} className={busy ? "spin" : ""} /> {t("common.refresh")}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{error && <Alert>{error}</Alert>}
|
||||
<SplitLayout
|
||||
main={
|
||||
<div className="stacked-sections">
|
||||
<section className="entity-head">
|
||||
<div>
|
||||
<div className="entity-title">{displayUsername(rating.Username) || rating.FirstName || t("bots.unnamed")}</div>
|
||||
<div className="entity-subtitle">{t("rating.userID")}: {rating.UserID}</div>
|
||||
</div>
|
||||
<div className="entity-badges">
|
||||
<LevelBadge level={rating.Level} />
|
||||
{pending !== 0 && <Badge tone="warn">{t("rating.pendingBadge", { amount: formatSigned(rating.PendingStars) })}</Badge>}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="metric-row">
|
||||
<Metric label={t("rating.stars")} value={formatQuantity(rating.Stars)} mono />
|
||||
<Metric label={t("rating.level")} value={String(rating.Level)} tone="good" />
|
||||
<Metric
|
||||
label={t("rating.nextLevel")}
|
||||
value={rating.HasNextLevel ? formatQuantity(rating.NextLevelStars) : t("rating.maxLevel")}
|
||||
mono={rating.HasNextLevel}
|
||||
/>
|
||||
<Metric
|
||||
label={t("rating.toNextLevel")}
|
||||
value={rating.HasNextLevel ? formatQuantity(String(progress.remaining)) : "-"}
|
||||
mono
|
||||
tone={rating.HasNextLevel && progress.percent >= 80 ? "good" : "neutral"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<section className="section-block">
|
||||
<SectionHead title={t("rating.breakdownTitle")} text={t("rating.breakdownHint")} />
|
||||
<Breakdown rating={rating} />
|
||||
<div className="summary-grid">
|
||||
<Summary label={t("rating.currentLevelStars")} value={formatQuantity(rating.CurrentLevelStars)} mono />
|
||||
<Summary
|
||||
label={t("rating.nextLevelStars")}
|
||||
value={rating.HasNextLevel ? formatQuantity(rating.NextLevelStars) : t("rating.maxLevel")}
|
||||
mono={rating.HasNextLevel}
|
||||
/>
|
||||
<Summary label={t("rating.computedAt")} value={formatDate(rating.ComputedAt) || "-"} />
|
||||
<Summary label={t("common.updatedAt")} value={formatDate(rating.UpdatedAt) || "-"} />
|
||||
</div>
|
||||
<div className="progress-wide">
|
||||
<RatingProgress row={rating} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{pending !== 0 && (
|
||||
<section className="section-block">
|
||||
<SectionHead title={t("rating.pendingTitle")} text={t("rating.pendingHint")} />
|
||||
<div className="summary-grid">
|
||||
<Summary label={t("rating.pending")} value={formatSigned(rating.PendingStars)} mono />
|
||||
<Summary label={t("rating.pendingDate")} value={formatDate(rating.PendingDate) || "-"} />
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="section-block">
|
||||
<SectionHead title={t("rating.eventsTitle")} text={t("rating.eventsHint")} />
|
||||
<div className="table-wrap">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("common.id")}</th>
|
||||
<th>{t("rating.eventKind")}</th>
|
||||
<th>{t("rating.amount")}</th>
|
||||
<th>{t("audit.reason")}</th>
|
||||
<th>{t("audit.actor")}</th>
|
||||
<th>{t("common.time")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{events.map((row) => (
|
||||
<tr key={row.ID}>
|
||||
<td className="mono">{row.ID}</td>
|
||||
<td><EventKind kind={row.Kind} /></td>
|
||||
<td className="mono">{formatSigned(row.Amount)}</td>
|
||||
<td className="truncate">{row.Reason || "-"}</td>
|
||||
<td>{row.Actor || "-"}</td>
|
||||
<td>{formatDate(row.CreatedAt) || "-"}</td>
|
||||
</tr>
|
||||
))}
|
||||
{events.length === 0 && <EmptyRow colSpan={6} />}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
}
|
||||
side={
|
||||
<section className="action-dock">
|
||||
<div className="dock-title">{t("rating.actionDock")}</div>
|
||||
<button className="btn icon-text" type="button" onClick={() => navigate(`/accounts/${rating.UserID}`)}>
|
||||
<User size={15} /> {t("rating.openAccount")}
|
||||
</button>
|
||||
<div className="action-stack">
|
||||
<ActionButton
|
||||
label={t("rating.recompute")}
|
||||
icon={<Calculator size={15} />}
|
||||
tone="neutral"
|
||||
path="/api/actions/recompute-account-rating"
|
||||
payload={() => ({ user_id: payloadUserID })}
|
||||
onDone={load}
|
||||
/>
|
||||
</div>
|
||||
<p className="bot-create-note">{t("rating.recomputeHint")}</p>
|
||||
<div className="dock-title">{t("rating.adjustTitle")}</div>
|
||||
<label className="duration-field">
|
||||
<span>{t("rating.adjustAmount")}</span>
|
||||
<input
|
||||
value={adjustment}
|
||||
onChange={(event) => setAdjustment(event.target.value)}
|
||||
type="number"
|
||||
step="1"
|
||||
placeholder="-500"
|
||||
/>
|
||||
</label>
|
||||
<div className="action-stack">
|
||||
<ActionButton
|
||||
label={t("rating.adjust")}
|
||||
icon={<SlidersHorizontal size={15} />}
|
||||
tone="warn"
|
||||
path="/api/actions/adjust-account-rating"
|
||||
payload={() => ({
|
||||
user_id: payloadUserID,
|
||||
amount: String(Number.parseInt(adjustment.trim() || "0", 10) || 0)
|
||||
})}
|
||||
onDone={() => {
|
||||
setAdjustment("");
|
||||
void load();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<p className="bot-create-note">{t("rating.adjustHint")}</p>
|
||||
</section>
|
||||
}
|
||||
/>
|
||||
</PageFrame>
|
||||
);
|
||||
}
|
||||
|
||||
function Breakdown({ rating }: { rating: AccountRatingRow }) {
|
||||
const { t } = useI18n();
|
||||
// PenaltyComponent is stored as a positive magnitude and subtracted by the
|
||||
// scorer, so it is shown (and summed) as a negative contribution.
|
||||
const components = [
|
||||
{ key: "stars", label: t("rating.componentStars"), hint: t("rating.componentStarsHint"), value: toNumeric(rating.StarsComponent) },
|
||||
{ key: "activity", label: t("rating.componentActivity"), hint: t("rating.componentActivityHint"), value: toNumeric(rating.ActivityComponent) },
|
||||
{ key: "penalty", label: t("rating.componentPenalty"), hint: t("rating.componentPenaltyHint"), value: -toNumeric(rating.PenaltyComponent) },
|
||||
{ key: "manual", label: t("rating.componentManual"), hint: t("rating.componentManualHint"), value: toNumeric(rating.ManualComponent) }
|
||||
];
|
||||
const scale = Math.max(1, ...components.map((item) => Math.abs(item.value)));
|
||||
// The score is clamped at zero, and a delayed increase sits in PendingStars
|
||||
// instead of the score, so both cases are expected rather than drift.
|
||||
const sum = Math.max(0, components.reduce((total, item) => total + item.value, 0));
|
||||
const total = toNumeric(rating.Stars);
|
||||
const pending = toNumeric(rating.PendingStars);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="breakdown-list">
|
||||
{components.map((item) => {
|
||||
const percent = Math.min(100, (Math.abs(item.value) / scale) * 100);
|
||||
const tone = item.value < 0 ? "danger" : item.value > 0 ? "good" : "";
|
||||
return (
|
||||
<div className="breakdown-row" key={item.key}>
|
||||
<div className="breakdown-label">
|
||||
<strong>{item.label}</strong>
|
||||
<small>{item.hint}</small>
|
||||
</div>
|
||||
<div className={`progress-bar ${tone}`} role="img" aria-label={String(item.value)}>
|
||||
<span style={{ width: `${percent}%` }} />
|
||||
</div>
|
||||
<div className={`breakdown-value mono ${tone}`}>{formatSigned(String(item.value))}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="breakdown-row total">
|
||||
<div className="breakdown-label"><strong>{t("rating.componentTotal")}</strong></div>
|
||||
<div className="breakdown-value mono">{formatQuantity(rating.Stars)}</div>
|
||||
</div>
|
||||
</div>
|
||||
{pending === 0 && sum !== total && (
|
||||
<Alert>{t("rating.breakdownMismatch", { sum: formatQuantity(String(sum)), total: formatQuantity(rating.Stars) })}</Alert>
|
||||
)}
|
||||
{pending !== 0 && <p className="bot-create-note">{t("rating.breakdownPending", { amount: formatSigned(rating.PendingStars) })}</p>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function EventKind({ kind }: { kind: AccountRatingEventKind }) {
|
||||
const { t } = useI18n();
|
||||
const tone = kind === "moderation" ? "danger" : kind === "manual" ? "warn" : kind === "recompute" ? "neutral" : "good";
|
||||
return <Badge tone={tone}>{t(`rating.kind.${kind}`)}</Badge>;
|
||||
}
|
||||
167
cmd/telesrv-admin/web/src/pages/AccountRatingsPage.tsx
Normal file
167
cmd/telesrv-admin/web/src/pages/AccountRatingsPage.tsx
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
import { ChevronDown, ChevronRight, Loader2, RefreshCw, Search, Trophy } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { api, errorMessage } from "../api";
|
||||
import { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel } from "../components/ui";
|
||||
import { useI18n } from "../i18n";
|
||||
import { displayUsername, formatDate, formatQuantity, toNumeric } from "../lib/format";
|
||||
import type { Navigate } from "../routing";
|
||||
import type { AccountRatingRow } from "../types";
|
||||
|
||||
export function AccountRatingsPage({ navigate }: { navigate: Navigate }) {
|
||||
const { t } = useI18n();
|
||||
const [minLevel, setMinLevel] = useState("");
|
||||
const [search, setSearch] = useState("");
|
||||
const [limit, setLimit] = useState("50");
|
||||
const [rows, setRows] = useState<AccountRatingRow[]>([]);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [cursor, setCursor] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
async function load(next = false) {
|
||||
// One free-text field: the backend matches a username prefix (editable or
|
||||
// collectible), a first/last name prefix, and a bare number as the user id.
|
||||
const wanted = search.trim();
|
||||
setBusy(true);
|
||||
setError("");
|
||||
const params = new URLSearchParams({ limit });
|
||||
if (minLevel.trim()) params.set("min_level", minLevel.trim());
|
||||
if (wanted) params.set("q", wanted);
|
||||
if (next && cursor) params.set("before_id", cursor);
|
||||
try {
|
||||
const result = await api.accountRatings(params);
|
||||
const page = result.rows ?? [];
|
||||
setRows((current) => (next ? [...current, ...page] : page));
|
||||
setCursor(result.next_before_id ?? "");
|
||||
setHasMore(Boolean(result.has_more));
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void load(false);
|
||||
}, []);
|
||||
|
||||
const topLevel = rows.reduce((max, row) => Math.max(max, row.Level), 0);
|
||||
const pendingCount = rows.filter((row) => toNumeric(row.PendingStars) !== 0).length;
|
||||
const avgLevel = rows.length > 0
|
||||
? (rows.reduce((sum, row) => sum + row.Level, 0) / rows.length).toFixed(1)
|
||||
: "0";
|
||||
|
||||
return (
|
||||
<PageFrame
|
||||
title={t("rating.pageTitle")}
|
||||
eyebrow={t("rating.eyebrow")}
|
||||
actions={
|
||||
<button className="btn icon-text" type="button" onClick={() => load(false)} disabled={busy}>
|
||||
<RefreshCw size={15} className={busy ? "spin" : ""} /> {t("common.refresh")}
|
||||
</button>
|
||||
}
|
||||
>
|
||||
{error && <Alert>{error}</Alert>}
|
||||
<div className="metric-row">
|
||||
<Metric label={t("rating.metricLoaded")} value={String(rows.length)} />
|
||||
<Metric label={t("rating.metricTopLevel")} value={String(topLevel)} tone="good" />
|
||||
<Metric label={t("rating.metricAvgLevel")} value={avgLevel} />
|
||||
<Metric label={t("rating.metricPending")} value={String(pendingCount)} tone={pendingCount ? "warn" : "neutral"} />
|
||||
</div>
|
||||
|
||||
<QueryPanel>
|
||||
<form className="toolbar" onSubmit={(event) => { event.preventDefault(); void load(false); }}>
|
||||
<label className="searchbox">
|
||||
<Search size={15} />
|
||||
<input value={search} onChange={(event) => setSearch(event.target.value)} placeholder={t("rating.searchPlaceholder")} />
|
||||
</label>
|
||||
<label className="field-inline">
|
||||
<span>{t("rating.minLevel")}</span>
|
||||
<input className="small-input" value={minLevel} onChange={(event) => setMinLevel(event.target.value)} type="number" min="0" placeholder="0" />
|
||||
</label>
|
||||
<label className="field-inline">
|
||||
<span>{t("common.limit")}</span>
|
||||
<input className="small-input" value={limit} onChange={(event) => setLimit(event.target.value)} type="number" min="1" max="200" />
|
||||
</label>
|
||||
<button className="btn primary icon-text" type="submit" disabled={busy}>
|
||||
{busy ? <Loader2 size={15} className="spin" /> : <Search size={15} />} {t("common.search")}
|
||||
</button>
|
||||
</form>
|
||||
</QueryPanel>
|
||||
|
||||
<div className="table-wrap">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("rating.userID")}</th>
|
||||
<th>{t("common.username")}</th>
|
||||
<th>{t("rating.level")}</th>
|
||||
<th>{t("rating.stars")}</th>
|
||||
<th>{t("rating.progress")}</th>
|
||||
<th>{t("rating.pending")}</th>
|
||||
<th>{t("rating.computedAt")}</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<tr key={row.UserID}>
|
||||
<td className="mono">{row.UserID}</td>
|
||||
<td>{displayUsername(row.Username) || row.FirstName || "-"}</td>
|
||||
<td><LevelBadge level={row.Level} /></td>
|
||||
<td className="mono">{formatQuantity(row.Stars)}</td>
|
||||
<td><RatingProgress row={row} /></td>
|
||||
<td className="mono">{toNumeric(row.PendingStars) !== 0 ? formatQuantity(row.PendingStars) : "-"}</td>
|
||||
<td>{formatDate(row.ComputedAt) || "-"}</td>
|
||||
<td>
|
||||
<button className="row-link" type="button" onClick={() => navigate(`/account-ratings/${row.UserID}`)}>
|
||||
<Trophy size={14} /> {t("common.detail")} <ChevronRight size={14} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{rows.length === 0 && <EmptyRow colSpan={8} />}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{hasMore && (
|
||||
<div className="toolbar">
|
||||
<button className="btn icon-text" type="button" onClick={() => load(true)} disabled={busy}>
|
||||
{busy ? <Loader2 size={15} className="spin" /> : <ChevronDown size={15} />} {t("common.loadMore")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</PageFrame>
|
||||
);
|
||||
}
|
||||
|
||||
export function LevelBadge({ level }: { level: number }) {
|
||||
const { t } = useI18n();
|
||||
const tone = level >= 10 ? "good" : level >= 5 ? "warn" : "neutral";
|
||||
return <Badge tone={tone}>{t("rating.levelValue", { level })}</Badge>;
|
||||
}
|
||||
|
||||
export function levelProgress(row: AccountRatingRow): { percent: number; remaining: number; target: number; stars: number } {
|
||||
const stars = toNumeric(row.Stars);
|
||||
const current = toNumeric(row.CurrentLevelStars);
|
||||
const target = toNumeric(row.NextLevelStars);
|
||||
const span = target - current;
|
||||
const percent = span > 0 ? Math.min(100, Math.max(0, ((stars - current) / span) * 100)) : 0;
|
||||
return { percent, remaining: Math.max(0, target - stars), target, stars };
|
||||
}
|
||||
|
||||
export function RatingProgress({ row }: { row: AccountRatingRow }) {
|
||||
const { t } = useI18n();
|
||||
if (!row.HasNextLevel) {
|
||||
return <span className="progress-note">{t("rating.maxLevel")}</span>;
|
||||
}
|
||||
const { percent, remaining, target } = levelProgress(row);
|
||||
return (
|
||||
<div className="progress-cell">
|
||||
<div className="progress-bar" role="img" aria-label={`${Math.round(percent)}%`}>
|
||||
<span style={{ width: `${percent}%` }} />
|
||||
</div>
|
||||
<small>{t("rating.progressHint", { remaining: formatQuantity(String(remaining)), target: formatQuantity(String(target)) })}</small>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,10 +1,10 @@
|
|||
import { ChevronRight, Loader2, RefreshCw, Search } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { api, errorMessage } from "../api";
|
||||
import { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel } from "../components/ui";
|
||||
import { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel, UsernameCell } from "../components/ui";
|
||||
import { ScamFakeBadges } from "../components/flags";
|
||||
import { useI18n } from "../i18n";
|
||||
import { displayName, displayPhone, displayUsername, formatDate, formatUnix } from "../lib/format";
|
||||
import { displayName, displayPhone, formatDate, formatUnix } from "../lib/format";
|
||||
import { accountMetrics } from "../lib/metrics";
|
||||
import type { Navigate } from "../routing";
|
||||
import type { AccountListResponse } from "../types";
|
||||
|
|
@ -107,7 +107,7 @@ export function AccountsPage({ navigate }: { navigate: Navigate }) {
|
|||
<tr key={row.ID}>
|
||||
<td className="mono">{row.ID}</td>
|
||||
<td>{displayPhone(row.Phone)}</td>
|
||||
<td>{displayUsername(row.Username)}</td>
|
||||
<td><UsernameCell username={row.Username} collectibles={row.Collectibles} /></td>
|
||||
<td>{displayName(row)}</td>
|
||||
<td>{row.DeviceCount}</td>
|
||||
<td>{formatDate(row.LastActiveAt)}</td>
|
||||
|
|
|
|||
965
cmd/telesrv-admin/web/src/pages/BotVerificationPage.tsx
Normal file
965
cmd/telesrv-admin/web/src/pages/BotVerificationPage.tsx
Normal file
|
|
@ -0,0 +1,965 @@
|
|||
import {
|
||||
Ban,
|
||||
BadgeCheck,
|
||||
Building2,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
ExternalLink,
|
||||
Loader2,
|
||||
Plus,
|
||||
Power,
|
||||
PowerOff,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Stamp,
|
||||
Sticker,
|
||||
Trash2
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { api, APIError, errorMessage } from "../api";
|
||||
import { ActionButton } from "../components/ActionButton";
|
||||
import { BotPicker } from "../components/EntityPicker";
|
||||
import { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel, SectionHead } from "../components/ui";
|
||||
import { useI18n } from "../i18n";
|
||||
import { displayUsername, formatDate } from "../lib/format";
|
||||
import {
|
||||
permissionBotVerificationManage,
|
||||
permissionVerificationReview,
|
||||
usePermissions
|
||||
} from "../permissions";
|
||||
import type { Navigate } from "../routing";
|
||||
import type {
|
||||
BotRow,
|
||||
BotVerificationPeerType,
|
||||
BotVerifierRow,
|
||||
CustomVerificationRequestRow,
|
||||
CustomVerificationRequestStatus,
|
||||
CustomVerificationRow,
|
||||
VerificationIconRow
|
||||
} from "../types";
|
||||
|
||||
type Tab = "requests" | "verifiers" | "icons" | "marks";
|
||||
type StatusFilter = "all" | CustomVerificationRequestStatus;
|
||||
type PeerTypeFilter = "all" | BotVerificationPeerType;
|
||||
|
||||
const statuses: CustomVerificationRequestStatus[] = ["pending", "approved", "rejected", "revoked"];
|
||||
const peerTypes: BotVerificationPeerType[] = ["user", "channel"];
|
||||
|
||||
// The section owns four different objects — applications, verifiers, the icon
|
||||
// catalogue and the granted marks — and mixing them into one table would hide which
|
||||
// row an action addresses. They are separate tabs over one shared verifier/icon
|
||||
// load: the roster feeds three of the four filters, so it is fetched once here
|
||||
// rather than per tab.
|
||||
export function BotVerificationPage({ navigate }: { navigate: Navigate }) {
|
||||
const { t } = useI18n();
|
||||
const { can } = usePermissions();
|
||||
const canManage = can(permissionBotVerificationManage);
|
||||
const canSeeOfficial = can(permissionVerificationReview);
|
||||
const [tab, setTab] = useState<Tab>("requests");
|
||||
const [verifiers, setVerifiers] = useState<BotVerifierRow[]>([]);
|
||||
const [icons, setIcons] = useState<VerificationIconRow[]>([]);
|
||||
const [error, setError] = useState("");
|
||||
const [rosterDenied, setRosterDenied] = useState(false);
|
||||
|
||||
async function loadRoster() {
|
||||
setError("");
|
||||
setRosterDenied(false);
|
||||
try {
|
||||
const [verifierResult, iconResult] = await Promise.all([
|
||||
api.botVerifiers(new URLSearchParams({ limit: "200" })),
|
||||
api.verificationIcons(new URLSearchParams({ limit: "200" }))
|
||||
]);
|
||||
setVerifiers(verifierResult.rows ?? []);
|
||||
setIcons(iconResult.rows ?? []);
|
||||
} catch (err) {
|
||||
// A 403 here is not a fault to alarm about: it means the session may review
|
||||
// applications but not see the roster. Saying so beats an empty table that
|
||||
// reads as "no verifiers configured".
|
||||
if (err instanceof APIError && err.status === 403) {
|
||||
setVerifiers([]);
|
||||
setIcons([]);
|
||||
setRosterDenied(true);
|
||||
return;
|
||||
}
|
||||
setError(errorMessage(err));
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void loadRoster();
|
||||
}, []);
|
||||
|
||||
const tabs: Array<{ key: Tab; label: string; icon: ReactNode }> = [
|
||||
{ key: "requests", label: t("botverification.tabRequests"), icon: <Stamp size={15} /> },
|
||||
{ key: "verifiers", label: t("botverification.tabVerifiers"), icon: <Building2 size={15} /> },
|
||||
{ key: "icons", label: t("botverification.tabIcons"), icon: <Sticker size={15} /> },
|
||||
{ key: "marks", label: t("botverification.tabMarks"), icon: <BadgeCheck size={15} /> }
|
||||
];
|
||||
|
||||
return (
|
||||
<PageFrame
|
||||
title={t("botverification.pageTitle")}
|
||||
eyebrow={t("botverification.eyebrow")}
|
||||
actions={
|
||||
canSeeOfficial ? (
|
||||
<button className="btn icon-text" type="button" onClick={() => navigate("/verification")}>
|
||||
<ExternalLink size={15} /> {t("botverification.openOfficial")}
|
||||
</button>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{error && <Alert>{error}</Alert>}
|
||||
{rosterDenied && <Alert>{t("botverification.rosterDenied")}</Alert>}
|
||||
{/* The one thing an operator has to understand before touching anything here:
|
||||
this is a verifier company's own icon, not the platform checkmark. */}
|
||||
<section className="section-block">
|
||||
<SectionHead title={t("botverification.explainTitle")} text={t("botverification.explainText")} />
|
||||
<p className="bot-create-note">{t("botverification.explainIcon")}</p>
|
||||
<p className="bot-create-note">{t("botverification.explainOfficial")}</p>
|
||||
{!canManage && <p className="bot-create-note">{t("botverification.manageMissing")}</p>}
|
||||
</section>
|
||||
|
||||
<div className="toolbar" role="group" aria-label={t("botverification.pageTitle")}>
|
||||
{tabs.map((item) => (
|
||||
<button
|
||||
key={item.key}
|
||||
className={`btn icon-text ${tab === item.key ? "primary" : ""}`}
|
||||
type="button"
|
||||
aria-pressed={tab === item.key}
|
||||
onClick={() => setTab(item.key)}
|
||||
>
|
||||
{item.icon} {item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === "requests" && <RequestsBlock navigate={navigate} verifiers={verifiers} />}
|
||||
{tab === "verifiers" && (
|
||||
<VerifiersBlock
|
||||
verifiers={verifiers}
|
||||
icons={icons}
|
||||
canManage={canManage}
|
||||
onChanged={loadRoster}
|
||||
navigate={navigate}
|
||||
/>
|
||||
)}
|
||||
{tab === "icons" && (
|
||||
<IconsBlock icons={icons} verifiers={verifiers} canManage={canManage} onChanged={loadRoster} />
|
||||
)}
|
||||
{tab === "marks" && <MarksBlock verifiers={verifiers} canManage={canManage} navigate={navigate} />}
|
||||
</PageFrame>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Applications
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function RequestsBlock({ navigate, verifiers }: { navigate: Navigate; verifiers: BotVerifierRow[] }) {
|
||||
const { t } = useI18n();
|
||||
const [status, setStatus] = useState<StatusFilter>("pending");
|
||||
const [verifierBotID, setVerifierBotID] = useState("");
|
||||
const [peerType, setPeerType] = useState<PeerTypeFilter>("all");
|
||||
const [q, setQ] = useState("");
|
||||
const [limit, setLimit] = useState("50");
|
||||
const [rows, setRows] = useState<CustomVerificationRequestRow[]>([]);
|
||||
const [counts, setCounts] = useState<Record<string, string>>({});
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [cursor, setCursor] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
// One free-text field: the backend matches the application id, the peer id and a
|
||||
// username (applicant or peer), so "@durov", "42" and a peer id all work without a
|
||||
// mode switch.
|
||||
async function load(next = false) {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
const params = new URLSearchParams({ limit });
|
||||
if (status !== "all") params.set("status", status);
|
||||
if (verifierBotID) params.set("verifier_bot_id", verifierBotID);
|
||||
if (peerType !== "all") params.set("peer_type", peerType);
|
||||
if (q.trim()) params.set("q", q.trim().replace(/^@/, ""));
|
||||
if (next && cursor) params.set("before_id", cursor);
|
||||
try {
|
||||
const result = await api.customVerificationRequests(params);
|
||||
const page = result.rows ?? [];
|
||||
setRows((current) => (next ? [...current, ...page] : page));
|
||||
setCursor(result.next_before_id ?? "");
|
||||
setHasMore(Boolean(result.has_more));
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
// The counts describe the whole queue, not the current page, so they are fetched
|
||||
// separately from the keyset listing.
|
||||
async function loadCounts() {
|
||||
try {
|
||||
const result = await api.botVerificationCounts();
|
||||
setCounts(result.counts ?? {});
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void load(false);
|
||||
void loadCounts();
|
||||
}, []);
|
||||
|
||||
function refresh() {
|
||||
void load(false);
|
||||
void loadCounts();
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="section-block">
|
||||
<SectionHead
|
||||
title={t("botverification.queueTitle")}
|
||||
text={t("botverification.queueHint")}
|
||||
action={
|
||||
<button className="btn icon-text" type="button" onClick={refresh} disabled={busy}>
|
||||
<RefreshCw size={15} className={busy ? "spin" : ""} /> {t("common.refresh")}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
{error && <Alert>{error}</Alert>}
|
||||
<div className="metric-row">
|
||||
{statuses.map((item) => (
|
||||
<Metric
|
||||
key={item}
|
||||
label={t(`botverification.status.${item}`)}
|
||||
value={counts[item] ?? "0"}
|
||||
mono
|
||||
tone={countTone(item, counts[item] ?? "0")}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<QueryPanel>
|
||||
<form className="toolbar" onSubmit={(event) => { event.preventDefault(); void load(false); }}>
|
||||
<label className="searchbox">
|
||||
<Search size={15} />
|
||||
<input value={q} onChange={(event) => setQ(event.target.value)} placeholder={t("botverification.searchPlaceholder")} />
|
||||
</label>
|
||||
<label className="field-inline">
|
||||
<span>{t("common.status")}</span>
|
||||
<select value={status} onChange={(event) => setStatus(event.target.value as StatusFilter)}>
|
||||
<option value="all">{t("botverification.statusAll")}</option>
|
||||
{statuses.map((item) => (
|
||||
<option key={item} value={item}>{t(`botverification.status.${item}`)}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="field-inline">
|
||||
<span>{t("botverification.verifier")}</span>
|
||||
<VerifierOptions value={verifierBotID} verifiers={verifiers} onChange={setVerifierBotID} />
|
||||
</label>
|
||||
<label className="field-inline">
|
||||
<span>{t("botverification.peerType")}</span>
|
||||
<select value={peerType} onChange={(event) => setPeerType(event.target.value as PeerTypeFilter)}>
|
||||
<option value="all">{t("botverification.peerTypeAll")}</option>
|
||||
{peerTypes.map((item) => (
|
||||
<option key={item} value={item}>{t(`botverification.peer.${item}`)}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="field-inline">
|
||||
<span>{t("common.limit")}</span>
|
||||
<input className="small-input" value={limit} onChange={(event) => setLimit(event.target.value)} type="number" min="1" max="200" />
|
||||
</label>
|
||||
<button className="btn primary icon-text" type="submit" disabled={busy}>
|
||||
{busy ? <Loader2 size={15} className="spin" /> : <Search size={15} />} {t("common.search")}
|
||||
</button>
|
||||
</form>
|
||||
</QueryPanel>
|
||||
|
||||
<div className="table-wrap">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("common.id")}</th>
|
||||
<th>{t("botverification.verifier")}</th>
|
||||
<th>{t("botverification.target")}</th>
|
||||
<th>{t("botverification.applicant")}</th>
|
||||
<th>{t("botverification.reason")}</th>
|
||||
<th>{t("common.status")}</th>
|
||||
<th>{t("botverification.createdAt")}</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<tr key={row.ID}>
|
||||
<td className="mono">
|
||||
<button className="row-link" type="button" onClick={() => navigate(`/bot-verification/${row.ID}`)}>
|
||||
#{row.ID}
|
||||
</button>
|
||||
</td>
|
||||
<td>
|
||||
<strong>{displayUsername(row.VerifierBotUsername) || row.VerifierBotID}</strong>
|
||||
<div className="entity-subtitle mono">{row.VerifierBotID}</div>
|
||||
</td>
|
||||
<td>
|
||||
<strong>{peerLabel(row)}</strong>
|
||||
<div className="entity-subtitle mono">
|
||||
{t(`botverification.peer.${row.PeerType}`)} · {row.PeerID}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
{displayUsername(row.ApplicantUsername) || "-"}
|
||||
<div className="entity-subtitle mono">{row.ApplicantUserID}</div>
|
||||
</td>
|
||||
<td className="truncate">{row.Reason || "-"}</td>
|
||||
<td><RequestStatusBadge status={row.Status} /></td>
|
||||
<td>{formatDate(row.CreatedAt) || "-"}</td>
|
||||
<td>
|
||||
<button className="row-link" type="button" onClick={() => navigate(`/bot-verification/${row.ID}`)}>
|
||||
<Stamp size={14} /> {t("common.detail")} <ChevronRight size={14} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{rows.length === 0 && <EmptyRow colSpan={8} />}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{hasMore && (
|
||||
<div className="toolbar">
|
||||
<button className="btn icon-text" type="button" onClick={() => load(true)} disabled={busy}>
|
||||
{busy ? <Loader2 size={15} className="spin" /> : <ChevronDown size={15} />} {t("common.loadMore")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Verifiers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function VerifiersBlock({
|
||||
verifiers,
|
||||
icons,
|
||||
canManage,
|
||||
onChanged,
|
||||
navigate
|
||||
}: {
|
||||
verifiers: BotVerifierRow[];
|
||||
icons: VerificationIconRow[];
|
||||
canManage: boolean;
|
||||
onChanged: () => void;
|
||||
navigate: Navigate;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const [bot, setBot] = useState<BotRow | null>(null);
|
||||
// editing carries the bot id of the row being updated: the grant endpoint is an
|
||||
// upsert, and version is the optimistic lock of the row it overwrites. A fresh
|
||||
// grant sends "0", which is what "there is no row yet" means.
|
||||
const [editing, setEditing] = useState<BotVerifierRow | null>(null);
|
||||
const [iconDocumentID, setIconDocumentID] = useState("");
|
||||
const [company, setCompany] = useState("");
|
||||
const [defaultDescription, setDefaultDescription] = useState("");
|
||||
const [canModify, setCanModify] = useState(false);
|
||||
const activeIcons = icons.filter((icon) => icon.Active);
|
||||
// A verifier can hold an icon the operator has since retired. Editing that row must
|
||||
// not silently swap the icon just because the select has no matching option, so the
|
||||
// current document is kept in the list and labelled instead.
|
||||
const iconOptions: Array<{ value: string; label: string }> = activeIcons.map((icon) => ({
|
||||
value: icon.DocumentID,
|
||||
label: `${icon.Name} · ${icon.DocumentID}`
|
||||
}));
|
||||
if (iconDocumentID && !iconOptions.some((option) => option.value === iconDocumentID)) {
|
||||
const retired = icons.find((icon) => icon.DocumentID === iconDocumentID);
|
||||
iconOptions.unshift({
|
||||
value: iconDocumentID,
|
||||
label: `${retired?.Name ?? iconDocumentID} · ${iconDocumentID} (${t("botverification.iconInactive")})`
|
||||
});
|
||||
}
|
||||
|
||||
function startEdit(row: BotVerifierRow) {
|
||||
setEditing(row);
|
||||
setBot(null);
|
||||
setIconDocumentID(row.IconDocumentID);
|
||||
setCompany(row.CompanyName);
|
||||
setDefaultDescription(row.DefaultDescription);
|
||||
setCanModify(row.CanModifyCustomDescription);
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
setEditing(null);
|
||||
setBot(null);
|
||||
setIconDocumentID("");
|
||||
setCompany("");
|
||||
setDefaultDescription("");
|
||||
setCanModify(false);
|
||||
}
|
||||
|
||||
// int64 fields go out as decimal strings (the backend tags them `,string`), which
|
||||
// is also the shape they arrived in, so nothing is re-parsed on the way back.
|
||||
function grantPayload(): Record<string, unknown> {
|
||||
const botID = editing ? editing.BotID : bot ? String(bot.ID) : "0";
|
||||
return {
|
||||
bot_id: botID,
|
||||
icon_document_id: iconDocumentID || "0",
|
||||
company_name: company.trim(),
|
||||
default_description: defaultDescription.trim(),
|
||||
can_modify_custom_description: canModify,
|
||||
version: editing ? editing.Version : "0"
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{canManage && (
|
||||
<section className="section-block">
|
||||
<SectionHead
|
||||
title={editing ? t("botverification.updateTitle") : t("botverification.grantTitle")}
|
||||
text={t("botverification.grantHint")}
|
||||
action={
|
||||
editing ? (
|
||||
<button className="btn icon-text" type="button" onClick={resetForm}>
|
||||
{t("botverification.cancelEdit")}
|
||||
</button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
{editing ? (
|
||||
<p className="bot-create-note">
|
||||
{t("botverification.editing", {
|
||||
bot: displayUsername(editing.BotUsername) || editing.BotID,
|
||||
version: editing.Version
|
||||
})}
|
||||
</p>
|
||||
) : (
|
||||
<BotPicker label={t("botverification.grantBot")} value={bot} onChange={setBot} />
|
||||
)}
|
||||
<div className="bot-create-fields">
|
||||
<label className="duration-field">
|
||||
<span>{t("botverification.grantIcon")}</span>
|
||||
<select value={iconDocumentID} onChange={(event) => setIconDocumentID(event.target.value)}>
|
||||
<option value="">{t("botverification.grantIconPick")}</option>
|
||||
{iconOptions.map((option) => (
|
||||
<option key={option.value} value={option.value}>{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="duration-field">
|
||||
<span>{t("botverification.company")}</span>
|
||||
<input
|
||||
value={company}
|
||||
onChange={(event) => setCompany(event.target.value)}
|
||||
placeholder={t("botverification.companyPlaceholder")}
|
||||
/>
|
||||
</label>
|
||||
<label className="duration-field">
|
||||
<span>{t("botverification.defaultDescription")}</span>
|
||||
<input
|
||||
value={defaultDescription}
|
||||
onChange={(event) => setDefaultDescription(event.target.value)}
|
||||
placeholder={t("botverification.defaultDescriptionPlaceholder")}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<label className="checkline">
|
||||
<input type="checkbox" checked={canModify} onChange={(event) => setCanModify(event.target.checked)} />
|
||||
{t("botverification.canModify")}
|
||||
</label>
|
||||
<p className="bot-create-note">{t("botverification.canModifyHint")}</p>
|
||||
{activeIcons.length === 0 && <Alert>{t("botverification.noActiveIcons")}</Alert>}
|
||||
<div className="bot-create-actions">
|
||||
<span className="bot-create-note">{t("botverification.grantNote")}</span>
|
||||
<ActionButton
|
||||
label={editing ? t("botverification.update") : t("botverification.grant")}
|
||||
icon={<Plus size={15} />}
|
||||
tone="neutral"
|
||||
path="/api/actions/grant-bot-verifier"
|
||||
payload={grantPayload}
|
||||
onDone={() => {
|
||||
resetForm();
|
||||
onChanged();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="section-block">
|
||||
<SectionHead
|
||||
title={t("botverification.verifiersTitle")}
|
||||
text={t("botverification.verifiersHint")}
|
||||
action={
|
||||
<button className="btn icon-text" type="button" onClick={onChanged}>
|
||||
<RefreshCw size={15} /> {t("common.refresh")}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<div className="table-wrap">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("botverification.bot")}</th>
|
||||
<th>{t("botverification.company")}</th>
|
||||
<th>{t("botverification.icon")}</th>
|
||||
<th>{t("botverification.canModifyShort")}</th>
|
||||
<th>{t("common.status")}</th>
|
||||
<th>{t("botverification.markCount")}</th>
|
||||
<th>{t("botverification.grantedBy")}</th>
|
||||
<th>{t("common.updatedAt")}</th>
|
||||
{canManage && <th></th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{verifiers.map((row) => (
|
||||
<tr key={row.BotID}>
|
||||
<td>
|
||||
<button className="row-link" type="button" onClick={() => navigate(`/bots/${row.BotID}`)}>
|
||||
<strong>{displayUsername(row.BotUsername) || row.BotName || row.BotID}</strong>
|
||||
</button>
|
||||
<div className="entity-subtitle mono">{row.BotID}</div>
|
||||
</td>
|
||||
<td>
|
||||
<strong>{row.CompanyName || "-"}</strong>
|
||||
<div className="entity-subtitle truncate">{row.DefaultDescription || t("botverification.notProvided")}</div>
|
||||
</td>
|
||||
<td>
|
||||
{row.IconName || "-"}
|
||||
<div className="entity-subtitle mono">{row.IconDocumentID}</div>
|
||||
</td>
|
||||
<td>{row.CanModifyCustomDescription ? t("common.yes") : t("common.no")}</td>
|
||||
<td>
|
||||
{row.Enabled
|
||||
? <Badge tone="good">{t("botverification.enabled")}</Badge>
|
||||
: <Badge tone="warn">{t("botverification.disabled")}</Badge>}
|
||||
</td>
|
||||
<td className="mono">{String(row.MarkCount ?? "0")}</td>
|
||||
<td>
|
||||
{row.GrantedBy || "-"}
|
||||
<div className="entity-subtitle truncate">{row.GrantReason || "-"}</div>
|
||||
</td>
|
||||
<td>{formatDate(row.UpdatedAt) || "-"}</td>
|
||||
{canManage && (
|
||||
<td>
|
||||
<div className="row-actions">
|
||||
<button className="btn compact-btn" type="button" onClick={() => startEdit(row)}>
|
||||
{t("botverification.edit")}
|
||||
</button>
|
||||
<ActionButton
|
||||
label={row.Enabled ? t("botverification.disable") : t("botverification.enable")}
|
||||
icon={row.Enabled ? <PowerOff size={14} /> : <Power size={14} />}
|
||||
tone={row.Enabled ? "warn" : "neutral"}
|
||||
compact
|
||||
path="/api/actions/set-bot-verifier-enabled"
|
||||
payload={() => ({ bot_id: row.BotID, enabled: !row.Enabled })}
|
||||
onDone={onChanged}
|
||||
/>
|
||||
<ActionButton
|
||||
label={t("botverification.revokeVerifier")}
|
||||
icon={<Trash2 size={14} />}
|
||||
tone="danger"
|
||||
compact
|
||||
path="/api/actions/revoke-bot-verifier"
|
||||
payload={() => ({ bot_id: row.BotID })}
|
||||
onDone={onChanged}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
{verifiers.length === 0 && <EmptyRow colSpan={canManage ? 9 : 8} />}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p className="bot-create-note">{t("botverification.disableHint")}</p>
|
||||
<p className="bot-create-note">{t("botverification.revokeVerifierHint")}</p>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Icon catalogue
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function IconsBlock({
|
||||
icons,
|
||||
verifiers,
|
||||
canManage,
|
||||
onChanged
|
||||
}: {
|
||||
icons: VerificationIconRow[];
|
||||
verifiers: BotVerifierRow[];
|
||||
canManage: boolean;
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const [documentID, setDocumentID] = useState("");
|
||||
const [name, setName] = useState("");
|
||||
const [ownerBotID, setOwnerBotID] = useState("");
|
||||
|
||||
// owner_bot_id is omitted entirely for a shared entry rather than sent as "" —
|
||||
// `,string,omitempty` cannot decode an empty string.
|
||||
function iconPayload(): Record<string, unknown> {
|
||||
const payload: Record<string, unknown> = {
|
||||
document_id: documentID.trim() || "0",
|
||||
name: name.trim()
|
||||
};
|
||||
if (ownerBotID) payload.owner_bot_id = ownerBotID;
|
||||
return payload;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{canManage && (
|
||||
<section className="section-block">
|
||||
<SectionHead title={t("botverification.addIconTitle")} text={t("botverification.addIconHint")} />
|
||||
<div className="bot-create-fields">
|
||||
<label className="duration-field">
|
||||
<span>{t("botverification.iconDocument")}</span>
|
||||
<input
|
||||
value={documentID}
|
||||
onChange={(event) => setDocumentID(event.target.value)}
|
||||
inputMode="numeric"
|
||||
placeholder="5361371319611781774"
|
||||
/>
|
||||
</label>
|
||||
<label className="duration-field">
|
||||
<span>{t("botverification.iconName")}</span>
|
||||
<input
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
placeholder={t("botverification.iconNamePlaceholder")}
|
||||
/>
|
||||
</label>
|
||||
<label className="duration-field">
|
||||
<span>{t("botverification.iconOwner")}</span>
|
||||
<select value={ownerBotID} onChange={(event) => setOwnerBotID(event.target.value)}>
|
||||
<option value="">{t("botverification.iconOwnerShared")}</option>
|
||||
{verifiers.map((row) => (
|
||||
<option key={row.BotID} value={row.BotID}>
|
||||
{`${row.CompanyName || row.BotID} · ${displayUsername(row.BotUsername) || row.BotID}`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<p className="bot-create-note">{t("botverification.iconDocumentHint")}</p>
|
||||
<p className="bot-create-note">{t("botverification.iconOwnerHint")}</p>
|
||||
<div className="bot-create-actions">
|
||||
<span className="bot-create-note">{t("botverification.addIconNote")}</span>
|
||||
<ActionButton
|
||||
label={t("botverification.addIcon")}
|
||||
icon={<Plus size={15} />}
|
||||
tone="neutral"
|
||||
path="/api/actions/upsert-verification-icon"
|
||||
payload={iconPayload}
|
||||
onDone={() => {
|
||||
setDocumentID("");
|
||||
setName("");
|
||||
setOwnerBotID("");
|
||||
onChanged();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="section-block">
|
||||
<SectionHead
|
||||
title={t("botverification.iconsTitle")}
|
||||
text={t("botverification.iconsHint")}
|
||||
action={
|
||||
<button className="btn icon-text" type="button" onClick={onChanged}>
|
||||
<RefreshCw size={15} /> {t("common.refresh")}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<div className="table-wrap">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("botverification.iconDocument")}</th>
|
||||
<th>{t("botverification.iconName")}</th>
|
||||
<th>{t("botverification.iconOwner")}</th>
|
||||
<th>{t("common.status")}</th>
|
||||
<th>{t("botverification.usedBy")}</th>
|
||||
<th>{t("botverification.createdAt")}</th>
|
||||
{canManage && <th></th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{icons.map((row) => (
|
||||
<tr key={row.ID}>
|
||||
<td className="mono">{row.DocumentID}</td>
|
||||
<td><strong>{row.Name || "-"}</strong></td>
|
||||
<td>
|
||||
{row.OwnerBotID && row.OwnerBotID !== "0"
|
||||
? <>
|
||||
{displayUsername(row.OwnerBotUsername) || row.OwnerBotID}
|
||||
<div className="entity-subtitle mono">{row.OwnerBotID}</div>
|
||||
</>
|
||||
: <Badge>{t("botverification.iconOwnerShared")}</Badge>}
|
||||
</td>
|
||||
<td>
|
||||
{row.Active
|
||||
? <Badge tone="good">{t("botverification.iconActive")}</Badge>
|
||||
: <Badge tone="warn">{t("botverification.iconInactive")}</Badge>}
|
||||
</td>
|
||||
<td className="mono">{String(row.UsedByVerifiers ?? "0")}</td>
|
||||
<td>{formatDate(row.CreatedAt) || "-"}</td>
|
||||
{canManage && (
|
||||
<td>
|
||||
<div className="row-actions">
|
||||
<ActionButton
|
||||
label={row.Active ? t("botverification.deactivateIcon") : t("botverification.activateIcon")}
|
||||
icon={row.Active ? <PowerOff size={14} /> : <Power size={14} />}
|
||||
tone={row.Active ? "warn" : "neutral"}
|
||||
compact
|
||||
path="/api/actions/set-verification-icon-active"
|
||||
payload={() => ({ icon_id: row.ID, active: !row.Active })}
|
||||
onDone={onChanged}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
{icons.length === 0 && <EmptyRow colSpan={canManage ? 7 : 6} />}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p className="bot-create-note">{t("botverification.deactivateIconHint")}</p>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Granted marks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function MarksBlock({
|
||||
verifiers,
|
||||
canManage,
|
||||
navigate
|
||||
}: {
|
||||
verifiers: BotVerifierRow[];
|
||||
canManage: boolean;
|
||||
navigate: Navigate;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const [verifierBotID, setVerifierBotID] = useState("");
|
||||
const [peerType, setPeerType] = useState<PeerTypeFilter>("all");
|
||||
const [q, setQ] = useState("");
|
||||
const [limit, setLimit] = useState("50");
|
||||
const [rows, setRows] = useState<CustomVerificationRow[]>([]);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [cursor, setCursor] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
async function load(next = false) {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
const params = new URLSearchParams({ limit });
|
||||
if (verifierBotID) params.set("verifier_bot_id", verifierBotID);
|
||||
if (peerType !== "all") params.set("peer_type", peerType);
|
||||
if (q.trim()) params.set("q", q.trim().replace(/^@/, ""));
|
||||
if (next && cursor) params.set("before_id", cursor);
|
||||
try {
|
||||
const result = await api.customVerifications(params);
|
||||
const page = result.rows ?? [];
|
||||
setRows((current) => (next ? [...current, ...page] : page));
|
||||
setCursor(result.next_before_id ?? "");
|
||||
setHasMore(Boolean(result.has_more));
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void load(false);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="section-block">
|
||||
<SectionHead
|
||||
title={t("botverification.marksTitle")}
|
||||
text={t("botverification.marksHint")}
|
||||
action={
|
||||
<button className="btn icon-text" type="button" onClick={() => load(false)} disabled={busy}>
|
||||
<RefreshCw size={15} className={busy ? "spin" : ""} /> {t("common.refresh")}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
{error && <Alert>{error}</Alert>}
|
||||
</section>
|
||||
|
||||
<QueryPanel>
|
||||
<form className="toolbar" onSubmit={(event) => { event.preventDefault(); void load(false); }}>
|
||||
<label className="searchbox">
|
||||
<Search size={15} />
|
||||
<input value={q} onChange={(event) => setQ(event.target.value)} placeholder={t("botverification.markSearchPlaceholder")} />
|
||||
</label>
|
||||
<label className="field-inline">
|
||||
<span>{t("botverification.verifier")}</span>
|
||||
<VerifierOptions value={verifierBotID} verifiers={verifiers} onChange={setVerifierBotID} />
|
||||
</label>
|
||||
<label className="field-inline">
|
||||
<span>{t("botverification.peerType")}</span>
|
||||
<select value={peerType} onChange={(event) => setPeerType(event.target.value as PeerTypeFilter)}>
|
||||
<option value="all">{t("botverification.peerTypeAll")}</option>
|
||||
{peerTypes.map((item) => (
|
||||
<option key={item} value={item}>{t(`botverification.peer.${item}`)}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="field-inline">
|
||||
<span>{t("common.limit")}</span>
|
||||
<input className="small-input" value={limit} onChange={(event) => setLimit(event.target.value)} type="number" min="1" max="200" />
|
||||
</label>
|
||||
<button className="btn primary icon-text" type="submit" disabled={busy}>
|
||||
{busy ? <Loader2 size={15} className="spin" /> : <Search size={15} />} {t("common.search")}
|
||||
</button>
|
||||
</form>
|
||||
</QueryPanel>
|
||||
|
||||
<div className="table-wrap">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("common.id")}</th>
|
||||
<th>{t("botverification.verifier")}</th>
|
||||
<th>{t("botverification.target")}</th>
|
||||
<th>{t("botverification.description")}</th>
|
||||
<th>{t("botverification.icon")}</th>
|
||||
<th>{t("botverification.createdAt")}</th>
|
||||
{canManage && <th></th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<tr key={row.ID}>
|
||||
<td className="mono">#{row.ID}</td>
|
||||
<td>
|
||||
<strong>{row.CompanyName || displayUsername(row.VerifierBotUsername) || row.VerifierBotID}</strong>
|
||||
<div className="entity-subtitle mono">
|
||||
{displayUsername(row.VerifierBotUsername) || row.VerifierBotID}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<button className="row-link" type="button" onClick={() => navigate(peerHref(row.PeerType, row.PeerID))}>
|
||||
<strong>{peerLabel(row)}</strong>
|
||||
</button>
|
||||
<div className="entity-subtitle mono">
|
||||
{t(`botverification.peer.${row.PeerType}`)} · {row.PeerID}
|
||||
</div>
|
||||
</td>
|
||||
<td className="truncate">{row.Description || t("botverification.notProvided")}</td>
|
||||
<td className="mono">{row.IconDocumentID}</td>
|
||||
<td>{formatDate(row.CreatedAt) || "-"}</td>
|
||||
{canManage && (
|
||||
<td>
|
||||
<div className="row-actions">
|
||||
<ActionButton
|
||||
label={t("botverification.revokeMark")}
|
||||
icon={<Ban size={14} />}
|
||||
tone="danger"
|
||||
compact
|
||||
path="/api/actions/revoke-custom-verification"
|
||||
payload={() => ({
|
||||
verifier_bot_id: row.VerifierBotID,
|
||||
peer_type: row.PeerType,
|
||||
peer_id: row.PeerID
|
||||
})}
|
||||
onDone={() => load(false)}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
{rows.length === 0 && <EmptyRow colSpan={canManage ? 7 : 6} />}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p className="bot-create-note">{t("botverification.revokeMarkHint")}</p>
|
||||
{hasMore && (
|
||||
<div className="toolbar">
|
||||
<button className="btn icon-text" type="button" onClick={() => load(true)} disabled={busy}>
|
||||
{busy ? <Loader2 size={15} className="spin" /> : <ChevronDown size={15} />} {t("common.loadMore")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared bits
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// The verifier filter lists the roster rather than asking for a bot id: a company
|
||||
// name is what an operator reads in the queue, and a disabled verifier still owns
|
||||
// rows worth filtering by, so it stays in the list and is labelled instead.
|
||||
function VerifierOptions({
|
||||
value,
|
||||
verifiers,
|
||||
onChange
|
||||
}: {
|
||||
value: string;
|
||||
verifiers: BotVerifierRow[];
|
||||
onChange: (value: string) => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<select value={value} onChange={(event) => onChange(event.target.value)}>
|
||||
<option value="">{t("botverification.verifierAll")}</option>
|
||||
{verifiers.map((row) => (
|
||||
<option key={row.BotID} value={row.BotID}>
|
||||
{`${row.CompanyName || row.BotID} · ${displayUsername(row.BotUsername) || row.BotID}`
|
||||
+ (row.Enabled ? "" : ` (${t("botverification.disabled")})`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
export function RequestStatusBadge({ status }: { status: CustomVerificationRequestStatus }) {
|
||||
const { t } = useI18n();
|
||||
return <Badge tone={statusTone(status)}>{t(`botverification.status.${status}`)}</Badge>;
|
||||
}
|
||||
|
||||
export function statusTone(status: CustomVerificationRequestStatus): "neutral" | "good" | "warn" | "danger" {
|
||||
if (status === "approved") return "good";
|
||||
if (status === "pending") return "warn";
|
||||
if (status === "rejected") return "danger";
|
||||
return "neutral";
|
||||
}
|
||||
|
||||
// pending is the only status that waits for somebody, so it is the only one
|
||||
// highlighted — and only while something actually sits in it.
|
||||
function countTone(status: CustomVerificationRequestStatus, count: string): "neutral" | "good" | "warn" {
|
||||
if (status === "pending") return count !== "0" && count !== "" ? "warn" : "neutral";
|
||||
return status === "approved" ? "good" : "neutral";
|
||||
}
|
||||
|
||||
export function peerLabel(row: { PeerUsername: string; PeerTitle: string; PeerID: string }): string {
|
||||
return displayUsername(row.PeerUsername) || row.PeerTitle || `#${row.PeerID}`;
|
||||
}
|
||||
|
||||
// The panel page that owns the peer type. A third-party mark can sit on an ordinary
|
||||
// account or on a bot — both are user rows, so both open the account page.
|
||||
export function peerHref(peerType: BotVerificationPeerType, peerID: string): string {
|
||||
return peerType === "channel" ? `/channels/${peerID}` : `/accounts/${peerID}`;
|
||||
}
|
||||
360
cmd/telesrv-admin/web/src/pages/BotVerificationRequestPage.tsx
Normal file
360
cmd/telesrv-admin/web/src/pages/BotVerificationRequestPage.tsx
Normal file
|
|
@ -0,0 +1,360 @@
|
|||
import {
|
||||
ArrowLeft,
|
||||
BadgeCheck,
|
||||
Ban,
|
||||
Building2,
|
||||
CheckCircle2,
|
||||
ExternalLink,
|
||||
RefreshCw,
|
||||
ShieldOff,
|
||||
Stamp,
|
||||
User,
|
||||
XCircle
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { api, APIError, errorMessage } from "../api";
|
||||
import { ActionButton } from "../components/ActionButton";
|
||||
import { Alert, Badge, LoadingSurface, PageFrame, SectionHead, SplitLayout, Summary } from "../components/ui";
|
||||
import { useI18n } from "../i18n";
|
||||
import { displayUsername, formatDate } from "../lib/format";
|
||||
import type { Navigate } from "../routing";
|
||||
import type { BotVerifierRow, CustomVerificationRequestDetail } from "../types";
|
||||
import { RequestStatusBadge, peerHref, peerLabel } from "./BotVerificationPage";
|
||||
|
||||
export function BotVerificationRequestPage({ id, navigate }: { id: string; navigate: Navigate }) {
|
||||
const { t } = useI18n();
|
||||
const [detail, setDetail] = useState<CustomVerificationRequestDetail | null>(null);
|
||||
const [note, setNote] = useState("");
|
||||
const [conflict, setConflict] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
async function load() {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
setDetail(await api.customVerificationRequest(id));
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
setConflict(false);
|
||||
void load();
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [id]);
|
||||
|
||||
// 409 is the one failure the operator cannot fix by editing the form: another
|
||||
// admin decided against the version this page read. The panel says so in plain
|
||||
// words and reloads, so the next attempt carries the current version.
|
||||
function handleActionError(err: unknown): string | undefined {
|
||||
if (err instanceof APIError && err.status === 409) {
|
||||
setConflict(true);
|
||||
void load();
|
||||
return t("botverification.conflict");
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (error && !detail) {
|
||||
return <Alert>{error}</Alert>;
|
||||
}
|
||||
if (!detail) {
|
||||
return <LoadingSurface label={t("botverification.loadingDetail")} />;
|
||||
}
|
||||
|
||||
const request = detail.request;
|
||||
const verifier = liveVerifier(detail.verifier);
|
||||
const markActive = detail.mark_active;
|
||||
const canDecide = request.Status === "pending";
|
||||
const canRevoke = request.Status === "approved";
|
||||
const trimmedNote = note.trim();
|
||||
// What the mark would actually say: the applicant's wording only when this
|
||||
// verifier is allowed to override its own default, otherwise the default. Same
|
||||
// rule the backend applies (BotVerifierSettings.DescriptionFor), shown here so a
|
||||
// reviewer is not surprised by the text that ends up in the profile.
|
||||
const requestedDescription = request.RequestedDescription.trim();
|
||||
const descriptionAllowed = Boolean(verifier?.CanModifyCustomDescription) && requestedDescription !== "";
|
||||
const effectiveDescription = descriptionAllowed
|
||||
? requestedDescription
|
||||
: (verifier?.DefaultDescription ?? "").trim();
|
||||
|
||||
// version is the optimistic-locking token: it goes with every decision, as the
|
||||
// decimal string it arrived as, so a stale page cannot overwrite a fresh one.
|
||||
function decisionPayload(): Record<string, unknown> {
|
||||
const payload: Record<string, unknown> = { version: request.Version };
|
||||
if (trimmedNote) payload.internal_note = trimmedNote;
|
||||
return payload;
|
||||
}
|
||||
|
||||
function afterDecision() {
|
||||
setNote("");
|
||||
setConflict(false);
|
||||
void load();
|
||||
}
|
||||
|
||||
return (
|
||||
<PageFrame
|
||||
title={t("botverification.detailTitle", { id: request.ID })}
|
||||
eyebrow={t("botverification.detailEyebrow")}
|
||||
actions={
|
||||
<>
|
||||
<button className="btn icon-text" type="button" onClick={() => navigate("/bot-verification")}>
|
||||
<ArrowLeft size={15} /> {t("common.backToList")}
|
||||
</button>
|
||||
<button className="btn icon-text" type="button" onClick={refresh} disabled={busy}>
|
||||
<RefreshCw size={15} className={busy ? "spin" : ""} /> {t("common.refresh")}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{error && <Alert>{error}</Alert>}
|
||||
{conflict && <Alert>{t("botverification.conflict")}</Alert>}
|
||||
<SplitLayout
|
||||
main={
|
||||
<div className="stacked-sections">
|
||||
<section className="entity-head">
|
||||
<div>
|
||||
<div className="entity-title">{peerLabel(request)}</div>
|
||||
<div className="entity-subtitle mono">
|
||||
#{request.ID} · {t(`botverification.peer.${request.PeerType}`)}:{request.PeerID} · v{request.Version}
|
||||
</div>
|
||||
</div>
|
||||
<div className="entity-badges">
|
||||
<RequestStatusBadge status={request.Status} />
|
||||
{markActive
|
||||
? <Badge tone="good"><BadgeCheck size={12} /> {t("botverification.markActive")}</Badge>
|
||||
: <Badge tone="neutral">{t("botverification.markInactive")}</Badge>}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Repeated on the detail page on purpose: the decision an operator is
|
||||
about to take grants a company's icon, not the platform badge. */}
|
||||
<section className="section-block">
|
||||
<SectionHead title={t("botverification.explainTitle")} text={t("botverification.explainText")} />
|
||||
<p className="bot-create-note">{t("botverification.explainIcon")}</p>
|
||||
</section>
|
||||
|
||||
<section className="section-block">
|
||||
<SectionHead
|
||||
title={t("botverification.verifierSection")}
|
||||
text={t("botverification.verifierHint")}
|
||||
action={
|
||||
<button className="btn icon-text" type="button" onClick={() => navigate(`/bots/${request.VerifierBotID}`)}>
|
||||
<Building2 size={15} /> {t("botverification.openVerifier")}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<div className="summary-grid">
|
||||
<Summary label={t("botverification.company")} value={verifier?.CompanyName || "-"} />
|
||||
<Summary label={t("botverification.bot")} value={displayUsername(request.VerifierBotUsername) || "-"} />
|
||||
<Summary label={t("botverification.verifierID")} value={request.VerifierBotID} mono />
|
||||
<Summary label={t("botverification.iconDocument")} value={verifier?.IconDocumentID || "-"} mono />
|
||||
<Summary label={t("botverification.iconName")} value={verifier?.IconName || "-"} />
|
||||
<Summary
|
||||
label={t("botverification.canModifyShort")}
|
||||
value={verifier?.CanModifyCustomDescription ? t("common.yes") : t("common.no")}
|
||||
/>
|
||||
</div>
|
||||
<FieldBlock label={t("botverification.defaultDescription")}>
|
||||
{verifier?.DefaultDescription
|
||||
? <p className="about-text">{verifier.DefaultDescription}</p>
|
||||
: <p className="bot-create-note">{t("botverification.notProvided")}</p>}
|
||||
</FieldBlock>
|
||||
{!verifier && <Alert>{t("botverification.verifierMissing")}</Alert>}
|
||||
{verifier && !verifier.Enabled && <Alert>{t("botverification.verifierDisabledHint")}</Alert>}
|
||||
</section>
|
||||
|
||||
<section className="section-block">
|
||||
<SectionHead
|
||||
title={t("botverification.targetSection")}
|
||||
text={t("botverification.targetHint")}
|
||||
action={
|
||||
<button
|
||||
className="btn icon-text"
|
||||
type="button"
|
||||
onClick={() => navigate(peerHref(request.PeerType, request.PeerID))}
|
||||
>
|
||||
<ExternalLink size={15} /> {t("botverification.openTarget")}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<div className="summary-grid">
|
||||
<Summary label={t("common.type")} value={t(`botverification.peer.${request.PeerType}`)} />
|
||||
<Summary label={t("common.username")} value={displayUsername(request.PeerUsername) || "-"} />
|
||||
<Summary label={t("botverification.targetTitle")} value={request.PeerTitle || "-"} />
|
||||
<Summary label={t("botverification.targetID")} value={request.PeerID} mono />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="section-block">
|
||||
<SectionHead
|
||||
title={t("botverification.applicantSection")}
|
||||
text={t("botverification.applicantHint")}
|
||||
action={
|
||||
<button
|
||||
className="btn icon-text"
|
||||
type="button"
|
||||
onClick={() => navigate(`/accounts/${request.ApplicantUserID}`)}
|
||||
>
|
||||
<User size={15} /> {t("botverification.openApplicant")}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<div className="summary-grid">
|
||||
<Summary label={t("common.username")} value={displayUsername(request.ApplicantUsername) || "-"} />
|
||||
<Summary label={t("botverification.applicantID")} value={request.ApplicantUserID} mono />
|
||||
<Summary label={t("botverification.createdAt")} value={formatDate(request.CreatedAt) || "-"} />
|
||||
<Summary label={t("common.updatedAt")} value={formatDate(request.UpdatedAt) || "-"} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="section-block">
|
||||
<SectionHead title={t("botverification.requestSection")} text={t("botverification.requestHint")} />
|
||||
<div className="stacked-sections">
|
||||
<div className="summary-grid">
|
||||
<Summary label={t("botverification.correlationID")} value={request.CorrelationID || "-"} mono />
|
||||
<Summary label={t("common.status")} value={t(`botverification.status.${request.Status}`)} />
|
||||
</div>
|
||||
<FieldBlock label={t("botverification.reason")}>
|
||||
{request.Reason
|
||||
? <p className="about-text">{request.Reason}</p>
|
||||
: <p className="bot-create-note">{t("botverification.notProvided")}</p>}
|
||||
</FieldBlock>
|
||||
<FieldBlock label={t("botverification.requestedDescription")}>
|
||||
{requestedDescription
|
||||
? <p className="about-text">{requestedDescription}</p>
|
||||
: <p className="bot-create-note">{t("botverification.notProvided")}</p>}
|
||||
</FieldBlock>
|
||||
<FieldBlock label={t("botverification.markPreview")}>
|
||||
{effectiveDescription
|
||||
? <p className="about-text">{effectiveDescription}</p>
|
||||
: <p className="bot-create-note">{t("botverification.notProvided")}</p>}
|
||||
</FieldBlock>
|
||||
<p className="bot-create-note">{t("botverification.markPreviewHint")}</p>
|
||||
{requestedDescription !== "" && !descriptionAllowed && (
|
||||
<p className="bot-create-note">{t("botverification.descriptionIgnoredHint")}</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="section-block">
|
||||
<SectionHead title={t("botverification.decisionSection")} text={t("botverification.decisionHint")} />
|
||||
<div className="stacked-sections">
|
||||
<div className="summary-grid">
|
||||
<Summary label={t("botverification.decidedBy")} value={request.DecidedBy || "-"} />
|
||||
<Summary label={t("botverification.approvedAt")} value={formatDate(request.ApprovedAt) || "-"} />
|
||||
<Summary label={t("botverification.rejectedAt")} value={formatDate(request.RejectedAt) || "-"} />
|
||||
<Summary label={t("botverification.version")} value={request.Version} mono />
|
||||
</div>
|
||||
<FieldBlock label={t("botverification.decisionReason")}>
|
||||
{request.DecisionReason
|
||||
? <p className="about-text">{request.DecisionReason}</p>
|
||||
: <p className="bot-create-note">{t("botverification.noDecision")}</p>}
|
||||
</FieldBlock>
|
||||
{/* The internal note is the operator handover text and is labelled as
|
||||
admin-only wherever it appears. */}
|
||||
<FieldBlock label={`${t("botverification.internalNote")} · ${t("botverification.adminOnly")}`}>
|
||||
{request.InternalNote
|
||||
? <p className="about-text">{request.InternalNote}</p>
|
||||
: <p className="bot-create-note">{t("botverification.notProvided")}</p>}
|
||||
</FieldBlock>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
}
|
||||
side={
|
||||
<section className="action-dock">
|
||||
<div className="dock-title"><Stamp size={14} /> {t("botverification.actionDock")}</div>
|
||||
{!canDecide && !canRevoke && <p className="bot-create-note">{t("botverification.noActions")}</p>}
|
||||
{(canDecide || canRevoke) && (
|
||||
<>
|
||||
<label className="duration-field">
|
||||
<span>{t("botverification.internalNote")}</span>
|
||||
<textarea
|
||||
value={note}
|
||||
onChange={(event) => setNote(event.target.value)}
|
||||
rows={3}
|
||||
placeholder={t("botverification.internalNotePlaceholder")}
|
||||
/>
|
||||
</label>
|
||||
<p className="bot-create-note">{t("botverification.internalNoteHint")}</p>
|
||||
</>
|
||||
)}
|
||||
{canDecide && (
|
||||
<>
|
||||
{!verifier && <Alert>{t("botverification.verifierMissing")}</Alert>}
|
||||
{verifier && !verifier.Enabled && <Alert>{t("botverification.verifierDisabledHint")}</Alert>}
|
||||
{markActive && <p className="bot-create-note">{t("botverification.markActiveHint")}</p>}
|
||||
<div className="action-stack">
|
||||
<ActionButton
|
||||
label={t("botverification.approve")}
|
||||
icon={<CheckCircle2 size={15} />}
|
||||
tone="neutral"
|
||||
path={`/api/botverification/requests/${request.ID}/approve`}
|
||||
payload={decisionPayload}
|
||||
onDone={afterDecision}
|
||||
onError={handleActionError}
|
||||
/>
|
||||
<ActionButton
|
||||
label={t("botverification.reject")}
|
||||
icon={<XCircle size={15} />}
|
||||
tone="warn"
|
||||
path={`/api/botverification/requests/${request.ID}/reject`}
|
||||
payload={decisionPayload}
|
||||
onDone={afterDecision}
|
||||
onError={handleActionError}
|
||||
/>
|
||||
</div>
|
||||
<p className="bot-create-note">{t("botverification.approveHint")}</p>
|
||||
<p className="bot-create-note">{t("botverification.rejectHint")}</p>
|
||||
</>
|
||||
)}
|
||||
{canRevoke && (
|
||||
<>
|
||||
<div className="dock-title"><ShieldOff size={14} /> {t("botverification.dangerZone")}</div>
|
||||
<div className="danger-zone">
|
||||
<ActionButton
|
||||
label={t("botverification.revokeRequest")}
|
||||
icon={<Ban size={15} />}
|
||||
tone="danger"
|
||||
path={`/api/botverification/requests/${request.ID}/revoke`}
|
||||
payload={decisionPayload}
|
||||
onDone={afterDecision}
|
||||
onError={handleActionError}
|
||||
/>
|
||||
<p className="bot-create-note">{t("botverification.revokeRequestHint")}</p>
|
||||
{!markActive && <p className="bot-create-note">{t("botverification.revokeNoMark")}</p>}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
}
|
||||
/>
|
||||
</PageFrame>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldBlock({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="duration-field">
|
||||
<span>{label}</span>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// A verifier whose row was revoked after the application was filed can come back as
|
||||
// null or as a zeroed record, depending on how the backend renders "gone". Both mean
|
||||
// the same thing to a reviewer, so they collapse into one absent value here.
|
||||
function liveVerifier(row: BotVerifierRow | null): BotVerifierRow | null {
|
||||
if (!row) return null;
|
||||
if (!row.BotID || row.BotID === "0") return null;
|
||||
return row;
|
||||
}
|
||||
|
|
@ -0,0 +1,253 @@
|
|||
import { ArrowLeft, ArrowLeftRight, ExternalLink, Flame, Trash2, RefreshCw, Undo2 } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { api, errorMessage } from "../api";
|
||||
import { ActionButton } from "../components/ActionButton";
|
||||
import { ChannelPicker, UserPicker } from "../components/EntityPicker";
|
||||
import { Alert, Badge, EmptyRow, LoadingSurface, PageFrame, SectionHead, SplitLayout, Summary } from "../components/ui";
|
||||
import { useI18n } from "../i18n";
|
||||
import { displayUsername, formatCurrency, formatDate } from "../lib/format";
|
||||
import type { Navigate } from "../routing";
|
||||
import type {
|
||||
AccountRow,
|
||||
ChannelRow,
|
||||
CollectibleUsernameDetail,
|
||||
CollectibleUsernameTransferKind
|
||||
} from "../types";
|
||||
import { UsernameStatus, ownerLabel, priceLabel } from "./CollectibleUsernamesPage";
|
||||
|
||||
type RecipientKind = "user" | "channel";
|
||||
|
||||
export function CollectibleUsernameDetailPage({ id, navigate }: { id: string; navigate: Navigate }) {
|
||||
const { t } = useI18n();
|
||||
const [detail, setDetail] = useState<CollectibleUsernameDetail | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [recipientKind, setRecipientKind] = useState<RecipientKind>("user");
|
||||
const [recipientUser, setRecipientUser] = useState<AccountRow | null>(null);
|
||||
const [recipientChannel, setRecipientChannel] = useState<ChannelRow | null>(null);
|
||||
|
||||
async function load() {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
setDetail(await api.collectibleUsername(id));
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [id]);
|
||||
|
||||
if (error && !detail) {
|
||||
return <Alert>{error}</Alert>;
|
||||
}
|
||||
if (!detail) {
|
||||
return <LoadingSurface label={busy ? t("usernames.loadingDetail") : t("account.waitingData")} />;
|
||||
}
|
||||
|
||||
const asset = detail.asset;
|
||||
const transfers = detail.transfers ?? [];
|
||||
const vaultLabel = t("usernames.statusVault");
|
||||
const hasOwner = Boolean(asset.OwnerPeerType) && asset.OwnerPeerID !== "" && asset.OwnerPeerID !== "0";
|
||||
const burned = asset.Status === "burned";
|
||||
|
||||
function openOwner() {
|
||||
if (!hasOwner) return;
|
||||
navigate(asset.OwnerPeerType === "channel" ? `/channels/${asset.OwnerPeerID}` : `/accounts/${asset.OwnerPeerID}`);
|
||||
}
|
||||
|
||||
// Peer ids travel as decimal strings to match the backend `,string` tags.
|
||||
function transferPayload(): Record<string, unknown> {
|
||||
const payload: Record<string, unknown> = { username: asset.Username };
|
||||
if (recipientKind === "user" && recipientUser) payload.to_user_id = String(recipientUser.ID);
|
||||
if (recipientKind === "channel" && recipientChannel) payload.to_channel_id = String(recipientChannel.ID);
|
||||
return payload;
|
||||
}
|
||||
|
||||
return (
|
||||
<PageFrame
|
||||
title={t("usernames.detailTitle", { username: displayUsername(asset.Username) })}
|
||||
eyebrow={t("usernames.detailEyebrow")}
|
||||
actions={
|
||||
<>
|
||||
<button className="btn icon-text" type="button" onClick={() => navigate("/collectible-usernames")}>
|
||||
<ArrowLeft size={15} /> {t("common.backToList")}
|
||||
</button>
|
||||
<button className="btn icon-text" type="button" onClick={load} disabled={busy}>
|
||||
<RefreshCw size={15} className={busy ? "spin" : ""} /> {t("common.refresh")}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{error && <Alert>{error}</Alert>}
|
||||
<SplitLayout
|
||||
main={
|
||||
<div className="stacked-sections">
|
||||
<section className="entity-head">
|
||||
<div>
|
||||
<div className="entity-title">{displayUsername(asset.Username)}</div>
|
||||
<div className="entity-subtitle">{t("usernames.assetID", { id: asset.ID })}</div>
|
||||
</div>
|
||||
<div className="entity-badges">
|
||||
<UsernameStatus status={asset.Status} />
|
||||
<Badge tone={asset.TransferCount > 0 ? "warn" : "neutral"}>
|
||||
{t("usernames.transferCount", { count: asset.TransferCount })}
|
||||
</Badge>
|
||||
{asset.Status === "owned" && (
|
||||
<Badge tone={asset.RegistryActive ? "good" : "warn"}>
|
||||
{asset.RegistryActive ? t("usernames.registryActive") : t("usernames.registryHidden")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
<div className="summary-grid">
|
||||
<Summary label={t("common.owner")} value={ownerLabel(asset, vaultLabel)} />
|
||||
<Summary label={t("usernames.price")} value={priceLabel(asset)} mono />
|
||||
<Summary label={t("usernames.purchaseDate")} value={formatDate(asset.PurchaseDate) || "-"} />
|
||||
<Summary
|
||||
label={t("usernames.originalOwner")}
|
||||
value={peerLabel(asset.OriginalOwnerPeerType, asset.OriginalOwnerPeerID, vaultLabel, asset.OriginalOwnerUsername)}
|
||||
/>
|
||||
<Summary label={t("usernames.transfers")} value={String(asset.TransferCount)} mono />
|
||||
<Summary label={t("account.createdAt")} value={formatDate(asset.CreatedAt) || "-"} />
|
||||
<Summary label={t("common.updatedAt")} value={formatDate(asset.UpdatedAt) || "-"} />
|
||||
</div>
|
||||
<div className="toolbar">
|
||||
{hasOwner && (
|
||||
<button className="row-link" type="button" onClick={openOwner}>
|
||||
{asset.OwnerPeerType === "channel" ? t("usernames.openOwnerChannel") : t("usernames.openOwnerAccount")}
|
||||
</button>
|
||||
)}
|
||||
{asset.URL && (
|
||||
<a className="row-link" href={asset.URL} target="_blank" rel="noreferrer noopener">
|
||||
<ExternalLink size={14} /> {t("usernames.openMarketplace")}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!burned && (
|
||||
<section className="section-block">
|
||||
<SectionHead title={t("usernames.transferTitle")} text={t("usernames.transferHint")} />
|
||||
<div className="toolbar" role="group" aria-label={t("usernames.recipientKind")}>
|
||||
<button type="button" className={`btn ${recipientKind === "user" ? "primary" : ""}`} onClick={() => setRecipientKind("user")}>
|
||||
{t("usernames.recipientUser")}
|
||||
</button>
|
||||
<button type="button" className={`btn ${recipientKind === "channel" ? "primary" : ""}`} onClick={() => setRecipientKind("channel")}>
|
||||
{t("usernames.recipientChannel")}
|
||||
</button>
|
||||
</div>
|
||||
{recipientKind === "user"
|
||||
? <UserPicker label={t("usernames.recipientUser")} value={recipientUser} onChange={setRecipientUser} />
|
||||
: <ChannelPicker label={t("usernames.recipientChannel")} value={recipientChannel} onChange={setRecipientChannel} />}
|
||||
<div className="bot-create-actions">
|
||||
<span className="bot-create-note">{t("usernames.transferNote")}</span>
|
||||
<ActionButton
|
||||
label={t("usernames.transfer")}
|
||||
icon={<ArrowLeftRight size={15} />}
|
||||
tone="warn"
|
||||
path="/api/actions/transfer-collectible-username"
|
||||
payload={transferPayload}
|
||||
onDone={load}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="section-block">
|
||||
<SectionHead title={t("usernames.historyTitle")} text={t("usernames.historyHint")} />
|
||||
<div className="table-wrap">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("common.id")}</th>
|
||||
<th>{t("usernames.eventKind")}</th>
|
||||
<th>{t("usernames.fromPeer")}</th>
|
||||
<th>{t("usernames.toPeer")}</th>
|
||||
<th>{t("usernames.price")}</th>
|
||||
<th>{t("audit.actor")}</th>
|
||||
<th>{t("audit.reason")}</th>
|
||||
<th>{t("common.time")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{transfers.map((row) => (
|
||||
<tr key={row.ID}>
|
||||
<td className="mono">{row.ID}</td>
|
||||
<td><TransferKind kind={row.Kind} /></td>
|
||||
<td className="mono">{peerLabel(row.FromPeerType, row.FromPeerID, vaultLabel, row.FromUsername)}</td>
|
||||
<td className="mono">{peerLabel(row.ToPeerType, row.ToPeerID, vaultLabel, row.ToUsername)}</td>
|
||||
<td className="mono">{row.Amount && row.Amount !== "0" ? formatCurrency(row.Amount, row.Currency) : "-"}</td>
|
||||
<td>{row.Actor || "-"}</td>
|
||||
<td className="truncate">{row.Reason || "-"}</td>
|
||||
<td>{formatDate(row.CreatedAt) || "-"}</td>
|
||||
</tr>
|
||||
))}
|
||||
{transfers.length === 0 && <EmptyRow colSpan={8} />}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
}
|
||||
side={
|
||||
<section className="action-dock">
|
||||
<div className="dock-title">{t("usernames.actionDock")}</div>
|
||||
{burned ? (
|
||||
<p className="bot-create-note">{t("usernames.burnedHint")}</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="action-stack">
|
||||
<ActionButton
|
||||
label={t("usernames.revoke")}
|
||||
icon={<Undo2 size={15} />}
|
||||
tone="warn"
|
||||
path="/api/actions/revoke-collectible-username"
|
||||
payload={() => ({ username: asset.Username, burn: false })}
|
||||
onDone={load}
|
||||
/>
|
||||
</div>
|
||||
<p className="bot-create-note">{t("usernames.revokeHint")}</p>
|
||||
<div className="danger-zone">
|
||||
<ActionButton
|
||||
label={t("usernames.burn")}
|
||||
icon={<Flame size={15} />}
|
||||
tone="danger"
|
||||
path="/api/actions/revoke-collectible-username"
|
||||
payload={() => ({ username: asset.Username, burn: true })}
|
||||
onDone={load}
|
||||
/>
|
||||
<p className="bot-create-note">{t("usernames.burnHint")}</p>
|
||||
<ActionButton
|
||||
label={t("usernames.delete")}
|
||||
icon={<Trash2 size={15} />}
|
||||
tone="danger"
|
||||
path="/api/actions/delete-collectible-username"
|
||||
payload={() => ({ username: asset.Username })}
|
||||
onDone={() => navigate("/collectible-usernames")}
|
||||
/>
|
||||
<p className="bot-create-note">{t("usernames.deleteHint")}</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
}
|
||||
/>
|
||||
</PageFrame>
|
||||
);
|
||||
}
|
||||
|
||||
function TransferKind({ kind }: { kind: CollectibleUsernameTransferKind }) {
|
||||
const { t } = useI18n();
|
||||
const tone = kind === "burn" ? "danger" : kind === "revoke" ? "warn" : kind === "mint" ? "good" : "neutral";
|
||||
return <Badge tone={tone}>{t(`usernames.kind.${kind}`)}</Badge>;
|
||||
}
|
||||
|
||||
function peerLabel(type: string, peerID: string, vaultLabel: string, username = ""): string {
|
||||
if (!type || peerID === "" || peerID === "0") return vaultLabel;
|
||||
const handle = displayUsername(username);
|
||||
return handle ? `${handle} · ${type}:${peerID}` : `${type}:${peerID}`;
|
||||
}
|
||||
309
cmd/telesrv-admin/web/src/pages/CollectibleUsernamesPage.tsx
Normal file
309
cmd/telesrv-admin/web/src/pages/CollectibleUsernamesPage.tsx
Normal file
|
|
@ -0,0 +1,309 @@
|
|||
import { AtSign, ChevronDown, ChevronRight, Flame, Loader2, Plus, RefreshCw, Search, Vault } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { api, errorMessage } from "../api";
|
||||
import { ActionButton } from "../components/ActionButton";
|
||||
import { ChannelPicker, UserPicker } from "../components/EntityPicker";
|
||||
import { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel, SectionHead } from "../components/ui";
|
||||
import { useI18n } from "../i18n";
|
||||
import { currencyExponent, displayUsername, formatCurrency, formatDate, toSmallestUnits } from "../lib/format";
|
||||
import type { Navigate } from "../routing";
|
||||
import type {
|
||||
AccountRow,
|
||||
ChannelRow,
|
||||
CollectibleCurrency,
|
||||
CollectibleUsernameRow,
|
||||
CollectibleUsernameStatus
|
||||
} from "../types";
|
||||
|
||||
type StatusFilter = "all" | CollectibleUsernameStatus;
|
||||
type OwnerKind = "vault" | "user" | "channel";
|
||||
|
||||
export function CollectibleUsernamesPage({ navigate }: { navigate: Navigate }) {
|
||||
const { t } = useI18n();
|
||||
const [status, setStatus] = useState<StatusFilter>("all");
|
||||
const [q, setQ] = useState("");
|
||||
const [limit, setLimit] = useState("50");
|
||||
const [rows, setRows] = useState<CollectibleUsernameRow[]>([]);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [cursor, setCursor] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
// Mint form state.
|
||||
const [ownerKind, setOwnerKind] = useState<OwnerKind>("vault");
|
||||
const [owner, setOwner] = useState<AccountRow | null>(null);
|
||||
const [ownerChannel, setOwnerChannel] = useState<ChannelRow | null>(null);
|
||||
const [mintUsername, setMintUsername] = useState("");
|
||||
const [currency, setCurrency] = useState<CollectibleCurrency>("XTR");
|
||||
const [amount, setAmount] = useState("");
|
||||
const [cryptoCurrency, setCryptoCurrency] = useState("");
|
||||
const [cryptoAmount, setCryptoAmount] = useState("");
|
||||
const [url, setUrl] = useState("");
|
||||
const [purchaseDate, setPurchaseDate] = useState("");
|
||||
const [purchaseTime, setPurchaseTime] = useState("");
|
||||
|
||||
async function load(next = false) {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
const params = new URLSearchParams({ limit });
|
||||
if (status !== "all") params.set("status", status);
|
||||
if (q.trim()) params.set("q", q.trim().replace(/^@/, ""));
|
||||
if (next && cursor) params.set("before_id", cursor);
|
||||
try {
|
||||
const result = await api.collectibleUsernames(params);
|
||||
const page = result.rows ?? [];
|
||||
setRows((current) => (next ? [...current, ...page] : page));
|
||||
setCursor(result.next_before_id ?? "");
|
||||
setHasMore(Boolean(result.has_more));
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void load(false);
|
||||
}, []);
|
||||
|
||||
const vaultCount = rows.filter((row) => row.Status === "vault").length;
|
||||
const ownedCount = rows.filter((row) => row.Status === "owned").length;
|
||||
const burnedCount = rows.filter((row) => row.Status === "burned").length;
|
||||
|
||||
// int64 request fields are sent as decimal strings (the backend tags them
|
||||
// `,string`); purchase_date is Unix seconds. Optional owner keys are omitted
|
||||
// entirely rather than sent empty, because `,string,omitempty` cannot decode "".
|
||||
// Both amounts are typed in whole currency units and converted here: the API
|
||||
// and fragment.collectibleInfo carry smallest units, so 900 TON has to leave
|
||||
// the panel as 900000000000 nanotons or clients render 0.0000009.
|
||||
const minorAmount = toSmallestUnits(amount, currency);
|
||||
const minorCryptoAmount = cryptoCurrency ? toSmallestUnits(cryptoAmount, cryptoCurrency) : "0";
|
||||
const amountInvalid = minorAmount === null;
|
||||
const cryptoAmountInvalid = minorCryptoAmount === null;
|
||||
|
||||
function mintPayload(): Record<string, unknown> {
|
||||
const payload: Record<string, unknown> = {
|
||||
username: mintUsername.trim().replace(/^@/, ""),
|
||||
currency,
|
||||
amount: minorAmount ?? "0"
|
||||
};
|
||||
if (ownerKind === "user" && owner) payload.owner_user_id = String(owner.ID);
|
||||
if (ownerKind === "channel" && ownerChannel) payload.owner_channel_id = String(ownerChannel.ID);
|
||||
// The backend accepts either no crypto leg at all, or TON with a positive
|
||||
// nanoton amount — never a currency without an amount.
|
||||
if (cryptoCurrency) {
|
||||
payload.crypto_currency = cryptoCurrency;
|
||||
payload.crypto_amount = minorCryptoAmount ?? "0";
|
||||
}
|
||||
if (url.trim()) payload.url = url.trim();
|
||||
if (purchaseDate) {
|
||||
// fragment.collectibleInfo.purchase_date is a unix timestamp, and the date has
|
||||
// always been read as UTC here. The time follows the same clock rather than the
|
||||
// operator's local one, so adding it cannot silently shift what a date-only
|
||||
// entry used to mean; the field label says UTC.
|
||||
const parsed = Date.parse(`${purchaseDate}T${purchaseTime || "00:00"}:00Z`);
|
||||
if (Number.isFinite(parsed)) payload.purchase_date = Math.floor(parsed / 1000);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
return (
|
||||
<PageFrame
|
||||
title={t("usernames.pageTitle")}
|
||||
eyebrow={t("usernames.eyebrow")}
|
||||
actions={
|
||||
<button className="btn icon-text" type="button" onClick={() => load(false)} disabled={busy}>
|
||||
<RefreshCw size={15} className={busy ? "spin" : ""} /> {t("common.refresh")}
|
||||
</button>
|
||||
}
|
||||
>
|
||||
{error && <Alert>{error}</Alert>}
|
||||
<div className="metric-row">
|
||||
<Metric label={t("usernames.metricLoaded")} value={String(rows.length)} />
|
||||
<Metric label={t("usernames.metricVault")} value={String(vaultCount)} />
|
||||
<Metric label={t("usernames.metricOwned")} value={String(ownedCount)} tone="good" />
|
||||
<Metric label={t("usernames.metricBurned")} value={String(burnedCount)} tone={burnedCount ? "danger" : "neutral"} />
|
||||
</div>
|
||||
|
||||
<section className="section-block">
|
||||
<SectionHead title={t("usernames.mintTitle")} text={t("usernames.mintHint")} />
|
||||
<div className="toolbar" role="group" aria-label={t("usernames.ownerKind")}>
|
||||
<button type="button" className={`btn ${ownerKind === "vault" ? "primary" : ""}`} onClick={() => setOwnerKind("vault")}>
|
||||
<Vault size={15} /> {t("usernames.ownerVault")}
|
||||
</button>
|
||||
<button type="button" className={`btn ${ownerKind === "user" ? "primary" : ""}`} onClick={() => setOwnerKind("user")}>
|
||||
{t("usernames.ownerUser")}
|
||||
</button>
|
||||
<button type="button" className={`btn ${ownerKind === "channel" ? "primary" : ""}`} onClick={() => setOwnerKind("channel")}>
|
||||
{t("usernames.ownerChannel")}
|
||||
</button>
|
||||
</div>
|
||||
{ownerKind === "user" && <UserPicker label={t("usernames.ownerUser")} value={owner} onChange={setOwner} />}
|
||||
{ownerKind === "channel" && <ChannelPicker label={t("usernames.ownerChannel")} value={ownerChannel} onChange={setOwnerChannel} />}
|
||||
<div className="bot-create-fields">
|
||||
<label className="duration-field">
|
||||
<span>{t("common.username")}</span>
|
||||
<input value={mintUsername} onChange={(event) => setMintUsername(event.target.value)} placeholder="durov" />
|
||||
</label>
|
||||
<label className="duration-field">
|
||||
<span>{t("usernames.currency")}</span>
|
||||
<select value={currency} onChange={(event) => setCurrency(event.target.value as CollectibleCurrency)}>
|
||||
<option value="XTR">XTR</option>
|
||||
<option value="TON">TON</option>
|
||||
<option value="USD">USD</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="duration-field">
|
||||
<span>{t("usernames.amount", { currency })}</span>
|
||||
<input value={amount} onChange={(event) => setAmount(event.target.value)} inputMode="decimal" placeholder="1000" />
|
||||
</label>
|
||||
<label className="duration-field">
|
||||
<span>{t("usernames.cryptoCurrency")}</span>
|
||||
<select value={cryptoCurrency} onChange={(event) => setCryptoCurrency(event.target.value)}>
|
||||
<option value="">{t("usernames.cryptoNone")}</option>
|
||||
<option value="TON">TON</option>
|
||||
</select>
|
||||
</label>
|
||||
{cryptoCurrency !== "" && (
|
||||
<label className="duration-field">
|
||||
<span>{t("usernames.cryptoAmount", { currency: cryptoCurrency })}</span>
|
||||
<input value={cryptoAmount} onChange={(event) => setCryptoAmount(event.target.value)} inputMode="decimal" placeholder="12.5" />
|
||||
</label>
|
||||
)}
|
||||
<label className="duration-field">
|
||||
<span>{t("usernames.url")}</span>
|
||||
<input value={url} onChange={(event) => setUrl(event.target.value)} placeholder="https://fragment.com/username/durov" />
|
||||
</label>
|
||||
<label className="duration-field">
|
||||
<span>{t("usernames.purchaseDate")}</span>
|
||||
<input value={purchaseDate} onChange={(event) => setPurchaseDate(event.target.value)} type="date" />
|
||||
</label>
|
||||
<label className="duration-field">
|
||||
<span>{t("usernames.purchaseTime")}</span>
|
||||
<input
|
||||
value={purchaseTime}
|
||||
onChange={(event) => setPurchaseTime(event.target.value)}
|
||||
type="time"
|
||||
step={60}
|
||||
disabled={!purchaseDate}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<p className="bot-create-note">
|
||||
{t("usernames.amountHint", {
|
||||
currency,
|
||||
decimals: String(currencyExponent(currency)),
|
||||
preview: formatCurrency(minorAmount ?? "0", currency)
|
||||
})}
|
||||
</p>
|
||||
{amountInvalid && <Alert>{t("usernames.amountInvalid", { currency, decimals: String(currencyExponent(currency)) })}</Alert>}
|
||||
{cryptoCurrency !== "" && cryptoAmountInvalid && (
|
||||
<Alert>{t("usernames.amountInvalid", { currency: cryptoCurrency, decimals: String(currencyExponent(cryptoCurrency)) })}</Alert>
|
||||
)}
|
||||
<div className="bot-create-actions">
|
||||
<span className="bot-create-note">{t("usernames.mintNote")}</span>
|
||||
<ActionButton
|
||||
disabled={amountInvalid || cryptoAmountInvalid}
|
||||
label={t("usernames.mint")}
|
||||
icon={<Plus size={15} />}
|
||||
tone="neutral"
|
||||
path="/api/actions/mint-collectible-username"
|
||||
payload={mintPayload}
|
||||
onDone={() => load(false)}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<QueryPanel>
|
||||
<form className="toolbar" onSubmit={(event) => { event.preventDefault(); void load(false); }}>
|
||||
<label className="searchbox">
|
||||
<Search size={15} />
|
||||
<input value={q} onChange={(event) => setQ(event.target.value)} placeholder={t("usernames.searchPlaceholder")} />
|
||||
</label>
|
||||
<label className="field-inline">
|
||||
<span>{t("common.status")}</span>
|
||||
<select value={status} onChange={(event) => setStatus(event.target.value as StatusFilter)}>
|
||||
<option value="all">{t("usernames.statusAll")}</option>
|
||||
<option value="vault">{t("usernames.statusVault")}</option>
|
||||
<option value="owned">{t("usernames.statusOwned")}</option>
|
||||
<option value="burned">{t("usernames.statusBurned")}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="field-inline">
|
||||
<span>{t("common.limit")}</span>
|
||||
<input className="small-input" value={limit} onChange={(event) => setLimit(event.target.value)} type="number" min="1" max="200" />
|
||||
</label>
|
||||
<button className="btn primary icon-text" type="submit" disabled={busy}>
|
||||
{busy ? <Loader2 size={15} className="spin" /> : <Search size={15} />} {t("common.search")}
|
||||
</button>
|
||||
</form>
|
||||
</QueryPanel>
|
||||
|
||||
<div className="table-wrap">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("common.username")}</th>
|
||||
<th>{t("common.status")}</th>
|
||||
<th>{t("common.owner")}</th>
|
||||
<th>{t("usernames.price")}</th>
|
||||
<th>{t("usernames.purchaseDate")}</th>
|
||||
<th>{t("usernames.transfers")}</th>
|
||||
<th>{t("common.updatedAt")}</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<tr key={row.ID}>
|
||||
<td><strong>{displayUsername(row.Username)}</strong></td>
|
||||
<td><UsernameStatus status={row.Status} /></td>
|
||||
<td>{ownerLabel(row, t("usernames.statusVault"))}</td>
|
||||
<td className="mono">{priceLabel(row)}</td>
|
||||
<td>{formatDate(row.PurchaseDate) || "-"}</td>
|
||||
<td className="mono">{row.TransferCount}</td>
|
||||
<td>{formatDate(row.UpdatedAt) || "-"}</td>
|
||||
<td>
|
||||
<button className="row-link" type="button" onClick={() => navigate(`/collectible-usernames/${row.ID}`)}>
|
||||
<AtSign size={14} /> {t("common.detail")} <ChevronRight size={14} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{rows.length === 0 && <EmptyRow colSpan={8} />}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{hasMore && (
|
||||
<div className="toolbar">
|
||||
<button className="btn icon-text" type="button" onClick={() => load(true)} disabled={busy}>
|
||||
{busy ? <Loader2 size={15} className="spin" /> : <ChevronDown size={15} />} {t("common.loadMore")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</PageFrame>
|
||||
);
|
||||
}
|
||||
|
||||
export function UsernameStatus({ status }: { status: CollectibleUsernameStatus }) {
|
||||
const { t } = useI18n();
|
||||
if (status === "owned") return <Badge tone="good">{t("usernames.statusOwned")}</Badge>;
|
||||
if (status === "burned") return <Badge tone="danger"><Flame size={12} /> {t("usernames.statusBurned")}</Badge>;
|
||||
return <Badge><Vault size={12} /> {t("usernames.statusVault")}</Badge>;
|
||||
}
|
||||
|
||||
export function ownerLabel(row: CollectibleUsernameRow, vaultLabel: string): string {
|
||||
if (!row.OwnerPeerType || row.OwnerPeerID === "" || row.OwnerPeerID === "0") return vaultLabel;
|
||||
const name = displayUsername(row.OwnerUsername) || row.OwnerName || row.OwnerPeerID;
|
||||
return `${name} · ${row.OwnerPeerType}:${row.OwnerPeerID}`;
|
||||
}
|
||||
|
||||
// priceLabel renders what a Telegram client will draw, not the stored integer:
|
||||
// both legs are smallest units on the wire (see formatCurrency).
|
||||
export function priceLabel(row: CollectibleUsernameRow): string {
|
||||
const base = formatCurrency(row.Amount, row.Currency);
|
||||
if (row.CryptoCurrency && row.CryptoAmount && row.CryptoAmount !== "0") {
|
||||
return `${base} (${formatCurrency(row.CryptoAmount, row.CryptoCurrency)})`;
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
|
@ -4,8 +4,9 @@ import { api, errorMessage } from "../api";
|
|||
import { Alert } from "../components/ui";
|
||||
import { LanguageSwitch, useI18n } from "../i18n";
|
||||
import { ThemeSwitch } from "../theme";
|
||||
import type { AdminSession } from "../types";
|
||||
|
||||
export function LoginPage({ onLogin }: { onLogin: (actor: string) => void }) {
|
||||
export function LoginPage({ onLogin }: { onLogin: (session: AdminSession) => void }) {
|
||||
const { t } = useI18n();
|
||||
const [secret, setSecret] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
|
|
@ -16,8 +17,10 @@ export function LoginPage({ onLogin }: { onLogin: (actor: string) => void }) {
|
|||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
// The login answer carries the permission set and the CSRF token; api.login
|
||||
// remembers the token, the session state keeps the rights.
|
||||
const result = await api.login(secret);
|
||||
onLogin(result.actor);
|
||||
onLogin({ actor: result.actor, permissions: result.permissions ?? [] });
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
import { type Navigate, type RouteState } from "../routing";
|
||||
import { AccountDetailPage } from "./AccountDetailPage";
|
||||
import { AccountRatingDetailPage } from "./AccountRatingDetailPage";
|
||||
import { AccountRatingsPage } from "./AccountRatingsPage";
|
||||
import { AccountsPage } from "./AccountsPage";
|
||||
import { CollectibleUsernameDetailPage } from "./CollectibleUsernameDetailPage";
|
||||
import { CollectibleUsernamesPage } from "./CollectibleUsernamesPage";
|
||||
import { ChannelDetailPage } from "./ChannelDetailPage";
|
||||
import { ChannelsPage } from "./ChannelsPage";
|
||||
import { BotDetailPage } from "./BotDetailPage";
|
||||
|
|
@ -15,12 +19,71 @@ import { GiftsPage } from "./GiftsPage";
|
|||
import { GiveGiftsPage } from "./GiveGiftsPage";
|
||||
import { ModerationCaseDetailPage } from "./ModerationCaseDetailPage";
|
||||
import { ModerationCasesPage } from "./ModerationCasesPage";
|
||||
import { BotVerificationPage } from "./BotVerificationPage";
|
||||
import { BotVerificationRequestPage } from "./BotVerificationRequestPage";
|
||||
import { VerificationDetailPage } from "./VerificationDetailPage";
|
||||
import { VerificationPage } from "./VerificationPage";
|
||||
import {
|
||||
PermissionGate,
|
||||
permissionBotVerificationReview,
|
||||
permissionVerificationReview
|
||||
} from "../permissions";
|
||||
|
||||
export function Routes({ route, navigate }: { route: RouteState; navigate: Navigate }) {
|
||||
const accountID = route.path.match(/^\/accounts\/(\d+)$/)?.[1];
|
||||
const channelID = route.path.match(/^\/channels\/(\d+)$/)?.[1];
|
||||
const botID = route.path.match(/^\/bots\/(\d+)$/)?.[1];
|
||||
const moderationCaseID = route.path.match(/^\/moderation\/(\d+)$/)?.[1];
|
||||
// int64 ids stay strings so large values never lose precision.
|
||||
const collectibleUsernameID = route.path.match(/^\/collectible-usernames\/(\d+)$/)?.[1];
|
||||
const ratingUserID = route.path.match(/^\/account-ratings\/(\d+)$/)?.[1];
|
||||
const verificationID = route.path.match(/^\/verification\/(\d+)$/)?.[1];
|
||||
// Third-party verification: a separate section with its own rights, matched before
|
||||
// the official one so neither prefix can shadow the other.
|
||||
const botVerificationRequestID = route.path.match(/^\/bot-verification\/(\d+)$/)?.[1];
|
||||
if (botVerificationRequestID) {
|
||||
return (
|
||||
<PermissionGate permission={permissionBotVerificationReview}>
|
||||
<BotVerificationRequestPage id={botVerificationRequestID} navigate={navigate} />
|
||||
</PermissionGate>
|
||||
);
|
||||
}
|
||||
if (route.path === "/bot-verification") {
|
||||
return (
|
||||
<PermissionGate permission={permissionBotVerificationReview}>
|
||||
<BotVerificationPage navigate={navigate} />
|
||||
</PermissionGate>
|
||||
);
|
||||
}
|
||||
// The detail match has to be tested before the exact "/verification" branch, and
|
||||
// the whole section is wrapped in the permission gate so a direct URL explains
|
||||
// itself instead of rendering an empty queue.
|
||||
if (verificationID) {
|
||||
return (
|
||||
<PermissionGate permission={permissionVerificationReview}>
|
||||
<VerificationDetailPage id={verificationID} navigate={navigate} />
|
||||
</PermissionGate>
|
||||
);
|
||||
}
|
||||
if (route.path === "/verification") {
|
||||
return (
|
||||
<PermissionGate permission={permissionVerificationReview}>
|
||||
<VerificationPage navigate={navigate} />
|
||||
</PermissionGate>
|
||||
);
|
||||
}
|
||||
if (collectibleUsernameID) {
|
||||
return <CollectibleUsernameDetailPage id={collectibleUsernameID} navigate={navigate} />;
|
||||
}
|
||||
if (ratingUserID) {
|
||||
return <AccountRatingDetailPage userID={ratingUserID} navigate={navigate} />;
|
||||
}
|
||||
if (route.path === "/collectible-usernames") {
|
||||
return <CollectibleUsernamesPage navigate={navigate} />;
|
||||
}
|
||||
if (route.path === "/account-ratings") {
|
||||
return <AccountRatingsPage navigate={navigate} />;
|
||||
}
|
||||
if (accountID) {
|
||||
return <AccountDetailPage id={Number(accountID)} navigate={navigate} />;
|
||||
}
|
||||
|
|
|
|||
412
cmd/telesrv-admin/web/src/pages/VerificationDetailPage.tsx
Normal file
412
cmd/telesrv-admin/web/src/pages/VerificationDetailPage.tsx
Normal file
|
|
@ -0,0 +1,412 @@
|
|||
import {
|
||||
ArrowLeft,
|
||||
BadgeCheck,
|
||||
Ban,
|
||||
CheckCircle2,
|
||||
ExternalLink,
|
||||
Handshake,
|
||||
RefreshCw,
|
||||
ShieldOff,
|
||||
User,
|
||||
XCircle
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { api, APIError, errorMessage } from "../api";
|
||||
import { ActionButton } from "../components/ActionButton";
|
||||
import { Alert, Badge, EmptyRow, LoadingSurface, PageFrame, SectionHead, SplitLayout, Summary } from "../components/ui";
|
||||
import { useI18n } from "../i18n";
|
||||
import { displayUsername, formatDate, safeHttpURL } from "../lib/format";
|
||||
import { permissionVerificationRevoke, usePermissions } from "../permissions";
|
||||
import type { Navigate } from "../routing";
|
||||
import type { VerificationApplicationDetail, VerificationEventKind } from "../types";
|
||||
import { VerificationStatusBadge, targetHref, targetLabel } from "./VerificationPage";
|
||||
|
||||
export function VerificationDetailPage({ id, navigate }: { id: string; navigate: Navigate }) {
|
||||
const { t } = useI18n();
|
||||
const { can } = usePermissions();
|
||||
const [detail, setDetail] = useState<VerificationApplicationDetail | null>(null);
|
||||
const [note, setNote] = useState("");
|
||||
const [conflict, setConflict] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
async function load() {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
setDetail(await api.verificationApplication(id));
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
setConflict(false);
|
||||
void load();
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [id]);
|
||||
|
||||
// 409 is the one failure the operator cannot fix by editing the form: another
|
||||
// reviewer decided against the version this page read. The panel says so in
|
||||
// plain words and reloads, so the next attempt carries the current version.
|
||||
function handleActionError(err: unknown): string | undefined {
|
||||
if (err instanceof APIError && err.status === 409) {
|
||||
setConflict(true);
|
||||
void load();
|
||||
return t("verification.conflict");
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (error && !detail) {
|
||||
return <Alert>{error}</Alert>;
|
||||
}
|
||||
if (!detail) {
|
||||
return <LoadingSurface label={t("verification.loadingDetail")} />;
|
||||
}
|
||||
|
||||
const app = detail.application;
|
||||
const events = detail.events ?? [];
|
||||
const controls = detail.applicant_controls_target;
|
||||
const verified = detail.target_verified;
|
||||
const canClaim = app.Status === "submitted";
|
||||
const canDecide = app.Status === "submitted" || app.Status === "in_review";
|
||||
const canRevoke = app.Status === "approved" && can(permissionVerificationRevoke);
|
||||
const trimmedNote = note.trim();
|
||||
|
||||
// version is the optimistic-locking token: it goes with every decision, as the
|
||||
// decimal string it arrived as, so a stale page cannot overwrite a fresh one.
|
||||
function decisionPayload(): Record<string, unknown> {
|
||||
const payload: Record<string, unknown> = { version: app.Version };
|
||||
if (trimmedNote) payload.internal_note = trimmedNote;
|
||||
return payload;
|
||||
}
|
||||
|
||||
function afterDecision() {
|
||||
setNote("");
|
||||
setConflict(false);
|
||||
void load();
|
||||
}
|
||||
|
||||
return (
|
||||
<PageFrame
|
||||
title={t("verification.detailTitle", { id: app.ID })}
|
||||
eyebrow={t("verification.detailEyebrow")}
|
||||
actions={
|
||||
<>
|
||||
<button className="btn icon-text" type="button" onClick={() => navigate("/verification")}>
|
||||
<ArrowLeft size={15} /> {t("common.backToList")}
|
||||
</button>
|
||||
<button className="btn icon-text" type="button" onClick={refresh} disabled={busy}>
|
||||
<RefreshCw size={15} className={busy ? "spin" : ""} /> {t("common.refresh")}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{error && <Alert>{error}</Alert>}
|
||||
{conflict && <Alert>{t("verification.conflict")}</Alert>}
|
||||
<SplitLayout
|
||||
main={
|
||||
<div className="stacked-sections">
|
||||
<section className="entity-head">
|
||||
<div>
|
||||
<div className="entity-title">{targetLabel(app)}</div>
|
||||
<div className="entity-subtitle mono">
|
||||
#{app.ID} · {t(`verification.type.${app.TargetType}`)}:{app.TargetID} · v{app.Version}
|
||||
</div>
|
||||
</div>
|
||||
<div className="entity-badges">
|
||||
<VerificationStatusBadge status={app.Status} />
|
||||
{verified && <Badge tone="good"><BadgeCheck size={12} /> {t("verification.alreadyVerified")}</Badge>}
|
||||
<Badge tone={controls ? "good" : "danger"}>
|
||||
{controls ? t("verification.controlsOk") : t("verification.controlsLost")}
|
||||
</Badge>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="section-block">
|
||||
<SectionHead
|
||||
title={t("verification.targetSection")}
|
||||
text={t("verification.targetHint")}
|
||||
action={
|
||||
<button className="btn icon-text" type="button" onClick={() => navigate(targetHref(app))}>
|
||||
<ExternalLink size={15} /> {t("verification.openTarget")}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<div className="summary-grid">
|
||||
<Summary label={t("common.type")} value={t(`verification.type.${app.TargetType}`)} />
|
||||
<Summary label={t("common.username")} value={displayUsername(app.TargetUsername) || "-"} />
|
||||
<Summary label={t("verification.targetTitle")} value={app.TargetTitle || "-"} />
|
||||
<Summary label={t("verification.targetID")} value={app.TargetID} mono />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="section-block">
|
||||
<SectionHead
|
||||
title={t("verification.applicantSection")}
|
||||
text={t("verification.applicantHint")}
|
||||
action={
|
||||
<button className="btn icon-text" type="button" onClick={() => navigate(`/accounts/${app.ApplicantUserID}`)}>
|
||||
<User size={15} /> {t("verification.openApplicant")}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<div className="summary-grid">
|
||||
<Summary label={t("common.username")} value={displayUsername(app.ApplicantUsername) || "-"} />
|
||||
<Summary label={t("common.name")} value={app.ApplicantName || "-"} />
|
||||
<Summary label={t("verification.applicantID")} value={app.ApplicantUserID} mono />
|
||||
<Summary label={t("verification.submittedAt")} value={formatDate(app.SubmittedAt) || "-"} />
|
||||
</div>
|
||||
{controls
|
||||
? <p className="bot-create-note">{t("verification.controlsOkHint")}</p>
|
||||
: <Alert>{t("verification.controlsLostHint")}</Alert>}
|
||||
</section>
|
||||
|
||||
<section className="section-block">
|
||||
<SectionHead title={t("verification.applicationSection")} text={t("verification.applicationHint")} />
|
||||
<div className="stacked-sections">
|
||||
<div className="summary-grid">
|
||||
<Summary label={t("verification.category")} value={app.Category || "-"} />
|
||||
<Summary label={t("verification.correlationID")} value={app.CorrelationID || "-"} mono />
|
||||
<Summary label={t("verification.createdAt")} value={formatDate(app.CreatedAt) || "-"} />
|
||||
<Summary label={t("common.updatedAt")} value={formatDate(app.UpdatedAt) || "-"} />
|
||||
</div>
|
||||
<FieldBlock label={t("verification.description")}>
|
||||
{app.Description
|
||||
? <p className="about-text">{app.Description}</p>
|
||||
: <p className="bot-create-note">{t("verification.notProvided")}</p>}
|
||||
</FieldBlock>
|
||||
<FieldBlock label={t("verification.officialWebsite")}>
|
||||
{app.OfficialWebsite
|
||||
? <div className="about-text"><SafeLink value={app.OfficialWebsite} /></div>
|
||||
: <p className="bot-create-note">{t("verification.notProvided")}</p>}
|
||||
</FieldBlock>
|
||||
<FieldBlock label={t("verification.socialLinks")}>
|
||||
<LinkList values={app.SocialLinks} />
|
||||
</FieldBlock>
|
||||
<FieldBlock label={t("verification.pressLinks")}>
|
||||
<LinkList values={app.PressLinks} />
|
||||
</FieldBlock>
|
||||
<FieldBlock label={t("verification.additionalNote")}>
|
||||
{app.AdditionalNote
|
||||
? <p className="about-text">{app.AdditionalNote}</p>
|
||||
: <p className="bot-create-note">{t("verification.notProvided")}</p>}
|
||||
</FieldBlock>
|
||||
<p className="bot-create-note">{t("verification.linkSafetyHint")}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="section-block">
|
||||
<SectionHead title={t("verification.decisionSection")} text={t("verification.decisionHint")} />
|
||||
<div className="stacked-sections">
|
||||
<div className="summary-grid">
|
||||
<Summary label={t("verification.reviewer")} value={app.ReviewerAdminID || "-"} />
|
||||
<Summary label={t("verification.reviewedAt")} value={formatDate(app.ReviewedAt) || "-"} />
|
||||
<Summary label={t("common.status")} value={t(`verification.status.${app.Status}`)} />
|
||||
<Summary label={t("verification.version")} value={app.Version} mono />
|
||||
</div>
|
||||
<FieldBlock label={t("verification.decisionReason")}>
|
||||
{app.DecisionReason
|
||||
? <p className="about-text">{app.DecisionReason}</p>
|
||||
: <p className="bot-create-note">{t("verification.noDecision")}</p>}
|
||||
</FieldBlock>
|
||||
{/* The internal note is the reviewer handover text and is labelled
|
||||
as admin-only wherever it appears. */}
|
||||
<FieldBlock label={`${t("verification.internalNote")} · ${t("verification.adminOnly")}`}>
|
||||
{app.InternalNote
|
||||
? <p className="about-text">{app.InternalNote}</p>
|
||||
: <p className="bot-create-note">{t("verification.notProvided")}</p>}
|
||||
</FieldBlock>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="section-block">
|
||||
<SectionHead title={t("verification.eventsSection")} text={t("verification.eventsHint")} />
|
||||
<div className="table-wrap">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("verification.eventKind")}</th>
|
||||
<th>{t("verification.transition")}</th>
|
||||
<th>{t("audit.actor")}</th>
|
||||
<th>{t("audit.reason")}</th>
|
||||
<th>{t("verification.eventNote")}</th>
|
||||
<th>{t("common.time")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{events.map((row) => (
|
||||
<tr key={row.ID}>
|
||||
<td><EventKind kind={row.Kind} /></td>
|
||||
<td className="mono">
|
||||
{row.FromStatus || "-"} → {row.ToStatus || "-"}
|
||||
</td>
|
||||
<td>{row.Actor || "-"}</td>
|
||||
<td className="truncate">{row.Reason || "-"}</td>
|
||||
<td className="truncate">{row.Note || "-"}</td>
|
||||
<td>{formatDate(row.CreatedAt) || "-"}</td>
|
||||
</tr>
|
||||
))}
|
||||
{events.length === 0 && <EmptyRow colSpan={6} />}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
}
|
||||
side={
|
||||
<section className="action-dock">
|
||||
<div className="dock-title">{t("verification.actionDock")}</div>
|
||||
{!canClaim && !canDecide && !canRevoke && (
|
||||
<p className="bot-create-note">{t("verification.noActions")}</p>
|
||||
)}
|
||||
{canClaim && (
|
||||
<>
|
||||
<div className="action-stack">
|
||||
<ActionButton
|
||||
label={t("verification.claim")}
|
||||
icon={<Handshake size={15} />}
|
||||
tone="neutral"
|
||||
path={`/api/verification/applications/${app.ID}/claim`}
|
||||
payload={() => ({ version: app.Version })}
|
||||
onDone={afterDecision}
|
||||
onError={handleActionError}
|
||||
/>
|
||||
</div>
|
||||
<p className="bot-create-note">{t("verification.claimHint")}</p>
|
||||
</>
|
||||
)}
|
||||
{/* One optional note field feeds every decision on this page,
|
||||
including a revoke. */}
|
||||
{(canDecide || canRevoke) && (
|
||||
<>
|
||||
<label className="duration-field">
|
||||
<span>{t("verification.internalNote")}</span>
|
||||
<textarea
|
||||
value={note}
|
||||
onChange={(event) => setNote(event.target.value)}
|
||||
rows={3}
|
||||
placeholder={t("verification.internalNotePlaceholder")}
|
||||
/>
|
||||
</label>
|
||||
<p className="bot-create-note">{t("verification.internalNoteHint")}</p>
|
||||
</>
|
||||
)}
|
||||
{canDecide && (
|
||||
<>
|
||||
{!controls && <Alert>{t("verification.controlsLostHint")}</Alert>}
|
||||
{verified && <p className="bot-create-note">{t("verification.alreadyVerifiedHint")}</p>}
|
||||
<div className="action-stack">
|
||||
<ActionButton
|
||||
label={t("verification.approve")}
|
||||
icon={<CheckCircle2 size={15} />}
|
||||
tone="neutral"
|
||||
path={`/api/verification/applications/${app.ID}/approve`}
|
||||
payload={decisionPayload}
|
||||
onDone={afterDecision}
|
||||
onError={handleActionError}
|
||||
/>
|
||||
<ActionButton
|
||||
label={t("verification.reject")}
|
||||
icon={<XCircle size={15} />}
|
||||
tone="warn"
|
||||
path={`/api/verification/applications/${app.ID}/reject`}
|
||||
payload={decisionPayload}
|
||||
onDone={afterDecision}
|
||||
onError={handleActionError}
|
||||
/>
|
||||
</div>
|
||||
<p className="bot-create-note">{t("verification.approveHint")}</p>
|
||||
<p className="bot-create-note">{t("verification.rejectHint")}</p>
|
||||
</>
|
||||
)}
|
||||
{canRevoke && (
|
||||
<>
|
||||
<div className="dock-title"><ShieldOff size={14} /> {t("verification.dangerZone")}</div>
|
||||
<div className="danger-zone">
|
||||
<ActionButton
|
||||
label={t("verification.revoke")}
|
||||
icon={<Ban size={15} />}
|
||||
tone="danger"
|
||||
path="/api/actions/revoke-verification"
|
||||
payload={() => {
|
||||
// Revoke addresses the peer, not the application: the
|
||||
// approved application stays approved as history.
|
||||
const payload: Record<string, unknown> = {
|
||||
target_type: app.TargetType,
|
||||
target_id: app.TargetID
|
||||
};
|
||||
if (trimmedNote) payload.internal_note = trimmedNote;
|
||||
return payload;
|
||||
}}
|
||||
onDone={afterDecision}
|
||||
onError={handleActionError}
|
||||
/>
|
||||
<p className="bot-create-note">{t("verification.revokeHint")}</p>
|
||||
{!verified && <p className="bot-create-note">{t("verification.revokeNotVerified")}</p>}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
}
|
||||
/>
|
||||
</PageFrame>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldBlock({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="duration-field">
|
||||
<span>{label}</span>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Applicant-supplied text is rendered as ordinary React children (escaped by
|
||||
// React) and only ever linked when it is an http(s) URL. No markup from a
|
||||
// submission reaches the DOM.
|
||||
function SafeLink({ value }: { value: string }) {
|
||||
const href = safeHttpURL(value);
|
||||
if (!href) {
|
||||
return <span className="mono">{value}</span>;
|
||||
}
|
||||
return (
|
||||
<a className="row-link" href={href} target="_blank" rel="noopener noreferrer">
|
||||
{value} <ExternalLink size={13} />
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function LinkList({ values }: { values: string[] | null }) {
|
||||
const { t } = useI18n();
|
||||
const links = (values ?? []).filter((item) => item.trim() !== "");
|
||||
if (links.length === 0) {
|
||||
return <p className="bot-create-note">{t("verification.notProvided")}</p>;
|
||||
}
|
||||
return (
|
||||
<div className="about-text">
|
||||
{links.map((item, index) => (
|
||||
<div key={`${index}-${item}`}><SafeLink value={item} /></div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EventKind({ kind }: { kind: VerificationEventKind }) {
|
||||
const { t } = useI18n();
|
||||
const tone = kind === "approved"
|
||||
? "good"
|
||||
: kind === "rejected" || kind === "revoked" || kind === "cancelled"
|
||||
? "danger"
|
||||
: kind === "submitted" || kind === "claimed"
|
||||
? "warn"
|
||||
: "neutral";
|
||||
return <Badge tone={tone}>{t(`verification.kind.${kind}`)}</Badge>;
|
||||
}
|
||||
232
cmd/telesrv-admin/web/src/pages/VerificationPage.tsx
Normal file
232
cmd/telesrv-admin/web/src/pages/VerificationPage.tsx
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
import { BadgeCheck, ChevronDown, ChevronRight, Loader2, RefreshCw, Search, ShieldCheck } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { api, errorMessage } from "../api";
|
||||
import { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel } from "../components/ui";
|
||||
import { useI18n } from "../i18n";
|
||||
import { displayUsername, formatDate } from "../lib/format";
|
||||
import type { Navigate } from "../routing";
|
||||
import type {
|
||||
VerificationApplicationRow,
|
||||
VerificationStatus,
|
||||
VerificationTargetType
|
||||
} from "../types";
|
||||
|
||||
type StatusFilter = "all" | VerificationStatus;
|
||||
type TargetFilter = "all" | VerificationTargetType;
|
||||
|
||||
const statuses: VerificationStatus[] = ["draft", "submitted", "in_review", "approved", "rejected", "cancelled"];
|
||||
const targetTypes: VerificationTargetType[] = ["bot", "channel", "supergroup", "user"];
|
||||
|
||||
export function VerificationPage({ navigate }: { navigate: Navigate }) {
|
||||
const { t } = useI18n();
|
||||
const [status, setStatus] = useState<StatusFilter>("all");
|
||||
const [targetType, setTargetType] = useState<TargetFilter>("all");
|
||||
const [reviewer, setReviewer] = useState("");
|
||||
const [q, setQ] = useState("");
|
||||
const [limit, setLimit] = useState("50");
|
||||
const [rows, setRows] = useState<VerificationApplicationRow[]>([]);
|
||||
const [counts, setCounts] = useState<Record<string, string>>({});
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [cursor, setCursor] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
// One free-text field: the backend matches the application id, the target peer
|
||||
// id and a username (applicant or target), so "@durov", "42" and a peer id all
|
||||
// work without a mode switch.
|
||||
async function load(next = false) {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
const params = new URLSearchParams({ limit });
|
||||
if (status !== "all") params.set("status", status);
|
||||
if (targetType !== "all") params.set("target_type", targetType);
|
||||
if (reviewer.trim()) params.set("reviewer", reviewer.trim());
|
||||
if (q.trim()) params.set("q", q.trim().replace(/^@/, ""));
|
||||
if (next && cursor) params.set("before_id", cursor);
|
||||
try {
|
||||
const result = await api.verificationApplications(params);
|
||||
const page = result.rows ?? [];
|
||||
setRows((current) => (next ? [...current, ...page] : page));
|
||||
setCursor(result.next_before_id ?? "");
|
||||
setHasMore(Boolean(result.has_more));
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
// The counts are the whole queue, not the current page, so they are fetched
|
||||
// separately from the keyset listing.
|
||||
async function loadCounts() {
|
||||
try {
|
||||
const result = await api.verificationCounts();
|
||||
setCounts(result.counts ?? {});
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void load(false);
|
||||
void loadCounts();
|
||||
}, []);
|
||||
|
||||
function refresh() {
|
||||
void load(false);
|
||||
void loadCounts();
|
||||
}
|
||||
|
||||
return (
|
||||
<PageFrame
|
||||
title={t("verification.pageTitle")}
|
||||
eyebrow={t("verification.eyebrow")}
|
||||
actions={
|
||||
<button className="btn icon-text" type="button" onClick={refresh} disabled={busy}>
|
||||
<RefreshCw size={15} className={busy ? "spin" : ""} /> {t("common.refresh")}
|
||||
</button>
|
||||
}
|
||||
>
|
||||
{error && <Alert>{error}</Alert>}
|
||||
<div className="metric-row">
|
||||
{statuses.map((item) => (
|
||||
<Metric
|
||||
key={item}
|
||||
label={t(`verification.status.${item}`)}
|
||||
value={counts[item] ?? "0"}
|
||||
mono
|
||||
tone={statusMetricTone(item, counts[item] ?? "0")}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<QueryPanel>
|
||||
<form className="toolbar" onSubmit={(event) => { event.preventDefault(); void load(false); }}>
|
||||
<label className="searchbox">
|
||||
<Search size={15} />
|
||||
<input value={q} onChange={(event) => setQ(event.target.value)} placeholder={t("verification.searchPlaceholder")} />
|
||||
</label>
|
||||
<label className="field-inline">
|
||||
<span>{t("common.status")}</span>
|
||||
<select value={status} onChange={(event) => setStatus(event.target.value as StatusFilter)}>
|
||||
<option value="all">{t("verification.statusAll")}</option>
|
||||
{statuses.map((item) => (
|
||||
<option key={item} value={item}>{t(`verification.status.${item}`)}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="field-inline">
|
||||
<span>{t("verification.targetType")}</span>
|
||||
<select value={targetType} onChange={(event) => setTargetType(event.target.value as TargetFilter)}>
|
||||
<option value="all">{t("verification.targetTypeAll")}</option>
|
||||
{targetTypes.map((item) => (
|
||||
<option key={item} value={item}>{t(`verification.type.${item}`)}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="field-inline">
|
||||
<span>{t("verification.reviewer")}</span>
|
||||
<input value={reviewer} onChange={(event) => setReviewer(event.target.value)} placeholder={t("verification.reviewerPlaceholder")} />
|
||||
</label>
|
||||
<label className="field-inline">
|
||||
<span>{t("common.limit")}</span>
|
||||
<input className="small-input" value={limit} onChange={(event) => setLimit(event.target.value)} type="number" min="1" max="200" />
|
||||
</label>
|
||||
<button className="btn primary icon-text" type="submit" disabled={busy}>
|
||||
{busy ? <Loader2 size={15} className="spin" /> : <Search size={15} />} {t("common.search")}
|
||||
</button>
|
||||
</form>
|
||||
</QueryPanel>
|
||||
|
||||
<div className="table-wrap">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("common.id")}</th>
|
||||
<th>{t("verification.target")}</th>
|
||||
<th>{t("verification.applicant")}</th>
|
||||
<th>{t("verification.category")}</th>
|
||||
<th>{t("common.status")}</th>
|
||||
<th>{t("verification.submittedAt")}</th>
|
||||
<th>{t("verification.reviewer")}</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<tr key={row.ID}>
|
||||
<td className="mono">
|
||||
<button className="row-link" type="button" onClick={() => navigate(`/verification/${row.ID}`)}>
|
||||
#{row.ID}
|
||||
</button>
|
||||
</td>
|
||||
<td>
|
||||
<strong>{targetLabel(row)}</strong>
|
||||
<div className="entity-subtitle mono">
|
||||
{t(`verification.type.${row.TargetType}`)} · {row.TargetID}
|
||||
</div>
|
||||
{row.TargetVerified && (
|
||||
<Badge tone="good"><BadgeCheck size={12} /> {t("verification.alreadyVerified")}</Badge>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{displayUsername(row.ApplicantUsername) || row.ApplicantName || "-"}
|
||||
<div className="entity-subtitle mono">{row.ApplicantUserID}</div>
|
||||
</td>
|
||||
<td>{row.Category || "-"}</td>
|
||||
<td><VerificationStatusBadge status={row.Status} /></td>
|
||||
<td>{formatDate(row.SubmittedAt) || "-"}</td>
|
||||
<td>{row.ReviewerAdminID || "-"}</td>
|
||||
<td>
|
||||
<button className="row-link" type="button" onClick={() => navigate(`/verification/${row.ID}`)}>
|
||||
<ShieldCheck size={14} /> {t("common.detail")} <ChevronRight size={14} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{rows.length === 0 && <EmptyRow colSpan={8} />}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{hasMore && (
|
||||
<div className="toolbar">
|
||||
<button className="btn icon-text" type="button" onClick={() => load(true)} disabled={busy}>
|
||||
{busy ? <Loader2 size={15} className="spin" /> : <ChevronDown size={15} />} {t("common.loadMore")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</PageFrame>
|
||||
);
|
||||
}
|
||||
|
||||
export function VerificationStatusBadge({ status }: { status: VerificationStatus }) {
|
||||
const { t } = useI18n();
|
||||
return <Badge tone={statusTone(status)}>{t(`verification.status.${status}`)}</Badge>;
|
||||
}
|
||||
|
||||
export function statusTone(status: VerificationStatus): "neutral" | "good" | "warn" | "danger" {
|
||||
if (status === "approved") return "good";
|
||||
if (status === "submitted" || status === "in_review") return "warn";
|
||||
if (status === "rejected") return "danger";
|
||||
return "neutral";
|
||||
}
|
||||
|
||||
// submitted and in_review are the two statuses that need a reviewer; they are
|
||||
// highlighted only while something actually sits in them.
|
||||
function statusMetricTone(status: VerificationStatus, count: string): "neutral" | "good" | "warn" {
|
||||
const waiting = status === "submitted" || status === "in_review";
|
||||
if (!waiting) return status === "approved" ? "good" : "neutral";
|
||||
return count !== "0" && count !== "" ? "warn" : "neutral";
|
||||
}
|
||||
|
||||
export function targetLabel(row: VerificationApplicationRow): string {
|
||||
return displayUsername(row.TargetUsername) || row.TargetTitle || `#${row.TargetID}`;
|
||||
}
|
||||
|
||||
// The panel page that owns the target peer type, so a reviewer can inspect the
|
||||
// live record rather than only the submission snapshot.
|
||||
export function targetHref(row: VerificationApplicationRow): string {
|
||||
if (row.TargetType === "bot") return `/bots/${row.TargetID}`;
|
||||
if (row.TargetType === "user") return `/accounts/${row.TargetID}`;
|
||||
return `/channels/${row.TargetID}`;
|
||||
}
|
||||
75
cmd/telesrv-admin/web/src/permissions.tsx
Normal file
75
cmd/telesrv-admin/web/src/permissions.tsx
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import { ShieldOff } from "lucide-react";
|
||||
import { createContext, useContext, useMemo, type ReactNode } from "react";
|
||||
import { Alert, PageFrame } from "./components/ui";
|
||||
import { useI18n } from "./i18n";
|
||||
|
||||
// Permission names exactly as the backend spells them
|
||||
// (cmd/telesrv-admin/security.go). "*" is the wildcard an operator configures for
|
||||
// a full-access session.
|
||||
export const permissionAll = "*";
|
||||
export const permissionVerificationReview = "verification.review";
|
||||
export const permissionVerificationRevoke = "verification.revoke";
|
||||
// Third-party verification is a separate mechanism and therefore a separate pair of
|
||||
// rights: review reads the section and decides applications, manage owns the
|
||||
// verifier roster, the icon catalogue and taking a granted mark away.
|
||||
export const permissionBotVerificationReview = "botverification.review";
|
||||
export const permissionBotVerificationManage = "botverification.manage";
|
||||
|
||||
// GET /api/session is read once at boot; the panel keeps the answer here so a
|
||||
// section the session may not use is hidden instead of rendered into a 403. This
|
||||
// is a convenience for the operator, not a security boundary: every route is
|
||||
// checked again server-side.
|
||||
const PermissionsContext = createContext<readonly string[]>([]);
|
||||
|
||||
export function PermissionsProvider({
|
||||
permissions,
|
||||
children
|
||||
}: {
|
||||
permissions: readonly string[];
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return <PermissionsContext.Provider value={permissions}>{children}</PermissionsContext.Provider>;
|
||||
}
|
||||
|
||||
export function usePermissions(): { permissions: readonly string[]; can: (permission: string) => boolean } {
|
||||
const permissions = useContext(PermissionsContext);
|
||||
return useMemo(
|
||||
() => ({
|
||||
permissions,
|
||||
can: (permission: string) => permissions.includes(permissionAll) || permissions.includes(permission)
|
||||
}),
|
||||
[permissions]
|
||||
);
|
||||
}
|
||||
|
||||
export function useCan(permission: string): boolean {
|
||||
return usePermissions().can(permission);
|
||||
}
|
||||
|
||||
// PermissionGate is what a direct URL hits: without the right the operator gets
|
||||
// an explanation naming the missing permission, not an empty table that looks
|
||||
// like "no data".
|
||||
export function PermissionGate({ permission, children }: { permission: string; children: ReactNode }) {
|
||||
const { can } = usePermissions();
|
||||
if (can(permission)) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
return <PermissionDenied permission={permission} />;
|
||||
}
|
||||
|
||||
export function PermissionDenied({ permission }: { permission: string }) {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<PageFrame title={t("permission.deniedTitle")} eyebrow={t("permission.deniedEyebrow")}>
|
||||
<Alert>{t("permission.deniedBody", { permission })}</Alert>
|
||||
<section className="section-block">
|
||||
<div className="entity-head">
|
||||
<div>
|
||||
<div className="entity-title"><ShieldOff size={16} /> {t("permission.deniedHeading")}</div>
|
||||
<div className="entity-subtitle">{t("permission.deniedHint")}</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</PageFrame>
|
||||
);
|
||||
}
|
||||
|
|
@ -17,6 +17,12 @@ export function currentRoute(): RouteState {
|
|||
}
|
||||
|
||||
export function routeTitle(pathname: string, t: TFunction): string {
|
||||
// Third-party verification is tested before the official section and before
|
||||
// "/bots": three different prefixes that all read as "verification of a bot".
|
||||
if (pathname.startsWith("/bot-verification")) return t("route.botVerification");
|
||||
if (pathname.startsWith("/verification")) return t("route.verification");
|
||||
if (pathname.startsWith("/collectible-usernames")) return t("route.collectibleUsernames");
|
||||
if (pathname.startsWith("/account-ratings")) return t("route.accountRatings");
|
||||
if (pathname.startsWith("/accounts")) return t("route.accounts");
|
||||
if (pathname.startsWith("/channels")) return t("route.channels");
|
||||
if (pathname.startsWith("/bots")) return t("route.bots");
|
||||
|
|
@ -29,6 +35,10 @@ export function routeTitle(pathname: string, t: TFunction): string {
|
|||
}
|
||||
|
||||
export function routeSubtitle(pathname: string, t: TFunction): string {
|
||||
if (pathname.startsWith("/bot-verification")) return t("route.botVerificationSubtitle");
|
||||
if (pathname.startsWith("/verification")) return t("route.verificationSubtitle");
|
||||
if (pathname.startsWith("/collectible-usernames")) return t("route.collectibleUsernamesSubtitle");
|
||||
if (pathname.startsWith("/account-ratings")) return t("route.accountRatingsSubtitle");
|
||||
if (pathname.startsWith("/accounts")) return t("route.accountsSubtitle");
|
||||
if (pathname.startsWith("/channels")) return t("route.channelsSubtitle");
|
||||
if (pathname.startsWith("/bots")) return t("route.botsSubtitle");
|
||||
|
|
|
|||
|
|
@ -349,6 +349,22 @@ select {
|
|||
|
||||
select {
|
||||
min-width: 220px;
|
||||
height: 34px;
|
||||
padding: 0 30px 0 10px;
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
cursor: pointer;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%239aa4b2' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 10px center;
|
||||
}
|
||||
|
||||
select:disabled {
|
||||
color: var(--muted-2);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
textarea {
|
||||
|
|
@ -621,3 +637,43 @@ textarea:focus {
|
|||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
|
||||
/* Level progress bars (account rating leaderboard and detail). */
|
||||
.progress-cell {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
min-width: 130px;
|
||||
}
|
||||
|
||||
.progress-cell small,
|
||||
.progress-note {
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
background: var(--panel-subtle);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.progress-bar > span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: var(--brand-2);
|
||||
}
|
||||
|
||||
.progress-bar.good > span {
|
||||
background: var(--good);
|
||||
}
|
||||
|
||||
.progress-bar.danger > span {
|
||||
background: var(--danger);
|
||||
}
|
||||
|
||||
.progress-wide .progress-cell {
|
||||
min-width: 0;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -103,7 +103,8 @@
|
|||
font-weight: 800;
|
||||
}
|
||||
|
||||
.duration-field input {
|
||||
.duration-field input,
|
||||
.duration-field select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
|
|
@ -126,6 +127,16 @@
|
|||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
/* A .dock-title already draws the rule under itself, so a .danger-zone placed
|
||||
directly after one must not draw a second: the verification and bot-verification
|
||||
detail docks label the zone with a dock-title and rendered two lines 10px apart
|
||||
above the revoke button. */
|
||||
.dock-title + .danger-zone {
|
||||
margin-top: 0;
|
||||
padding-top: 0;
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
.authorization-block {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
|
|
@ -585,7 +596,8 @@
|
|||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.attr-block .duration-field input {
|
||||
.attr-block .duration-field input,
|
||||
.duration-field select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
|
|
@ -685,3 +697,113 @@
|
|||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Account rating component breakdown. */
|
||||
.breakdown-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.breakdown-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(140px, 260px) 1fr minmax(80px, auto);
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 9px 10px;
|
||||
background: var(--panel-subtle);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.breakdown-row.total {
|
||||
grid-template-columns: 1fr minmax(80px, auto);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.breakdown-label {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.breakdown-label strong {
|
||||
color: var(--text);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.breakdown-label small {
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.breakdown-value {
|
||||
color: var(--text);
|
||||
font-weight: 800;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.breakdown-value.good {
|
||||
color: var(--good);
|
||||
}
|
||||
|
||||
.breakdown-value.danger {
|
||||
color: var(--danger-text);
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.breakdown-row,
|
||||
.breakdown-row.total {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.breakdown-value {
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
|
||||
/* Collectible usernames branching off the peer's editable one. The guide is drawn
|
||||
with borders rather than a "↳" character so it lines up at any font size and is
|
||||
not read out by a screen reader as punctuation. */
|
||||
.username-branch {
|
||||
margin: 2px 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.username-branch li {
|
||||
position: relative;
|
||||
padding-left: 14px;
|
||||
color: var(--text-soft);
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.username-branch li::before {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 3px;
|
||||
width: 6px;
|
||||
height: 11px;
|
||||
border-left: 1px solid var(--line-strong, var(--line));
|
||||
border-bottom: 1px solid var(--line-strong, var(--line));
|
||||
content: "";
|
||||
}
|
||||
|
||||
.username-branch li.inactive {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.username-branch li.inactive span {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.username-branch li em {
|
||||
margin-left: 6px;
|
||||
font-size: 10px;
|
||||
font-style: normal;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,19 @@
|
|||
// AccountUsername is one collectible username the peer holds. Active mirrors the
|
||||
// username#b4073647 flag: an inactive collectible is owned but does not resolve.
|
||||
export type AccountUsername = {
|
||||
Username: string;
|
||||
Active: boolean;
|
||||
};
|
||||
|
||||
export type AccountRow = {
|
||||
ID: number;
|
||||
Phone: string;
|
||||
Username: string;
|
||||
FirstName: string;
|
||||
LastName: string;
|
||||
// Collectible usernames in projection order; never includes the editable slot
|
||||
// above. Always an array, so it can be iterated unconditionally.
|
||||
Collectibles: AccountUsername[];
|
||||
CreatedAt: string;
|
||||
UpdatedAt: string;
|
||||
Frozen: boolean;
|
||||
|
|
@ -349,6 +359,347 @@ export type StarGiftCollectiblePreview = {
|
|||
backdrops?: StarGiftCollectibleAttributeRow[];
|
||||
};
|
||||
|
||||
export type CollectibleUsernameStatus = "vault" | "owned" | "burned";
|
||||
|
||||
export type CollectiblePeerType = "" | "user" | "channel";
|
||||
|
||||
export type CollectibleCurrency = "XTR" | "TON" | "USD";
|
||||
|
||||
// int64 columns arrive as JSON strings to survive the 2^53 boundary.
|
||||
export type CollectibleUsernameRow = {
|
||||
ID: string;
|
||||
Username: string;
|
||||
Status: CollectibleUsernameStatus;
|
||||
OwnerPeerType: CollectiblePeerType;
|
||||
OwnerPeerID: string;
|
||||
OwnerUsername: string;
|
||||
OwnerName: string;
|
||||
PurchaseDate: string;
|
||||
Currency: CollectibleCurrency;
|
||||
Amount: string;
|
||||
CryptoCurrency: string;
|
||||
CryptoAmount: string;
|
||||
URL: string;
|
||||
OriginalOwnerPeerType: string;
|
||||
OriginalOwnerPeerID: string;
|
||||
OriginalOwnerUsername: string;
|
||||
TransferCount: number;
|
||||
Version: string;
|
||||
// Mirrors the holder's username-registry row: an owned asset can still be
|
||||
// hidden from the profile.
|
||||
RegistryActive: boolean;
|
||||
RegistrySortOrder: number;
|
||||
CreatedAt: string;
|
||||
UpdatedAt: string;
|
||||
};
|
||||
|
||||
export type CollectibleUsernameTransferKind = "mint" | "transfer" | "revoke" | "burn";
|
||||
|
||||
export type CollectibleUsernameTransferRow = {
|
||||
ID: string;
|
||||
CollectibleID: string;
|
||||
Kind: CollectibleUsernameTransferKind;
|
||||
FromPeerType: string;
|
||||
FromPeerID: string;
|
||||
FromUsername: string;
|
||||
ToPeerType: string;
|
||||
ToPeerID: string;
|
||||
ToUsername: string;
|
||||
Currency: string;
|
||||
Amount: string;
|
||||
Actor: string;
|
||||
Reason: string;
|
||||
CommandKey: string;
|
||||
CreatedAt: string;
|
||||
};
|
||||
|
||||
export type CollectibleUsernameListResponse = {
|
||||
rows: CollectibleUsernameRow[] | null;
|
||||
has_more: boolean;
|
||||
next_before_id: string;
|
||||
};
|
||||
|
||||
export type CollectibleUsernameDetail = {
|
||||
asset: CollectibleUsernameRow;
|
||||
transfers: CollectibleUsernameTransferRow[] | null;
|
||||
};
|
||||
|
||||
export type AccountRatingRow = {
|
||||
UserID: string;
|
||||
Username: string;
|
||||
FirstName: string;
|
||||
Level: number;
|
||||
Stars: string;
|
||||
CurrentLevelStars: string;
|
||||
NextLevelStars: string;
|
||||
HasNextLevel: boolean;
|
||||
StarsComponent: string;
|
||||
ActivityComponent: string;
|
||||
PenaltyComponent: string;
|
||||
ManualComponent: string;
|
||||
PendingStars: string;
|
||||
PendingDate: string;
|
||||
ComputedAt: string;
|
||||
UpdatedAt: string;
|
||||
Version: string;
|
||||
};
|
||||
|
||||
export type AccountRatingEventKind = "stars" | "activity" | "moderation" | "manual" | "recompute";
|
||||
|
||||
export type AccountRatingEventRow = {
|
||||
ID: string;
|
||||
UserID: string;
|
||||
Kind: AccountRatingEventKind;
|
||||
Amount: string;
|
||||
Reason: string;
|
||||
Actor: string;
|
||||
CommandKey: string;
|
||||
CreatedAt: string;
|
||||
};
|
||||
|
||||
export type AccountRatingListResponse = {
|
||||
rows: AccountRatingRow[] | null;
|
||||
has_more: boolean;
|
||||
next_before_id: string;
|
||||
};
|
||||
|
||||
export type AccountRatingDetail = {
|
||||
rating: AccountRatingRow;
|
||||
events: AccountRatingEventRow[] | null;
|
||||
};
|
||||
|
||||
// Official platform verification. Every int64 the backend tags `,string` stays a
|
||||
// decimal string here: application ids, peer ids and the optimistic-locking
|
||||
// version all outgrow the exact range of a JSON number, and a rounded version
|
||||
// would send a decision against the wrong revision of the row.
|
||||
export type VerificationTargetType = "bot" | "channel" | "supergroup" | "user";
|
||||
|
||||
export type VerificationStatus =
|
||||
| "draft"
|
||||
| "submitted"
|
||||
| "in_review"
|
||||
| "approved"
|
||||
| "rejected"
|
||||
| "cancelled";
|
||||
|
||||
export type VerificationEventKind =
|
||||
| "created"
|
||||
| "updated"
|
||||
| "submitted"
|
||||
| "claimed"
|
||||
| "approved"
|
||||
| "rejected"
|
||||
| "cancelled"
|
||||
| "revoked"
|
||||
| "notified";
|
||||
|
||||
export type VerificationApplicationRow = {
|
||||
ID: string;
|
||||
ApplicantUserID: string;
|
||||
ApplicantUsername: string;
|
||||
ApplicantName: string;
|
||||
TargetType: VerificationTargetType;
|
||||
TargetID: string;
|
||||
TargetTitle: string;
|
||||
TargetUsername: string;
|
||||
TargetVerified: boolean;
|
||||
Category: string;
|
||||
Description: string;
|
||||
OfficialWebsite: string;
|
||||
// Go marshals an empty slice as null, so both shapes have to be tolerated.
|
||||
SocialLinks: string[] | null;
|
||||
PressLinks: string[] | null;
|
||||
AdditionalNote: string;
|
||||
Status: VerificationStatus;
|
||||
ReviewerAdminID: string;
|
||||
DecisionReason: string;
|
||||
// InternalNote is the reviewer handover note: operator-only, never shown to the
|
||||
// applicant.
|
||||
InternalNote: string;
|
||||
CorrelationID: string;
|
||||
CreatedAt: string;
|
||||
UpdatedAt: string;
|
||||
SubmittedAt: string;
|
||||
ReviewedAt: string;
|
||||
Version: string;
|
||||
};
|
||||
|
||||
export type VerificationEventRow = {
|
||||
ID: string;
|
||||
Kind: VerificationEventKind;
|
||||
FromStatus: string;
|
||||
ToStatus: string;
|
||||
Actor: string;
|
||||
Reason: string;
|
||||
Note: string;
|
||||
CreatedAt: string;
|
||||
};
|
||||
|
||||
export type VerificationApplicationListResponse = {
|
||||
rows: VerificationApplicationRow[] | null;
|
||||
has_more: boolean;
|
||||
next_before_id: string;
|
||||
};
|
||||
|
||||
export type VerificationApplicationDetail = {
|
||||
application: VerificationApplicationRow;
|
||||
events: VerificationEventRow[] | null;
|
||||
// Both flags describe the target as it is now, not as it was at submission.
|
||||
applicant_controls_target: boolean;
|
||||
target_verified: boolean;
|
||||
};
|
||||
|
||||
// Counts are decimal strings for the same exactness reason as the ids; the
|
||||
// backend always sends all six statuses.
|
||||
export type VerificationCountsResponse = {
|
||||
counts: Record<string, string> | null;
|
||||
};
|
||||
|
||||
// Third-party bot verification (core.telegram.org/api/bots/verification): a
|
||||
// verifier bot marks a peer with its OWN icon and description, rendered before the
|
||||
// name. It is a different mechanism from the official checkmark above — the two
|
||||
// never read each other's state — so it gets its own row types rather than reusing
|
||||
// VerificationApplicationRow.
|
||||
//
|
||||
// Every int64 the backend tags `,string` stays a decimal string here: bot ids, peer
|
||||
// ids, custom emoji document ids and the optimistic-locking version all outgrow the
|
||||
// exact range of a JSON number.
|
||||
export type BotVerificationPeerType = "user" | "channel";
|
||||
|
||||
export type CustomVerificationRequestStatus = "pending" | "approved" | "rejected" | "revoked";
|
||||
|
||||
// MarkCount is tagged `,string` like the ids (it is the count that would cascade
|
||||
// away with a revocation, read as int64), while VerificationIconRow.UsedByVerifiers
|
||||
// is a plain number — it counts verifier rows and cannot approach the exactness
|
||||
// limit. Both are rendered through String(), so neither shape can surprise a cell.
|
||||
export type BotVerifierRow = {
|
||||
BotID: string;
|
||||
BotUsername: string;
|
||||
BotName: string;
|
||||
// IconDocumentID is the custom emoji document the verifier marks with. Clients
|
||||
// resolve it through messages.getCustomEmojiDocuments, so an id naming no
|
||||
// fetchable document renders as no badge at all.
|
||||
IconDocumentID: string;
|
||||
IconName: string;
|
||||
CompanyName: string;
|
||||
DefaultDescription: string;
|
||||
// CanModifyCustomDescription mirrors botVerifierSettings flags.1: when false the
|
||||
// verifier may only apply DefaultDescription.
|
||||
CanModifyCustomDescription: boolean;
|
||||
// Enabled is the operator kill switch: a disabled verifier keeps its granted
|
||||
// marks but can no longer mark anything new.
|
||||
Enabled: boolean;
|
||||
GrantedBy: string;
|
||||
GrantReason: string;
|
||||
MarkCount: string;
|
||||
CreatedAt: string;
|
||||
UpdatedAt: string;
|
||||
Version: string;
|
||||
};
|
||||
|
||||
export type VerificationIconRow = {
|
||||
ID: string;
|
||||
DocumentID: string;
|
||||
// OwnerBotID is "0" for a catalogue entry any verifier may use, and a bot id
|
||||
// when the operator reserved the icon for one verifier.
|
||||
OwnerBotID: string;
|
||||
OwnerBotUsername: string;
|
||||
Name: string;
|
||||
Active: boolean;
|
||||
UsedByVerifiers: number;
|
||||
CreatedAt: string;
|
||||
UpdatedAt: string;
|
||||
};
|
||||
|
||||
export type CustomVerificationRow = {
|
||||
ID: string;
|
||||
VerifierBotID: string;
|
||||
VerifierBotUsername: string;
|
||||
CompanyName: string;
|
||||
PeerType: BotVerificationPeerType;
|
||||
PeerID: string;
|
||||
PeerTitle: string;
|
||||
PeerUsername: string;
|
||||
// Denormalised at grant time, so a mark keeps the icon it was granted with even
|
||||
// after the verifier changes its own.
|
||||
IconDocumentID: string;
|
||||
Description: string;
|
||||
CreatedAt: string;
|
||||
UpdatedAt: string;
|
||||
Version: string;
|
||||
};
|
||||
|
||||
export type CustomVerificationRequestRow = {
|
||||
ID: string;
|
||||
VerifierBotID: string;
|
||||
VerifierBotUsername: string;
|
||||
ApplicantUserID: string;
|
||||
ApplicantUsername: string;
|
||||
PeerType: BotVerificationPeerType;
|
||||
PeerID: string;
|
||||
PeerTitle: string;
|
||||
PeerUsername: string;
|
||||
Reason: string;
|
||||
RequestedDescription: string;
|
||||
Status: CustomVerificationRequestStatus;
|
||||
DecidedBy: string;
|
||||
DecisionReason: string;
|
||||
// InternalNote is the operator handover note: never shown to the applicant.
|
||||
InternalNote: string;
|
||||
CorrelationID: string;
|
||||
CreatedAt: string;
|
||||
UpdatedAt: string;
|
||||
ApprovedAt: string;
|
||||
RejectedAt: string;
|
||||
Version: string;
|
||||
};
|
||||
|
||||
export type BotVerifierListResponse = {
|
||||
rows: BotVerifierRow[] | null;
|
||||
};
|
||||
|
||||
export type VerificationIconListResponse = {
|
||||
rows: VerificationIconRow[] | null;
|
||||
};
|
||||
|
||||
export type CustomVerificationListResponse = {
|
||||
rows: CustomVerificationRow[] | null;
|
||||
has_more: boolean;
|
||||
next_before_id: string;
|
||||
};
|
||||
|
||||
export type CustomVerificationRequestListResponse = {
|
||||
rows: CustomVerificationRequestRow[] | null;
|
||||
has_more: boolean;
|
||||
next_before_id: string;
|
||||
};
|
||||
|
||||
export type CustomVerificationRequestDetail = {
|
||||
request: CustomVerificationRequestRow;
|
||||
// The verifier row as it is now: it can be disabled, or revoked entirely, after
|
||||
// the application was filed.
|
||||
verifier: BotVerifierRow | null;
|
||||
// mark_active describes the peer right now, not the application status: an
|
||||
// approved application whose mark a verifier later withdrew reads false.
|
||||
mark_active: boolean;
|
||||
};
|
||||
|
||||
// Counts are decimal strings for the same exactness reason as the ids; the backend
|
||||
// always sends all four statuses.
|
||||
export type BotVerificationCountsResponse = {
|
||||
counts: Record<string, string> | null;
|
||||
};
|
||||
|
||||
export type AdminSession = {
|
||||
actor: string;
|
||||
// The right set the signed session was issued with; ["*"] means everything.
|
||||
permissions?: string[] | null;
|
||||
};
|
||||
|
||||
export type AdminLoginResult = AdminSession & {
|
||||
csrf_token: string;
|
||||
};
|
||||
|
||||
export type MessageDetail = {
|
||||
Message: MessageRow;
|
||||
MessageJSON: string;
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ import (
|
|||
"telesrv/internal/app/auth"
|
||||
authdiagnosticsapp "telesrv/internal/app/authdiagnostics"
|
||||
botsapp "telesrv/internal/app/bots"
|
||||
botverificationapp "telesrv/internal/app/botverification"
|
||||
channelapp "telesrv/internal/app/channels"
|
||||
chatlistsapp "telesrv/internal/app/chatlists"
|
||||
clienttelemetryapp "telesrv/internal/app/clienttelemetry"
|
||||
|
|
@ -48,6 +49,7 @@ import (
|
|||
phoneapp "telesrv/internal/app/phone"
|
||||
pollsapp "telesrv/internal/app/polls"
|
||||
privacyapp "telesrv/internal/app/privacy"
|
||||
ratingapp "telesrv/internal/app/rating"
|
||||
secretchatapp "telesrv/internal/app/secretchat"
|
||||
"telesrv/internal/app/stargifts"
|
||||
"telesrv/internal/app/stars"
|
||||
|
|
@ -56,8 +58,10 @@ import (
|
|||
themesapp "telesrv/internal/app/themes"
|
||||
translationapp "telesrv/internal/app/translation"
|
||||
"telesrv/internal/app/updates"
|
||||
usernamesapp "telesrv/internal/app/usernames"
|
||||
"telesrv/internal/app/userprojection"
|
||||
"telesrv/internal/app/users"
|
||||
verificationapp "telesrv/internal/app/verification"
|
||||
"telesrv/internal/botapi"
|
||||
"telesrv/internal/config"
|
||||
"telesrv/internal/domain"
|
||||
|
|
@ -270,6 +274,172 @@ func liveStreamDep(s *livestream.Service) rpc.LiveStreamsService {
|
|||
return s
|
||||
}
|
||||
|
||||
// verificationPeerVerifier writes the platform verification flag onto the peer
|
||||
// record for app/verification.
|
||||
//
|
||||
// It is called from *inside* the store transaction that decides the application,
|
||||
// which is the whole point of the port: "approved" and "target carries the badge"
|
||||
// must commit together. That is why the transaction is taken from the context
|
||||
// (postgres.VerificationTxFromContext) and written through — a write on a separate
|
||||
// pool connection would survive a rollback of the decision and leave a peer
|
||||
// wearing a badge no approved application backs.
|
||||
//
|
||||
// The app-service path is only the fallback for a context that carries no
|
||||
// transaction (a non-postgres store, or a direct call): there is nothing to join
|
||||
// then, and going through the services keeps their cache refresh behaviour.
|
||||
type verificationPeerVerifier struct {
|
||||
users interface {
|
||||
SetVerified(ctx context.Context, userID int64, verified bool) (domain.User, error)
|
||||
}
|
||||
channels interface {
|
||||
SetVerified(ctx context.Context, channelID int64, verified bool) (domain.Channel, error)
|
||||
}
|
||||
// channelRowCache is handed to the transaction-scoped channel store so the
|
||||
// cached channel row is dropped on the flag write, exactly as the pooled store
|
||||
// does it.
|
||||
channelRowCache *postgres.ChannelRowCache
|
||||
}
|
||||
|
||||
func (v verificationPeerVerifier) SetUserVerified(ctx context.Context, userID int64, verified bool) error {
|
||||
if tx, ok := postgres.VerificationTxFromContext(ctx); ok {
|
||||
_, err := postgres.NewUserStore(tx).SetVerified(ctx, userID, verified)
|
||||
return err
|
||||
}
|
||||
if v.users == nil {
|
||||
return fmt.Errorf("verification peer verifier: user service is not wired")
|
||||
}
|
||||
_, err := v.users.SetVerified(ctx, userID, verified)
|
||||
return err
|
||||
}
|
||||
|
||||
func (v verificationPeerVerifier) SetChannelVerified(ctx context.Context, channelID int64, verified bool) error {
|
||||
if tx, ok := postgres.VerificationTxFromContext(ctx); ok {
|
||||
opts := []postgres.ChannelStoreOption(nil)
|
||||
if v.channelRowCache != nil {
|
||||
opts = append(opts, postgres.WithChannelRowCache(v.channelRowCache))
|
||||
}
|
||||
_, err := postgres.NewChannelStore(tx, opts...).SetChannelVerified(ctx, channelID, verified)
|
||||
return err
|
||||
}
|
||||
if v.channels == nil {
|
||||
return fmt.Errorf("verification peer verifier: channel service is not wired")
|
||||
}
|
||||
_, err := v.channels.SetVerified(ctx, channelID, verified)
|
||||
return err
|
||||
}
|
||||
|
||||
var _ verificationapp.PeerVerifier = verificationPeerVerifier{}
|
||||
|
||||
// botVerificationMarkApplier writes a third-party mark on the decision's own
|
||||
// transaction when there is one.
|
||||
//
|
||||
// postgres.DecideCustomVerificationRequest hands its callback a context carrying
|
||||
// the transaction, and the pooled store would open a second, independently
|
||||
// committing one -- so an approval whose mark write failed would leave the request
|
||||
// approved with no mark. This adapter is what makes "approved implies mark exists"
|
||||
// survive a rollback, exactly as verificationPeerVerifier does for the official flag.
|
||||
type botVerificationMarkApplier struct {
|
||||
store storepkg.BotVerificationStore
|
||||
}
|
||||
|
||||
func (a botVerificationMarkApplier) GrantCustomVerification(ctx context.Context, mark domain.CustomVerification) (domain.CustomVerification, bool, error) {
|
||||
if tx, ok := postgres.VerificationTxFromContext(ctx); ok {
|
||||
return postgres.NewBotVerificationStore(tx).GrantCustomVerification(ctx, mark)
|
||||
}
|
||||
return a.store.GrantCustomVerification(ctx, mark)
|
||||
}
|
||||
|
||||
func (a botVerificationMarkApplier) RevokeCustomVerification(ctx context.Context, verifierBotID int64, peer domain.Peer) (bool, error) {
|
||||
if tx, ok := postgres.VerificationTxFromContext(ctx); ok {
|
||||
return postgres.NewBotVerificationStore(tx).RevokeCustomVerification(ctx, verifierBotID, peer)
|
||||
}
|
||||
return a.store.RevokeCustomVerification(ctx, verifierBotID, peer)
|
||||
}
|
||||
|
||||
var _ botverificationapp.MarkApplier = botVerificationMarkApplier{}
|
||||
|
||||
// compositeBotVerificationNotifier drops the cached peer projections before the
|
||||
// edge rebuilds and pushes the peer, so a mark change cannot be pushed with a
|
||||
// stale badge.
|
||||
type compositeBotVerificationNotifier struct {
|
||||
cache rpcProjectionVerificationNotifier
|
||||
edge botverificationapp.PeerNotifier
|
||||
}
|
||||
|
||||
func (n compositeBotVerificationNotifier) NotifyPeerBotVerification(ctx context.Context, peer domain.Peer) error {
|
||||
if err := n.cache.NotifyPeerVerified(ctx, peer); err != nil && n.cache.log != nil {
|
||||
n.cache.log.Warn("invalidate peer caches after third-party verification change",
|
||||
zap.String("peer_type", string(peer.Type)), zap.Int64("peer_id", peer.ID), zap.Error(err))
|
||||
}
|
||||
if n.edge == nil {
|
||||
return nil
|
||||
}
|
||||
return n.edge.NotifyPeerBotVerification(ctx, peer)
|
||||
}
|
||||
|
||||
var _ botverificationapp.PeerNotifier = compositeBotVerificationNotifier{}
|
||||
|
||||
// rpcProjectionVerificationNotifier is the fallback badge-change hook, the same
|
||||
// shape and for the same reason as rpcProjectionUsernameNotifier: the RPC edge
|
||||
// owns both the cached peer projections and the tg.* push, and until it exposes
|
||||
// NotifyPeerVerified only the invalidation half can be wired here. Invalidation is
|
||||
// the half that must not be skipped — a decided application whose peer projection
|
||||
// still says "not verified" would keep showing the old badge state to every client
|
||||
// that reads from cache.
|
||||
type rpcProjectionVerificationNotifier struct {
|
||||
invalidator interface {
|
||||
InvalidateRPCProjectionReadModelForUser(userID int64)
|
||||
InvalidateRPCProjectionReadModelForChannel(channelID int64)
|
||||
}
|
||||
users storepkg.UserCache
|
||||
log *zap.Logger
|
||||
}
|
||||
|
||||
func (n rpcProjectionVerificationNotifier) NotifyPeerVerified(ctx context.Context, peer domain.Peer) error {
|
||||
if n.invalidator == nil {
|
||||
return nil
|
||||
}
|
||||
switch peer.Type {
|
||||
case domain.PeerTypeUser:
|
||||
n.invalidator.InvalidateRPCProjectionReadModelForUser(peer.ID)
|
||||
// The shared user:base cache is the source the projection rebuilds from, so
|
||||
// dropping only the projection would let it rebuild from a stale row.
|
||||
if n.users != nil {
|
||||
if err := n.users.Delete(ctx, []int64{peer.ID}); err != nil && n.log != nil {
|
||||
n.log.Warn("invalidate base user cache after verification change",
|
||||
zap.Int64("user_id", peer.ID), zap.Error(err))
|
||||
}
|
||||
}
|
||||
case domain.PeerTypeChannel:
|
||||
n.invalidator.InvalidateRPCProjectionReadModelForChannel(peer.ID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// compositeVerificationNotifier drops the cached peer projections first and only
|
||||
// then lets the protocol edge push the change, so the pushed peer is rebuilt from
|
||||
// the committed row rather than from a cache entry written before the decision.
|
||||
// A cache failure must not swallow the push: the push is what online clients see.
|
||||
type compositeVerificationNotifier struct {
|
||||
cache rpcProjectionVerificationNotifier
|
||||
edge verificationapp.PeerNotifier
|
||||
}
|
||||
|
||||
func (n compositeVerificationNotifier) NotifyPeerVerified(ctx context.Context, peer domain.Peer) error {
|
||||
if err := n.cache.NotifyPeerVerified(ctx, peer); err != nil && n.cache.log != nil {
|
||||
n.cache.log.Warn("invalidate peer caches after verification change",
|
||||
zap.String("peer_type", string(peer.Type)), zap.Int64("peer_id", peer.ID), zap.Error(err))
|
||||
}
|
||||
if n.edge == nil {
|
||||
return nil
|
||||
}
|
||||
return n.edge.NotifyPeerVerified(ctx, peer)
|
||||
}
|
||||
|
||||
var _ verificationapp.PeerNotifier = compositeVerificationNotifier{}
|
||||
|
||||
var _ verificationapp.PeerNotifier = rpcProjectionVerificationNotifier{}
|
||||
|
||||
func externalMediaOption(cfg config.Config) filesapp.Option {
|
||||
if !cfg.ExternalMediaEnable {
|
||||
return nil
|
||||
|
|
@ -659,6 +829,7 @@ func run(logger *zap.Logger) error {
|
|||
botsapp.WithStickerSetCreator(filesService),
|
||||
botsapp.WithUserStickerSets(accountService),
|
||||
botsapp.WithTelegramLogin(telegramLoginService),
|
||||
botsapp.WithDialogRateLimiter(rateLimiter, cfg.VerificationBotRateLimit, cfg.VerificationBotRateWindow),
|
||||
botsapp.WithPublicBaseURL(cfg.PublicBaseURL))
|
||||
groupCallStore := postgres.NewGroupCallStore(pool)
|
||||
groupCallsService := groupcallsapp.NewService(groupCallStore, groupcallsapp.WithPublicBaseURL(cfg.PublicBaseURL))
|
||||
|
|
@ -859,6 +1030,78 @@ func run(logger *zap.Logger) error {
|
|||
Store: accountService,
|
||||
Sender: loginEmailSender,
|
||||
}))
|
||||
// Collectible (NFT) usernames are projected at the protocol edge. The
|
||||
// composite account rating is a separate local admin read model and is never
|
||||
// projected into Telegram's stars_rating fields.
|
||||
collectibleUsernameStore := postgres.NewCollectibleUsernameStore(pool)
|
||||
accountRatingStore := postgres.NewAccountRatingStore(pool)
|
||||
usernamesService := usernamesapp.NewService(
|
||||
usernamesapp.WithRegistryStore(collectibleUsernameStore),
|
||||
usernamesapp.WithCollectibleStore(collectibleUsernameStore),
|
||||
usernamesapp.WithURLTemplate(cfg.CollectibleUsernameURLTemplate),
|
||||
usernamesapp.WithPublicBaseURL(cfg.PublicBaseURL),
|
||||
usernamesapp.WithLogger(logger.Named("app").Named("usernames")),
|
||||
)
|
||||
ratingService := ratingapp.NewService(
|
||||
ratingapp.WithStore(accountRatingStore),
|
||||
ratingapp.WithEnabled(cfg.RatingEnabled),
|
||||
ratingapp.WithWeights(cfg.AccountRatingWeights()),
|
||||
ratingapp.WithPendingDelay(cfg.RatingPendingDelay),
|
||||
ratingapp.WithStaleAfter(cfg.RatingStaleAfter),
|
||||
ratingapp.WithLogger(logger.Named("app").Named("rating")),
|
||||
)
|
||||
// Official platform verification: applications are filed through the built-in
|
||||
// @verifybot and decided in the admin panel. Every eligibility rule lives in
|
||||
// this service; the bot and the panel are only its two surfaces.
|
||||
verificationStore := postgres.NewVerificationStore(pool)
|
||||
verificationLogger := logger.Named("app").Named("verification")
|
||||
verificationService := verificationapp.NewService(
|
||||
verificationapp.WithStore(verificationStore),
|
||||
verificationapp.WithUserDirectory(usersService),
|
||||
verificationapp.WithBotDirectory(botsService),
|
||||
verificationapp.WithChannelDirectory(channelsService),
|
||||
verificationapp.WithAccountFreezeProvider(adminService),
|
||||
verificationapp.WithPeerVerifier(verificationPeerVerifier{
|
||||
users: usersService,
|
||||
channels: channelsService,
|
||||
channelRowCache: channelRowCache,
|
||||
}),
|
||||
verificationapp.WithRateLimiter(rateLimiter, cfg.VerificationApplyRateLimit, cfg.VerificationApplyRateWindow),
|
||||
verificationapp.WithEnabled(cfg.VerificationEnabled),
|
||||
verificationapp.WithAllowUserTargets(cfg.VerificationAllowUserTargets),
|
||||
verificationapp.WithRejectCooldown(cfg.VerificationRejectCooldown),
|
||||
verificationapp.WithMaxActivePerUser(cfg.VerificationMaxActivePerUser),
|
||||
verificationapp.WithLogger(verificationLogger),
|
||||
)
|
||||
// @verifybot is the applicant surface, and the notifier that carries decisions
|
||||
// back to the applicant as ordinary messages. Both directions are deferred
|
||||
// injections because the bots service is built before the peer directories the
|
||||
// verification service needs.
|
||||
botsService.SetVerification(verificationService)
|
||||
verificationService.SetApplicantNotifier(botsService)
|
||||
// Third-party verification is a SEPARATE mechanism: a verifier bot marks peers
|
||||
// with its own custom-emoji icon and description, which clients render before the
|
||||
// name. It shares no state with the official badge above -- different tables,
|
||||
// different rights, different TL fields (bot_verification_icon / bot_verification
|
||||
// versus verified).
|
||||
botVerificationStore := postgres.NewBotVerificationStore(pool)
|
||||
botVerificationService := botverificationapp.NewService(
|
||||
botverificationapp.WithStore(botVerificationStore),
|
||||
botverificationapp.WithUserDirectory(usersService),
|
||||
botverificationapp.WithBotDirectory(botsService),
|
||||
botverificationapp.WithChannelDirectory(channelsService),
|
||||
// The icon must be a real custom emoji document: an id no client can fetch
|
||||
// renders as nothing, so the badge would be silently invisible.
|
||||
botverificationapp.WithIconResolver(filesService),
|
||||
botverificationapp.WithMarkApplier(botVerificationMarkApplier{store: botVerificationStore}),
|
||||
botverificationapp.WithRateLimiter(rateLimiter, cfg.BotVerificationRequestRateLimit, cfg.BotVerificationRequestRateWindow),
|
||||
botverificationapp.WithEnabled(cfg.BotVerificationEnabled),
|
||||
botverificationapp.WithMaxPerVerifier(cfg.BotVerificationMaxPerVerifier),
|
||||
botverificationapp.WithLogger(logger.Named("app").Named("botverification")),
|
||||
)
|
||||
// @verifierbot files applications with the operator and reports decisions back.
|
||||
botsService.SetCustomVerification(botVerificationService)
|
||||
botVerificationService.SetApplicantNotifier(botsService)
|
||||
updatesService := updates.NewService(updateStateStore, updateEventStore, updates.WithLogger(logger.Named("app").Named("updates")))
|
||||
router := rpc.New(rpc.Config{
|
||||
DC: cfg.DC,
|
||||
|
|
@ -899,6 +1142,8 @@ func run(logger *zap.Logger) error {
|
|||
EphemeralPush: ephemeralStore,
|
||||
Moderation: moderationService,
|
||||
Users: usersService,
|
||||
Usernames: usernamesService,
|
||||
BotVerifications: botVerificationService,
|
||||
TelegramLogin: telegramLoginRPCDependency(telegramLoginService),
|
||||
Updates: updatesService,
|
||||
BootstrapUpdates: bootstrapUpdateStore,
|
||||
|
|
@ -914,6 +1159,7 @@ func run(logger *zap.Logger) error {
|
|||
Files: filesService,
|
||||
PremiumPromo: filesService,
|
||||
Bots: botsService,
|
||||
ServiceBotCallbacks: botsService,
|
||||
Polls: pollsapp.NewService(pollStore),
|
||||
Stories: storiesService,
|
||||
Phone: phoneService,
|
||||
|
|
@ -971,7 +1217,69 @@ func run(logger *zap.Logger) error {
|
|||
Bots: botsService,
|
||||
Emoji: filesService,
|
||||
Moderation: moderationService,
|
||||
Usernames: usernamesService,
|
||||
Rating: ratingService,
|
||||
Verification: verificationService,
|
||||
BotVerification: botVerificationService,
|
||||
})
|
||||
// The RPC edge owns the tg.* projection cache and the standard non-PTS
|
||||
// updateUser/updateChannel refresh, so committed registry mutations are
|
||||
// visible to online viewers immediately.
|
||||
usernamesService.SetPeerUsernameNotifier(router)
|
||||
// The badge change is a peer fact the protocol edge caches and pushes, so the
|
||||
// verification service gets the same hook the username registry uses. The
|
||||
// assertion is deliberately dynamic: NotifyPeerVerified lands with the edge
|
||||
// agent, and until then only projection invalidation is wired — a decision can
|
||||
// then never be masked by a stale projection, and clients converge on their next
|
||||
// authoritative peer read.
|
||||
if notifier, ok := any(router).(verificationapp.PeerNotifier); ok {
|
||||
// Compose rather than choose: the decision writes users.verified inside the
|
||||
// verification transaction (through postgres.VerificationTxFromContext), so it
|
||||
// bypasses users.Service and its cache refresh. Dropping the shared user:base
|
||||
// entry before the edge builds the pushed tg.User is what keeps the badge in
|
||||
// that push from being one beat stale; the cross-instance read-model listener
|
||||
// would otherwise only catch up asynchronously.
|
||||
verificationService.SetPeerNotifier(compositeVerificationNotifier{
|
||||
cache: rpcProjectionVerificationNotifier{
|
||||
invalidator: router,
|
||||
users: userCache,
|
||||
log: verificationLogger,
|
||||
},
|
||||
edge: notifier,
|
||||
})
|
||||
} else {
|
||||
verificationService.SetPeerNotifier(rpcProjectionVerificationNotifier{
|
||||
invalidator: router,
|
||||
users: userCache,
|
||||
log: verificationLogger,
|
||||
})
|
||||
logger.Warn("verification badge update push is not implemented by the RPC edge; only projection invalidation is wired",
|
||||
zap.String("expected_hook", "rpc.Router.NotifyPeerVerified"))
|
||||
}
|
||||
// The third-party mark lives on the same peer projections as the official flag,
|
||||
// so it needs the same edge hook. Composed with the cache drop for the same reason:
|
||||
// the mark can be written on the decision's own transaction, bypassing the app
|
||||
// services that would otherwise refresh the shared user:base entry.
|
||||
if notifier, ok := any(router).(botverificationapp.PeerNotifier); ok {
|
||||
botVerificationService.SetPeerNotifier(compositeBotVerificationNotifier{
|
||||
cache: rpcProjectionVerificationNotifier{
|
||||
invalidator: router,
|
||||
users: userCache,
|
||||
log: verificationLogger,
|
||||
},
|
||||
edge: notifier,
|
||||
})
|
||||
} else {
|
||||
logger.Warn("third-party verification push is not implemented by the RPC edge",
|
||||
zap.String("expected_hook", "rpc.Router.NotifyPeerBotVerification"))
|
||||
}
|
||||
go ratingapp.NewRecomputeWorker(ratingService, logger.Named("rating").Named("recompute"),
|
||||
cfg.RatingRecomputeInterval, cfg.RatingRecomputeBatch).Run(ctx)
|
||||
// Applicant notifications are delivered from a durable outbox, never inside the
|
||||
// decision transaction: @verifybot may be blocked and the panel must not wait on
|
||||
// a message send.
|
||||
go verificationapp.NewNotificationWorker(verificationService, logger.Named("verification").Named("notify"),
|
||||
cfg.VerificationNotifyInterval, cfg.VerificationNotifyBatch).Run(ctx)
|
||||
moderationActionOptions := []moderationapp.ActionExecutorOption{}
|
||||
if cfg.PublicLinkWebAddr != "" {
|
||||
moderationActionOptions = append(
|
||||
|
|
@ -1047,7 +1355,21 @@ func run(logger *zap.Logger) error {
|
|||
if _, err := botapi.Start(ctx, cfg.BotAPIAddr, botsService, usersService, router, router, logger.Named("botapi")); err != nil {
|
||||
return fmt.Errorf("start bot api: %w", err)
|
||||
}
|
||||
if _, err := adminapi.Start(ctx, adminapi.Config{Addr: cfg.AdminAPIAddr, Token: cfg.AdminAPIToken}, adminService, logger.Named("adminapi")); err != nil {
|
||||
// Scoped tokens carry a bounded permission set; the master token stays
|
||||
// unrestricted, so a deployment that configures none behaves exactly as before.
|
||||
adminScopedTokens := make([]adminapi.ScopedToken, 0, len(cfg.AdminScopedTokens))
|
||||
for _, item := range cfg.AdminScopedTokens {
|
||||
adminScopedTokens = append(adminScopedTokens, adminapi.ScopedToken{
|
||||
Name: item.Name,
|
||||
Token: item.Token,
|
||||
Permissions: item.Permissions,
|
||||
})
|
||||
}
|
||||
if _, err := adminapi.Start(ctx, adminapi.Config{
|
||||
Addr: cfg.AdminAPIAddr,
|
||||
Token: cfg.AdminAPIToken,
|
||||
ScopedTokens: adminScopedTokens,
|
||||
}, adminService, logger.Named("adminapi")); err != nil {
|
||||
return fmt.Errorf("start admin api: %w", err)
|
||||
}
|
||||
if _, err := web.Start(ctx, web.Config{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue