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

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

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

Reviewed-Head: 2796345775ea0f908fb7734601e5e1dee4b653b9
Original-Head: fa082b892fd5180c9c9bc53c81c21cf5d250a75b

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

View file

@ -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)
}

View 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)
}
}
}

View file

@ -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

View 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)
}
}
}

View 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)
}
}

View 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)
}
}

View 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{}
}

View file

@ -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)

View file

@ -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,
})
}

View file

@ -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)
}
}

View 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)
}

View 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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -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>

View file

@ -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"
},

View file

@ -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>
);
}

View file

@ -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()}`),

View file

@ -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);

View file

@ -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,

View file

@ -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>

View file

@ -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

View file

@ -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,]+/)

View file

@ -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>}

View 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>;
}

View 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>
);
}

View file

@ -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>

View 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}`;
}

View 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;
}

View file

@ -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}`;
}

View 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;
}

View file

@ -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 {

View file

@ -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} />;
}

View 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>;
}

View 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}`;
}

View 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>
);
}

View file

@ -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");

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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;

View file

@ -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{