Merge remote-tracking branch 'upstream/main' into merge-gramsrv-9106877

This commit is contained in:
onysd 2026-08-03 23:29:20 +03:00
commit ac6a50c5ff
697 changed files with 100880 additions and 8052 deletions

View file

@ -22,6 +22,8 @@ TELESRV_ADVERTISE_IP=127.0.0.1
# Which "data center" number this server presents itself as. There's only # Which "data center" number this server presents itself as. There's only
# ever one physical server, so this normally stays 2 -- no need to change it. # ever one physical server, so this normally stays 2 -- no need to change it.
TELESRV_DC=2 TELESRV_DC=2
# ISO 3166-1 alpha-2 code returned by help.getNearestDc. CN preselects +86.
TELESRV_DEFAULT_COUNTRY_CODE=CN
## Phone Login Codes -- How a login code gets to a phone number when someone signs in. ## Phone Login Codes -- How a login code gets to a phone number when someone signs in.
@ -117,6 +119,19 @@ TELESRV_ADMIN_SESSION_KEY=
TELESRV_ADMIN_API_ADDR= TELESRV_ADMIN_API_ADDR=
# Address the admin panel's own web UI listens on. # Address the admin panel's own web UI listens on.
TELESRV_ADMIN_UI_ADDR=127.0.0.1:2600 TELESRV_ADMIN_UI_ADDR=127.0.0.1:2600
# Permissions granted to an Admin UI session that logged in with
# TELESRV_ADMIN_UI_PASSWORD / _TOKEN. Comma-separated; "*" means every
# permission and is the default, so enabling RBAC never locks an operator out of
# a panel that worked before. Names are letters/digits/._:- and may end in
# "namespace.*" to grant a whole namespace.
TELESRV_ADMIN_UI_PERMISSIONS=*
# Additional Admin API bearer tokens with a bounded permission set each, so an
# integration gets exactly the rights it needs instead of the unrestricted
# TELESRV_ADMIN_API_TOKEN. Format: "name:token:perm1,perm2" entries separated by
# ';'. A token may not contain ':' or whitespace, names and tokens must be
# unique, and reusing TELESRV_ADMIN_API_TOKEN here is refused; any malformed
# entry fails startup rather than silently granting or dropping rights.
TELESRV_ADMIN_SCOPED_TOKENS=
## Bot API Gateway -- Optional HTTP gateway for bot libraries (e.g. python-telegram-bot). ## Bot API Gateway -- Optional HTTP gateway for bot libraries (e.g. python-telegram-bot).
@ -242,15 +257,12 @@ TELESRV_MTPROTO_RPC_TIMEOUT=30s
TELESRV_MTPROTO_RPC_GLOBAL_WORKERS=256 TELESRV_MTPROTO_RPC_GLOBAL_WORKERS=256
TELESRV_MTPROTO_RPC_GLOBAL_MAX_TASKS=8192 TELESRV_MTPROTO_RPC_GLOBAL_MAX_TASKS=8192
TELESRV_MTPROTO_RPC_GLOBAL_MAX_BYTES=536870912 TELESRV_MTPROTO_RPC_GLOBAL_MAX_BYTES=536870912
# In-memory cache of recent RPC results, used to safely retry a request the # Metadata-only rpc_result receipt budgets: global >= auth >= session. ACK deletes immediately;
# client resends. Keep the limits ordered global >= auth >= session. # 331s is only the no-ACK horizon. Payloads live solely in the logical-session outbound budget.
TELESRV_MTPROTO_RPC_RESULT_CACHE_MAX_ENTRIES=262144 TELESRV_MTPROTO_RPC_EXECUTION_MAX_ENTRIES=262144
TELESRV_MTPROTO_RPC_RESULT_CACHE_MAX_BYTES=67108864 TELESRV_MTPROTO_RPC_EXECUTION_AUTH_MAX_ENTRIES=32768
TELESRV_MTPROTO_RPC_RESULT_CACHE_AUTH_MAX_ENTRIES=32768 TELESRV_MTPROTO_RPC_EXECUTION_SESSION_MAX_ENTRIES=16384
TELESRV_MTPROTO_RPC_RESULT_CACHE_AUTH_MAX_BYTES=33554432 TELESRV_MTPROTO_RPC_EXECUTION_PENDING_PER_AUTH=2048
TELESRV_MTPROTO_RPC_RESULT_CACHE_SESSION_MAX_ENTRIES=16384
TELESRV_MTPROTO_RPC_RESULT_CACHE_SESSION_MAX_BYTES=16777216
TELESRV_MTPROTO_RPC_RESULT_PENDING_PER_AUTH=2048
# Process-wide in-flight transport wire + decrypted plaintext reservation. # Process-wide in-flight transport wire + decrypted plaintext reservation.
TELESRV_MTPROTO_INBOUND_FRAME_GLOBAL_MAX_BYTES=536870912 TELESRV_MTPROTO_INBOUND_FRAME_GLOBAL_MAX_BYTES=536870912
# Per-connection outbound mailboxes (normal/control) and process-wide resend pending bodies. # Per-connection outbound mailboxes (normal/control) and process-wide resend pending bodies.
@ -365,6 +377,93 @@ TELESRV_STARGIFT_RESELL_DELAY=0s
TELESRV_STARGIFT_CRAFT_DELAY=0s TELESRV_STARGIFT_CRAFT_DELAY=0s
TELESRV_STARGIFT_CRAFT_CHANCE_PERMILLE=250 TELESRV_STARGIFT_CRAFT_CHANCE_PERMILLE=250
# Local admin-only composite account rating. It is not projected into Telegram's
# userFull.stars_rating fields. Disabling it refuses local rating writes.
TELESRV_RATING_ENABLED=true
# A local rating increase is parked for this long before it becomes the visible
# admin level; a decrease always applies immediately. 0 applies every change at once.
TELESRV_RATING_PENDING_DELAY=24h
# Background recompute worker: the rating derives from signals owned by other
# subsystems, so freshness is a worker property rather than a write-path one.
TELESRV_RATING_RECOMPUTE_INTERVAL=15m
TELESRV_RATING_RECOMPUTE_BATCH=500
TELESRV_RATING_STALE_AFTER=6h
# Integer composite weights; the defaults below are exactly the shipped domain
# formula. Penalties are magnitudes that the formula subtracts, so every value is
# non-negative and a negative one fails startup.
TELESRV_RATING_WEIGHT_STARS_RECEIVED_PERMILLE=1000
TELESRV_RATING_WEIGHT_STARS_SPENT_PERMILLE=250
TELESRV_RATING_WEIGHT_MESSAGE_SENT=1
TELESRV_RATING_WEIGHT_ACCOUNT_AGE_DAY=2
TELESRV_RATING_WEIGHT_GIFT_RECEIVED=25
TELESRV_RATING_WEIGHT_MODERATION_CASE=150
TELESRV_RATING_WEIGHT_SCAM_PENALTY=5000
TELESRV_RATING_WEIGHT_FAKE_PENALTY=5000
# Upper bound of the activity component so activity alone cannot outweigh Stars
# and moderation; 0 leaves it uncapped.
TELESRV_RATING_ACTIVITY_CAP=5000
# Landing URL recorded on a minted collectible (NFT) username when the mint
# command carries no explicit URL. Empty derives
# <TELESRV_PUBLIC_BASE_URL>/nft/username/<username>. A template may carry the
# {username} placeholder; without it the name is appended as the last path
# segment. No external marketplace is contacted.
TELESRV_COLLECTIBLE_USERNAME_URL_TEMPLATE=
# Official platform verification: applications filed through the built-in
# @verifybot and decided in the admin panel. An approval flips the platform
# verified flag on the target peer and nothing else; it is not the third-party
# bot verification icon. Disabling refuses every verification use case, while
# peers already carrying the badge keep it.
TELESRV_VERIFICATION_ENABLED=true
# Plain user accounts as verification subjects. Off by default: the official
# process verifies a public presence (bot, public channel, public supergroup).
TELESRV_VERIFICATION_ALLOW_USER_TARGETS=false
# How long an applicant must wait before filing the same target again after a
# rejection, measured from the decision so a slow review never shortens it.
# 0 disables the cooldown; must be 0..8760h.
TELESRV_VERIFICATION_REJECT_COOLDOWN=720h
# Applications one applicant may create per window. Either value 0 disables the
# budget; a positive limit requires a positive window.
TELESRV_VERIFICATION_APPLY_RATE_LIMIT=3
TELESRV_VERIFICATION_APPLY_RATE_WINDOW=24h
# @verifybot dialog rate per applicant, independent of how many applications are
# actually created. Either value 0 disables it.
TELESRV_VERIFICATION_BOT_RATE_LIMIT=30
TELESRV_VERIFICATION_BOT_RATE_WINDOW=1m
# Applicant notification worker. A decision commits with its outbox row, never
# with a message send, so delivery is a separate retrying cycle over durable
# rows. Interval must be positive; batch must be 1..500.
TELESRV_VERIFICATION_NOTIFY_INTERVAL=15s
TELESRV_VERIFICATION_NOTIFY_BATCH=50
# Applications one applicant may keep open at once; 0 disables the cap, maximum
# is 50.
TELESRV_VERIFICATION_MAX_ACTIVE_PER_USER=3
# Third-party bot verification (core.telegram.org/api/bots/verification): a
# verifier bot marks peers with its OWN icon and description, which clients render
# before the name. This is NOT the platform checkmark above: the operator grants
# verifier status to a bot, and the two mechanisms never read each other's state.
# Disabling refuses every third-party mutation (grants, revocations, applications,
# icon catalogue edits) while the marks already granted keep rendering -- blanking
# one verifier's badges is what its per-verifier kill switch is for.
TELESRV_BOT_VERIFICATION_ENABLED=true
# Peers one verifier bot may mark. Verifier status is granted per deployment rather
# than earned per peer, so an unbounded verifier would be an unbounded badge
# printer. 0 disables the service bound and leaves only the storage bound, which is
# also the maximum accepted here (10000).
TELESRV_BOT_VERIFICATION_MAX_PER_VERIFIER=10000
# Verification applications one applicant may file per window, across all verifier
# bots. Either value 0 disables the budget; a positive limit requires a positive
# window. Looser than the official budget on purpose: a deployment can run several
# verifier companies, and filing with a second one is not a retry of the first.
TELESRV_BOT_VERIFICATION_REQUEST_RATE_LIMIT=5
TELESRV_BOT_VERIFICATION_REQUEST_RATE_WINDOW=24h
# Optional Premium feature-preview media export. Missing directory keeps the
# no-video fallback; an existing but incomplete/invalid directory fails startup.
TELESRV_PREMIUM_PROMO_SEED_DIR=data/premium-promo
# 1-to-1 call timing/limits. # 1-to-1 call timing/limits.
TELESRV_CALL_RING_TIMEOUT=90s TELESRV_CALL_RING_TIMEOUT=90s
TELESRV_CALL_TOMBSTONE_TTL=60s TELESRV_CALL_TOMBSTONE_TTL=60s

View file

@ -9,8 +9,8 @@
// go run ./cmd/bots/botcheck -token "<bot_id>:<secret>" # 仅登录自检 // go run ./cmd/bots/botcheck -token "<bot_id>:<secret>" # 仅登录自检
// go run ./cmd/bots/botcheck -token "<bot_id>:<secret>" -echo # 自检后持续 echo // go run ./cmd/bots/botcheck -token "<bot_id>:<secret>" -echo # 自检后持续 echo
// //
// 连接生产 telesrvobfuscated TCP靠 DCOption.TCPObfuscatedOnly=true // 以 obfuscated TCP 连接生产 telesrvserver 会逐连接自动区分 plain/obfuscated
// gotd dcs.Plain 据此自动走 MTProto TCP obfuscation。 // 此探针靠 DCOption.TCPObfuscatedOnly=true 让 gotd 客户端选择 MTProto TCP obfuscation。
package main package main
import ( import (
@ -42,7 +42,7 @@ import (
) )
// obfuscatedResolver 用标准无-secret MTProto TCP obfuscationobfuscated2连接 // obfuscatedResolver 用标准无-secret MTProto TCP obfuscationobfuscated2连接
// 匹配 telesrv 生产 server 的 transport.ObfuscatedListenerobfuscated2.Accept(conn, nil) // 匹配 telesrv 生产 server 自动检测后的 obfuscated2.Accept(conn, nil) 路径
// gotd 内置 dcs.Plain 的 obfuscated 路径走 MTProxy强制 secret不适用这里。 // gotd 内置 dcs.Plain 的 obfuscated 路径走 MTProxy强制 secret不适用这里。
type obfuscatedResolver struct { type obfuscatedResolver struct {
host string host string

View file

@ -64,8 +64,8 @@ import (
) )
// obfuscatedResolver 用标准无-secret MTProto TCP obfuscationobfuscated2连接 telesrv // obfuscatedResolver 用标准无-secret MTProto TCP obfuscationobfuscated2连接 telesrv
// 匹配生产 server 的 transport.ObfuscatedListener。gotd 内置 dcs.Plain 的 obfuscated 路径 // 匹配生产 server 自动检测后的 obfuscated2 路径。gotd 内置 dcs.Plain 的
// 走 MTProxy强制 secret不适用这里所以自定义一个 Resolver。 // TCPObfuscatedOnly 路径走 MTProxy强制 secret不适用这里所以自定义 Resolver。
type obfuscatedResolver struct { type obfuscatedResolver struct {
host string host string
port int port int

View file

@ -830,12 +830,13 @@ func downloadPartSize(expectedSize int64) int {
if expectedSize <= 0 || expectedSize >= max { if expectedSize <= 0 || expectedSize >= max {
return int(max) return int(max)
} }
// Choose a valid 4 KiB-aligned limit strictly larger than the file whenever // Non-precise upload.getFile limits must use the client-compatible chunk
// possible, so downloader.Stream recognizes the first short chunk as final // ladder (4, 8, ..., 512 KiB), whose values also divide a 1 MiB window.
// without an extra EOF probe. // Merely rounding to an arbitrary 4 KiB multiple (for example 48 KiB)
partSize := ((expectedSize + 1 + unit - 1) / unit) * unit // is rejected with LIMIT_INVALID by some official file DCs.
if partSize > max { partSize := unit
partSize = max for partSize <= expectedSize && partSize < max {
partSize *= 2
} }
return int(partSize) return int(partSize)
} }

View file

@ -69,6 +69,7 @@ func TestDownloadPartSize(t *testing.T) {
{size: 1, want: 4 << 10}, {size: 1, want: 4 << 10},
{size: (4 << 10) - 1, want: 4 << 10}, {size: (4 << 10) - 1, want: 4 << 10},
{size: 4 << 10, want: 8 << 10}, {size: 4 << 10, want: 8 << 10},
{size: 48_632, want: 64 << 10},
{size: (512 << 10) - 1, want: 512 << 10}, {size: (512 << 10) - 1, want: 512 << 10},
{size: 512 << 10, want: 512 << 10}, {size: 512 << 10, want: 512 << 10},
{size: 1 << 20, want: 512 << 10}, {size: 1 << 20, want: 512 << 10},
@ -80,6 +81,22 @@ func TestDownloadPartSize(t *testing.T) {
} }
} }
func TestDownloadPartSizeUsesNonPreciseChunkLadder(t *testing.T) {
const oneMiB = 1 << 20
for size := int64(1); size < 512<<10; size += 997 {
partSize := downloadPartSize(size)
if partSize < 4<<10 || partSize > 512<<10 || partSize%(4<<10) != 0 {
t.Fatalf("downloadPartSize(%d) = %d is outside the valid 4 KiB-aligned range", size, partSize)
}
if oneMiB%partSize != 0 {
t.Fatalf("downloadPartSize(%d) = %d does not divide a 1 MiB request window", size, partSize)
}
if int64(partSize) <= size {
t.Fatalf("downloadPartSize(%d) = %d does not cover the known-size single chunk", size, partSize)
}
}
}
func TestParseAllowedMissingThumbs(t *testing.T) { func TestParseAllowedMissingThumbs(t *testing.T) {
allowed, err := parseAllowedMissingThumbs("5417911440709285239:photo:m,42:video:v") allowed, err := parseAllowedMissingThumbs("5417911440709285239:photo:m,42:video:v")
if err != nil { if err != nil {

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 Password string
Token string Token string
SessionKey []byte 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 配置文件与环境变量, // loadConfig 通过 internal/config.Load() 加载 .env 配置文件与环境变量,
@ -105,6 +110,7 @@ func loadConfig() (uiConfig, error) {
Password: appCfg.AdminUIPassword, Password: appCfg.AdminUIPassword,
Token: appCfg.AdminUIToken, Token: appCfg.AdminUIToken,
SessionKey: sum[:], SessionKey: sum[:],
Permissions: appCfg.AdminUIPermissions,
}, nil }, 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 { func (s *server) routes() http.Handler {
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("POST /api/login", s.handleAPILogin) 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/session", s.requireAuthAPI(http.HandlerFunc(s.handleSession)))
mux.Handle("GET /api/accounts", s.requireAuthAPI(http.HandlerFunc(s.handleAccountsAPI))) mux.Handle("GET /api/accounts", s.requireAuthAPI(http.HandlerFunc(s.handleAccountsAPI)))
mux.Handle("GET /api/accounts/stats", s.requireAuthAPI(http.HandlerFunc(s.handleAccountsStatsAPI))) mux.Handle("GET /api/accounts/stats", s.requireAuthAPI(http.HandlerFunc(s.handleAccountsStatsAPI)))
@ -71,6 +74,16 @@ 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}/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", 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/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)))
mux.Handle("POST /api/moderation/cases/{id}/claim", s.requireAuthAPI(http.HandlerFunc(s.handleClaimModerationCaseAPI)))
mux.Handle("POST /api/moderation/cases/{id}/decide", s.requireAuthAPI(http.HandlerFunc(s.handleDecideModerationCaseAPI)))
mux.Handle("POST /api/moderation/cases/{id}/appeals/{appeal_id}/review", s.requireAuthAPI(http.HandlerFunc(s.handleReviewModerationAppealAPI)))
mux.Handle("POST /api/actions/set-frozen", s.requireAuthAPI(http.HandlerFunc(s.handleSetAccountFrozenAPI))) mux.Handle("POST /api/actions/set-frozen", s.requireAuthAPI(http.HandlerFunc(s.handleSetAccountFrozenAPI)))
mux.Handle("POST /api/actions/grant-premium", s.requireAuthAPI(http.HandlerFunc(s.handleGrantPremiumAPI))) mux.Handle("POST /api/actions/grant-premium", s.requireAuthAPI(http.HandlerFunc(s.handleGrantPremiumAPI)))
mux.Handle("POST /api/actions/grant-stars", s.requireAuthAPI(http.HandlerFunc(s.handleGrantStarsAPI))) mux.Handle("POST /api/actions/grant-stars", s.requireAuthAPI(http.HandlerFunc(s.handleGrantStarsAPI)))
@ -110,6 +123,43 @@ func (s *server) routes() http.Handler {
mux.Handle("POST /api/actions/add-sticker-to-set", s.requireAuthAPI(http.HandlerFunc(s.handleAddStickerToSetAPI))) mux.Handle("POST /api/actions/add-sticker-to-set", s.requireAuthAPI(http.HandlerFunc(s.handleAddStickerToSetAPI)))
mux.Handle("POST /api/actions/remove-sticker-from-set", s.requireAuthAPI(http.HandlerFunc(s.handleRemoveStickerFromSetAPI))) mux.Handle("POST /api/actions/remove-sticker-from-set", s.requireAuthAPI(http.HandlerFunc(s.handleRemoveStickerFromSetAPI)))
mux.Handle("POST /api/actions/give-gift", s.requireAuthAPI(http.HandlerFunc(s.handleGiveGiftAPI))) 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) { mux.HandleFunc("/api/", func(w http.ResponseWriter, _ *http.Request) {
writeAPIError(w, http.StatusNotFound, "api route not found") writeAPIError(w, http.StatusNotFound, "api route not found")
}) })
@ -119,23 +169,6 @@ func (s *server) routes() http.Handler {
type actorKey struct{} 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 { func actorFromContext(ctx context.Context) string {
if actor, ok := ctx.Value(actorKey{}).(string); ok && actor != "" { if actor, ok := ctx.Value(actorKey{}).(string); ok && actor != "" {
return actor return actor
@ -160,7 +193,18 @@ type loginRequest struct {
Secret string `json:"secret"` 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) { 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 var req loginRequest
if err := decodeJSON(r, &req); err != nil { if err := decodeJSON(r, &req); err != nil {
writeAPIError(w, http.StatusBadRequest, err.Error()) writeAPIError(w, http.StatusBadRequest, err.Error())
@ -170,10 +214,18 @@ func (s *server) handleAPILogin(w http.ResponseWriter, r *http.Request) {
writeAPIError(w, http.StatusUnauthorized, "invalid credential") writeAPIError(w, http.StatusUnauthorized, "invalid credential")
return 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{ value, err := signSession(s.cfg.SessionKey, sessionClaims{
Actor: "admin", Actor: "admin",
Exp: time.Now().Add(12 * time.Hour).Unix(), Exp: time.Now().Add(sessionTTL).Unix(),
Nonce: newCommandID("sess"), Nonce: newCommandID("sess"),
Permissions: permissions.List(),
CSRF: csrfToken,
}) })
if err != nil { if err != nil {
writeAPIError(w, http.StatusInternalServerError, err.Error()) writeAPIError(w, http.StatusInternalServerError, err.Error())
@ -183,11 +235,16 @@ func (s *server) handleAPILogin(w http.ResponseWriter, r *http.Request) {
Name: sessionCookieName, Name: sessionCookieName,
Value: value, Value: value,
Path: "/", Path: "/",
MaxAge: int((12 * time.Hour).Seconds()), MaxAge: int(sessionTTL.Seconds()),
HttpOnly: true, HttpOnly: true,
SameSite: http.SameSiteLaxMode, 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 { func (s *server) validSecret(secret string) bool {
@ -205,8 +262,14 @@ func (s *server) handleAPILogout(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"ok": true}) 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) { 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) { func (s *server) handleStarGiftsAPI(w http.ResponseWriter, r *http.Request) {
@ -370,6 +433,20 @@ func (s *server) handleStarGiftCollectibleAnimationAPI(w http.ResponseWriter, r
} }
func (s *server) proxyAdminJSON(w http.ResponseWriter, r *http.Request, apiPath string, maxBytes int64) { func (s *server) proxyAdminJSON(w http.ResponseWriter, r *http.Request, apiPath string, maxBytes int64) {
s.proxyAdminJSONWithCache(w, r, apiPath, maxBytes, "private, max-age=30")
}
func (s *server) proxyAdminJSONNoStore(w http.ResponseWriter, r *http.Request, apiPath string, maxBytes int64) {
s.proxyAdminJSONWithCache(w, r, apiPath, maxBytes, "no-store")
}
func (s *server) proxyAdminJSONWithCache(
w http.ResponseWriter,
r *http.Request,
apiPath string,
maxBytes int64,
cacheControl string,
) {
req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, s.cfg.AdminAPIURL+apiPath, nil) req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, s.cfg.AdminAPIURL+apiPath, nil)
if err != nil { if err != nil {
writeAPIError(w, http.StatusInternalServerError, err.Error()) writeAPIError(w, http.StatusInternalServerError, err.Error())
@ -392,11 +469,115 @@ func (s *server) proxyAdminJSON(w http.ResponseWriter, r *http.Request, apiPath
return return
} }
w.Header().Set("Content-Type", "application/json; charset=utf-8") w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Cache-Control", "private, max-age=30") w.Header().Set("Cache-Control", cacheControl)
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
_, _ = w.Write(raw) _, _ = w.Write(raw)
} }
func (s *server) handleModerationCasesAPI(w http.ResponseWriter, r *http.Request) {
apiPath := "/v1/moderation/cases"
if r.URL.RawQuery != "" {
apiPath += "?" + r.URL.RawQuery
}
s.proxyAdminJSONNoStore(w, r, apiPath, 4<<20)
}
func (s *server) handleModerationCaseAPI(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || id <= 0 {
writeAPIError(w, http.StatusBadRequest, "invalid moderation case id")
return
}
s.proxyAdminJSONNoStore(w, r, fmt.Sprintf("/v1/moderation/cases/%d", id), 4<<20)
}
func (s *server) handleModerationReportAPI(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || id <= 0 {
writeAPIError(w, http.StatusBadRequest, "invalid moderation report id")
return
}
s.proxyAdminJSONNoStore(w, r, fmt.Sprintf("/v1/moderation/reports/%d", id), 4<<20)
}
func (s *server) handleClaimModerationCaseAPI(w http.ResponseWriter, r *http.Request) {
s.proxyModerationWrite(w, r, "claim", false)
}
func (s *server) handleDecideModerationCaseAPI(w http.ResponseWriter, r *http.Request) {
s.proxyModerationWrite(w, r, "decide", true)
}
func (s *server) handleReviewModerationAppealAPI(w http.ResponseWriter, r *http.Request) {
s.proxyModerationWrite(w, r, "appeals/"+r.PathValue("appeal_id")+"/review", true)
}
func (s *server) proxyModerationWrite(w http.ResponseWriter, r *http.Request, suffix string, needsCommand bool) {
caseID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || caseID <= 0 {
writeAPIError(w, http.StatusBadRequest, "invalid moderation case id")
return
}
if strings.HasPrefix(suffix, "appeals/") {
appealID, err := strconv.ParseInt(r.PathValue("appeal_id"), 10, 64)
if err != nil || appealID <= 0 {
writeAPIError(w, http.StatusBadRequest, "invalid moderation appeal id")
return
}
}
defer r.Body.Close()
var payload map[string]any
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20))
if err := decoder.Decode(&payload); err != nil {
writeAPIError(w, http.StatusBadRequest, "invalid json")
return
}
payload["actor"] = actorFromContext(r.Context())
if needsCommand {
commandID, _ := payload["command_id"].(string)
if strings.TrimSpace(commandID) == "" {
payload["command_id"] = newCommandID("moderation")
}
}
raw, err := json.Marshal(payload)
if err != nil {
writeAPIError(w, http.StatusBadRequest, err.Error())
return
}
s.relayAdminJSON(
w, r, http.MethodPost,
fmt.Sprintf("/v1/moderation/cases/%d/%s", caseID, suffix),
raw, 4<<20,
)
}
func (s *server) relayAdminJSON(w http.ResponseWriter, r *http.Request, method, apiPath string, body []byte, maxBytes int64) {
req, err := http.NewRequestWithContext(
r.Context(), method, s.cfg.AdminAPIURL+apiPath, bytes.NewReader(body),
)
if err != nil {
writeAPIError(w, http.StatusInternalServerError, err.Error())
return
}
req.Header.Set("Authorization", "Bearer "+s.cfg.AdminAPIToken)
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
writeAPIError(w, http.StatusBadGateway, err.Error())
return
}
defer resp.Body.Close()
raw, err := io.ReadAll(io.LimitReader(resp.Body, maxBytes+1))
if err != nil || int64(len(raw)) > maxBytes {
writeAPIError(w, http.StatusBadGateway, "invalid admin api response")
return
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(resp.StatusCode)
_, _ = w.Write(raw)
}
func (s *server) handleAccountsAPI(w http.ResponseWriter, r *http.Request) { func (s *server) handleAccountsAPI(w http.ResponseWriter, r *http.Request) {
if s.read == nil { if s.read == nil {
writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured") writeAPIError(w, http.StatusServiceUnavailable, "read store is not configured")
@ -1825,6 +2006,367 @@ func (s *server) handleGiveGiftAPI(w http.ResponseWriter, r *http.Request) {
writeCommandResultAPI(w, result, err) 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 { func (s *server) commandMetaFromAPI(r *http.Request, commandID, reason string, confirm bool, prefix string) admin.CommandMeta {
commandID = strings.TrimSpace(commandID) commandID = strings.TrimSpace(commandID)
if confirm && strings.HasPrefix(commandID, "dry-") { if confirm && strings.HasPrefix(commandID, "dry-") {
@ -1876,6 +2418,42 @@ func (s *server) callAdminAPI(ctx context.Context, apiPath string, payload any)
return result, nil 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) { func (s *server) callAdminMultipart(ctx context.Context, apiPath string, metadata any, fileName string, data []byte) (admin.CommandResult, error) {
var body bytes.Buffer var body bytes.Buffer
writer := multipart.NewWriter(&body) writer := multipart.NewWriter(&body)

View file

@ -2,6 +2,7 @@ package main
import ( import (
"crypto/hmac" "crypto/hmac"
"crypto/rand"
"crypto/sha256" "crypto/sha256"
"crypto/subtle" "crypto/subtle"
"encoding/base64" "encoding/base64"
@ -13,10 +14,29 @@ import (
const sessionCookieName = "telesrv_admin_session" 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 { type sessionClaims struct {
Actor string `json:"actor"` Actor string `json:"actor"`
Exp int64 `json:"exp"` Exp int64 `json:"exp"`
Nonce string `json:"nonce"` 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) { 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 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) { func clearSessionCookie(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{ http.SetCookie(w, &http.Cookie{
Name: sessionCookieName, Name: sessionCookieName,
@ -65,4 +107,12 @@ func clearSessionCookie(w http.ResponseWriter) {
HttpOnly: true, HttpOnly: true,
SameSite: http.SameSiteLaxMode, SameSite: http.SameSiteLaxMode,
}) })
http.SetCookie(w, &http.Cookie{
Name: csrfCookieName,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: false,
SameSite: http.SameSiteLaxMode,
})
} }

View file

@ -83,6 +83,67 @@ func TestSetAccountFrozenBFFForwardsClientVisibleState(t *testing.T) {
} }
} }
func TestModerationReadAPIDisablesBrowserCaching(t *testing.T) {
tests := []struct {
name string
requestPath string
upstreamPath string
invoke func(*server, http.ResponseWriter, *http.Request)
}{
{
name: "case list",
requestPath: "/api/moderation/cases?status=open",
upstreamPath: "/v1/moderation/cases?status=open",
invoke: (*server).handleModerationCasesAPI,
},
{
name: "case detail",
requestPath: "/api/moderation/cases/7",
upstreamPath: "/v1/moderation/cases/7",
invoke: func(s *server, w http.ResponseWriter, r *http.Request) {
r.SetPathValue("id", "7")
s.handleModerationCaseAPI(w, r)
},
},
{
name: "report detail",
requestPath: "/api/moderation/reports/9",
upstreamPath: "/v1/moderation/reports/9",
invoke: func(s *server, w http.ResponseWriter, r *http.Request) {
r.SetPathValue("id", "9")
s.handleModerationReportAPI(w, r)
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.URL.RequestURI(); got != test.upstreamPath {
t.Fatalf("upstream request URI = %q, want %q", got, test.upstreamPath)
}
if got := r.Header.Get("Authorization"); got != "Bearer secret" {
t.Fatalf("upstream authorization = %q", got)
}
_, _ = w.Write([]byte(`{}`))
}))
defer upstream.Close()
srv := &server{cfg: uiConfig{AdminAPIURL: upstream.URL, AdminAPIToken: "secret"}}
req := httptest.NewRequest(http.MethodGet, test.requestPath, nil)
rec := httptest.NewRecorder()
test.invoke(srv, rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
if got := rec.Header().Get("Cache-Control"); got != "no-store" {
t.Fatalf("Cache-Control = %q, want no-store", got)
}
})
}
}
func TestStarGiftRowJSONPreservesInt64AsDecimalStrings(t *testing.T) { func TestStarGiftRowJSONPreservesInt64AsDecimalStrings(t *testing.T) {
const maxInt64 = int64(9223372036854775807) const maxInt64 = int64(9223372036854775807)
raw, err := json.Marshal(StarGiftRow{ raw, err := json.Marshal(StarGiftRow{
@ -168,3 +229,214 @@ func TestSetStarGiftEnabledBFFForwardsExactInt64(t *testing.T) {
t.Fatalf("forwarded gift request = %+v", got) 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

@ -23,8 +23,8 @@
})(); })();
</script> </script>
<script type="module" crossorigin src="/assets/index-kK52bvQu.js"></script> <script type="module" crossorigin src="/assets/index-65rEwtSD.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DjPaQXsn.css"> <link rel="stylesheet" crossorigin href="/assets/index-CKoIcj6p.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View file

@ -758,9 +758,9 @@
} }
}, },
"node_modules/nanoid": { "node_modules/nanoid": {
"version": "3.3.15", "version": "3.3.16",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
"integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@ -797,9 +797,9 @@
} }
}, },
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.5.16", "version": "8.5.23",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz",
"integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@ -817,7 +817,7 @@
], ],
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"nanoid": "^3.3.12", "nanoid": "^3.3.16",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
"source-map-js": "^1.2.1" "source-map-js": "^1.2.1"
}, },

View file

@ -1,12 +1,16 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { api, APIError } from "./api"; import { api } from "./api";
import { BootScreen, Shell } from "./components/Layout"; import { BootScreen, Shell } from "./components/Layout";
import { LoginPage } from "./pages/LoginPage"; import { LoginPage } from "./pages/LoginPage";
import { PermissionsProvider } from "./permissions";
import { Routes } from "./pages/Routes"; import { Routes } from "./pages/Routes";
import { currentRoute, type RouteState } from "./routing"; import { currentRoute, type RouteState } from "./routing";
import type { AdminSession } from "./types";
export function App() { 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()); const [route, setRoute] = useState<RouteState>(() => currentRoute());
useEffect(() => { useEffect(() => {
@ -17,14 +21,10 @@ export function App() {
useEffect(() => { useEffect(() => {
api.session() api.session()
.then((session) => setActor(session.actor)) .then((next) => setSession(next))
.catch((error) => { // A 401 and an unreachable backend both end at the login screen; there is
if (error instanceof APIError && error.status === 401) { // nothing the panel can render without a session.
setActor(null); .catch(() => setSession(null));
return;
}
setActor(null);
});
}, []); }, []);
const navigate = (href: string) => { const navigate = (href: string) => {
@ -32,17 +32,19 @@ export function App() {
setRoute(currentRoute()); setRoute(currentRoute());
}; };
if (actor === undefined) { if (session === undefined) {
return <BootScreen />; return <BootScreen />;
} }
if (actor === null) { if (session === null) {
return <LoginPage onLogin={setActor} />; return <LoginPage onLogin={setSession} />;
} }
return ( return (
<Shell actor={actor} route={route} navigate={navigate} onLogout={() => setActor(null)}> <PermissionsProvider permissions={session.permissions ?? []}>
<Routes route={route} navigate={navigate} /> <Shell actor={session.actor} route={route} navigate={navigate} onLogout={() => setSession(null)}>
</Shell> <Routes route={route} navigate={navigate} />
</Shell>
</PermissionsProvider>
); );
} }

View file

@ -1,22 +1,40 @@
import type { import type {
AccountDetail, AccountDetail,
AccountListResponse, AccountListResponse,
AccountRatingDetail,
AccountRatingListResponse,
AccountStatsResponse, AccountStatsResponse,
AdminLoginResult,
AdminSession,
BotDetail, BotDetail,
BotListResponse, BotListResponse,
BotVerificationCountsResponse,
BotVerifierListResponse,
ChannelDetail, ChannelDetail,
CustomVerificationListResponse,
CustomVerificationRequestDetail,
CustomVerificationRequestListResponse,
VerificationIconListResponse,
EmojiListResponse, EmojiListResponse,
ChannelListResponse, ChannelListResponse,
CollectibleUsernameDetail,
CollectibleUsernameListResponse,
CommandResult, CommandResult,
GroupMessageDetail, GroupMessageDetail,
GroupMessageListResponse, GroupMessageListResponse,
MessageDetail, MessageDetail,
MessageListResponse, MessageListResponse,
DefaultGiftListResponse, DefaultGiftListResponse,
ModerationCaseDetail,
ModerationCaseRow,
ModerationReport,
OfficialStarGiftListResponse, OfficialStarGiftListResponse,
StarGiftCollectiblePreview, StarGiftCollectiblePreview,
StarGiftListResponse, StarGiftListResponse,
StickerSetListResponse StickerSetListResponse,
VerificationApplicationDetail,
VerificationApplicationListResponse,
VerificationCountsResponse
} from "./types"; } from "./types";
export class APIError extends Error { export class APIError extends Error {
@ -28,12 +46,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> { async function request<T>(url: string, init: RequestInit = {}): Promise<T> {
const isForm = typeof FormData !== "undefined" && init.body instanceof FormData; 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, { const response = await fetch(url, {
credentials: "same-origin", credentials: "same-origin",
headers: isForm ? init.headers : { "Content-Type": "application/json", ...(init.headers ?? {}) }, ...init,
...init headers
}); });
const text = await response.text(); const text = await response.text();
const data = text ? JSON.parse(text) : null; const data = text ? JSON.parse(text) : null;
@ -52,11 +139,16 @@ export function errorMessage(error: unknown): string {
} }
export const api = { export const api = {
session: () => request<{ actor: string }>("/api/session"), session: () => request<AdminSession>("/api/session"),
login: (secret: string) => request<{ actor: string }>("/api/login", { login: async (secret: string) => {
method: "POST", const result = await request<AdminLoginResult>("/api/login", {
body: JSON.stringify({ secret }) 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: "{}" }), logout: () => request<{ ok: boolean }>("/api/logout", { method: "POST", body: "{}" }),
accounts: (params: URLSearchParams) => request<AccountListResponse>(`/api/accounts?${params.toString()}`), accounts: (params: URLSearchParams) => request<AccountListResponse>(`/api/accounts?${params.toString()}`),
accountStats: () => request<AccountStatsResponse>("/api/accounts/stats"), accountStats: () => request<AccountStatsResponse>("/api/accounts/stats"),
@ -65,6 +157,36 @@ export const api = {
channel: (id: number) => request<ChannelDetail>(`/api/channels/${id}`), channel: (id: number) => request<ChannelDetail>(`/api/channels/${id}`),
bots: (params: URLSearchParams) => request<BotListResponse>(`/api/bots?${params.toString()}`), bots: (params: URLSearchParams) => request<BotListResponse>(`/api/bots?${params.toString()}`),
bot: (id: number) => request<BotDetail>(`/api/bots/${id}`), 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()}`), emoji: (params: URLSearchParams) => request<EmojiListResponse>(`/api/emoji?${params.toString()}`),
emojiAnimation: (documentID: string) => request<Record<string, unknown>>(`/api/emoji/${encodeURIComponent(documentID)}/animation`), emojiAnimation: (documentID: string) => request<Record<string, unknown>>(`/api/emoji/${encodeURIComponent(documentID)}/animation`),
messages: (params: URLSearchParams) => request<MessageListResponse>(`/api/messages?${params.toString()}`), messages: (params: URLSearchParams) => request<MessageListResponse>(`/api/messages?${params.toString()}`),
@ -77,6 +199,27 @@ export const api = {
const params = new URLSearchParams({ channel_id: String(channelID), msg_id: String(msgID) }); const params = new URLSearchParams({ channel_id: String(channelID), msg_id: String(msgID) });
return request<GroupMessageDetail>(`/api/messages/groups/detail?${params.toString()}`); return request<GroupMessageDetail>(`/api/messages/groups/detail?${params.toString()}`);
}, },
moderationCases: (params: URLSearchParams) =>
request<{ cases: ModerationCaseRow[] }>(`/api/moderation/cases?${params.toString()}`),
moderationCase: (id: number) =>
request<ModerationCaseDetail>(`/api/moderation/cases/${id}`),
moderationReport: (id: number) =>
request<ModerationReport>(`/api/moderation/reports/${id}`),
claimModerationCase: (id: number, expectedVersion: number) =>
request<ModerationCaseRow>(`/api/moderation/cases/${id}/claim`, {
method: "POST",
body: JSON.stringify({ expected_version: expectedVersion })
}),
decideModerationCase: (id: number, payload: Record<string, unknown>) =>
request<{ created: boolean; case: ModerationCaseDetail }>(`/api/moderation/cases/${id}/decide`, {
method: "POST",
body: JSON.stringify(payload)
}),
reviewModerationAppeal: (caseID: number, appealID: number, payload: Record<string, unknown>) =>
request<{ created: boolean; case: ModerationCaseDetail }>(`/api/moderation/cases/${caseID}/appeals/${appealID}/review`, {
method: "POST",
body: JSON.stringify(payload)
}),
gifts: () => request<StarGiftListResponse>("/api/gifts"), gifts: () => request<StarGiftListResponse>("/api/gifts"),
stickerSets: (kind: string) => request<StickerSetListResponse>(`/api/stickers?kind=${encodeURIComponent(kind)}`), stickerSets: (kind: string) => request<StickerSetListResponse>(`/api/stickers?kind=${encodeURIComponent(kind)}`),
stickerSetDocuments: (setID: string) => request<{ document_ids: string[] }>(`/api/stickers/${encodeURIComponent(setID)}/documents`), stickerSetDocuments: (setID: string) => request<{ document_ids: string[] }>(`/api/stickers/${encodeURIComponent(setID)}/documents`),

View file

@ -3,7 +3,6 @@ import type { ReactNode } from "react";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { api, errorMessage } from "../api"; import { api, errorMessage } from "../api";
import { useI18n } from "../i18n";
import type { CommandResult } from "../types"; import type { CommandResult } from "../types";
import { Alert, JsonBlock } from "./ui"; import { Alert, JsonBlock } from "./ui";
@ -16,7 +15,9 @@ export function ActionButton({
icon, icon,
compact = false, compact = false,
tone = "danger", tone = "danger",
onDone disabled = false,
onDone,
onError
}: { }: {
label: string; label: string;
path: string; path: string;
@ -24,9 +25,16 @@ export function ActionButton({
icon?: ReactNode; icon?: ReactNode;
compact?: boolean; compact?: boolean;
tone?: ActionTone; 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; 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); const [open, setOpen] = useState(false);
const [reason, setReason] = useState(""); const [reason, setReason] = useState("");
const [result, setResult] = useState<CommandResult | null>(null); const [result, setResult] = useState<CommandResult | null>(null);
@ -41,7 +49,7 @@ export function ActionButton({
async function run(confirm: boolean) { async function run(confirm: boolean) {
if (!reason.trim()) { if (!reason.trim()) {
setError(t("action.reasonRequired")); setError("Please enter an operation reason");
return; return;
} }
setBusy(true); setBusy(true);
@ -54,7 +62,7 @@ export function ActionButton({
onDone?.(); onDone?.();
} }
} catch (err) { } catch (err) {
setError(errorMessage(err)); setError(onError?.(err) || errorMessage(err));
} finally { } finally {
setBusy(false); setBusy(false);
} }
@ -75,6 +83,7 @@ export function ActionButton({
<button <button
className={triggerClass} className={triggerClass}
type="button" type="button"
disabled={disabled}
onClick={() => { onClick={() => {
reset(); reset();
setOpen(true); setOpen(true);
@ -88,29 +97,29 @@ export function ActionButton({
<section className="modal command-modal" role="dialog" aria-modal="true" aria-label={label}> <section className="modal command-modal" role="dialog" aria-modal="true" aria-label={label}>
<div className="modal-head"> <div className="modal-head">
<div> <div>
<div className="eyebrow">{t("action.flow")}</div> <div className="eyebrow">{"Action Flow"}</div>
<h2>{label}</h2> <h2>{label}</h2>
</div> </div>
<button className="icon-btn" type="button" onClick={() => setOpen(false)} aria-label={t("action.close")}><X size={15} /></button> <button className="icon-btn" type="button" onClick={() => setOpen(false)} aria-label={"Close"}><X size={15} /></button>
</div> </div>
<div className="command-body"> <div className="command-body">
<div className="command-steps"> <div className="command-steps">
<div className={`command-step ${reason.trim() ? "done" : "active"}`}> <div className={`command-step ${reason.trim() ? "done" : "active"}`}>
<span>1</span><strong>{t("action.stepReason")}</strong> <span>1</span><strong>{"Enter reason"}</strong>
</div> </div>
<div className={`command-step ${result?.dry_run ? "done" : reason.trim() ? "active" : ""}`}> <div className={`command-step ${result?.dry_run ? "done" : reason.trim() ? "active" : ""}`}>
<span>2</span><strong>{t("action.stepDryRun")}</strong> <span>2</span><strong>{"Dry-run check"}</strong>
</div> </div>
<div className={`command-step ${result && !result.dry_run && !result.error ? "done" : canConfirm ? "active" : ""}`}> <div className={`command-step ${result && !result.dry_run && !result.error ? "done" : canConfirm ? "active" : ""}`}>
<span>3</span><strong>{t("action.stepConfirm")}</strong> <span>3</span><strong>{"Confirm execution"}</strong>
</div> </div>
</div> </div>
<label className="form-field"> <label className="form-field">
<span>{t("action.reason")}</span> <span>{"Operation reason"}</span>
<textarea value={reason} onChange={(event) => setReason(event.target.value)} rows={3} placeholder={t("action.reasonPlaceholder")} /> <textarea value={reason} onChange={(event) => setReason(event.target.value)} rows={3} placeholder={"Describe why this operation is being performed"} />
</label> </label>
<div className="command-preview"> <div className="command-preview">
<div className="preview-head"><FileJson size={14} /> {t("action.requestPreview")}</div> <div className="preview-head"><FileJson size={14} /> {"Request preview"}</div>
<JsonBlock value={JSON.stringify(previewPayload, null, 2)} /> <JsonBlock value={JSON.stringify(previewPayload, null, 2)} />
</div> </div>
{error && <Alert>{error}</Alert>} {error && <Alert>{error}</Alert>}
@ -118,25 +127,25 @@ export function ActionButton({
<div className="result-box"> <div className="result-box">
<div className="result-title"> <div className="result-title">
{result.error ? <CircleAlert size={16} /> : <CheckCircle2 size={16} />} {result.error ? <CircleAlert size={16} /> : <CheckCircle2 size={16} />}
<strong>{result.message || result.error || t("action.result")}</strong> <strong>{result.message || result.error || "Action result"}</strong>
</div> </div>
<div className="result-line"><span>{t("action.commandID")}</span><strong>{result.command_id}</strong></div> <div className="result-line"><span>{"Command ID"}</span><strong>{result.command_id}</strong></div>
<div className="result-line"><span>{t("action.status")}</span><strong>{result.status}</strong></div> <div className="result-line"><span>{"Status"}</span><strong>{result.status}</strong></div>
<div className="result-line"><span>{t("action.dryRun")}</span><strong>{result.dry_run ? t("common.yes") : t("common.no")}</strong></div> <div className="result-line"><span>{"Dry-run"}</span><strong>{result.dry_run ? "Yes" : "No"}</strong></div>
<div className="result-message">{result.message || result.error}</div> <div className="result-message">{result.message || result.error}</div>
{result.details && <JsonBlock value={JSON.stringify(result.details, null, 2)} />} {result.details && <JsonBlock value={JSON.stringify(result.details, null, 2)} />}
</div> </div>
)} )}
</div> </div>
<div className="modal-actions"> <div className="modal-actions">
<button className="btn" type="button" onClick={() => setOpen(false)}>{t("common.close")}</button> <button className="btn" type="button" onClick={() => setOpen(false)}>{"Close"}</button>
<button className="btn icon-text" type="button" onClick={() => run(false)} disabled={busy}> <button className="btn icon-text" type="button" onClick={() => run(false)} disabled={busy}>
{busy ? <Loader2 size={15} className="spin" /> : <Play size={15} />} {busy ? <Loader2 size={15} className="spin" /> : <Play size={15} />}
{result ? t("action.runAgain") : t("action.runDry")} {result ? "Run dry-run again" : "Run dry-run first"}
</button> </button>
<button className="btn danger icon-text" type="button" onClick={() => run(true)} disabled={busy || !canConfirm}> <button className="btn danger icon-text" type="button" onClick={() => run(true)} disabled={busy || !canConfirm}>
<CheckCircle2 size={15} /> <CheckCircle2 size={15} />
{t("action.confirm")} {"Confirm execution"}
</button> </button>
</div> </div>
</section> </section>

View file

@ -1,13 +1,11 @@
import { Cable, LogOut, ShieldCheck } from "lucide-react"; import { Cable, LogOut, ShieldCheck } from "lucide-react";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { formatDate } from "../lib/format"; import { formatDate } from "../lib/format";
import { useI18n } from "../i18n";
import type { AuthorizationRow } from "../types"; import type { AuthorizationRow } from "../types";
import { ActionButton } from "./ActionButton"; import { ActionButton } from "./ActionButton";
import { EmptyRow } from "./ui"; import { EmptyRow } from "./ui";
export function AuthorizationTable({ rows, userID, onDone }: { rows: AuthorizationRow[]; userID: number; onDone: () => void }) { export function AuthorizationTable({ rows, userID, onDone }: { rows: AuthorizationRow[]; userID: number; onDone: () => void }) {
const { t } = useI18n();
const [removedHashes, setRemovedHashes] = useState<Set<number>>(() => new Set()); const [removedHashes, setRemovedHashes] = useState<Set<number>>(() => new Set());
useEffect(() => { useEffect(() => {
@ -30,11 +28,11 @@ export function AuthorizationTable({ rows, userID, onDone }: { rows: Authorizati
<table className="data-table authorization-table"> <table className="data-table authorization-table">
<thead> <thead>
<tr> <tr>
<th>{t("auth.device")}</th> <th>{"Device"}</th>
<th>{t("auth.platform")}</th> <th>{"Platform"}</th>
<th>{t("auth.ip")}</th> <th>{"IP"}</th>
<th>{t("auth.lastActive")}</th> <th>{"Last active"}</th>
<th className="device-actions-head">{t("common.actions")}</th> <th className="device-actions-head">{"Actions"}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -47,7 +45,7 @@ export function AuthorizationTable({ rows, userID, onDone }: { rows: Authorizati
<td className="device-actions-cell"> <td className="device-actions-cell">
<div className="device-actions"> <div className="device-actions">
<ActionButton <ActionButton
label={t("auth.revokeCurrent")} label={"Revoke current"}
icon={<LogOut size={13} />} icon={<LogOut size={13} />}
compact compact
path="/api/actions/revoke-sessions" path="/api/actions/revoke-sessions"
@ -55,7 +53,7 @@ export function AuthorizationTable({ rows, userID, onDone }: { rows: Authorizati
onDone={() => afterRevoke((previous) => new Set([...previous, row.Hash]))} onDone={() => afterRevoke((previous) => new Set([...previous, row.Hash]))}
/> />
<ActionButton <ActionButton
label={t("auth.keepCurrent")} label={"Keep current"}
icon={<ShieldCheck size={13} />} icon={<ShieldCheck size={13} />}
compact compact
path="/api/actions/revoke-sessions" path="/api/actions/revoke-sessions"
@ -72,7 +70,7 @@ export function AuthorizationTable({ rows, userID, onDone }: { rows: Authorizati
</div> </div>
<div className="danger-zone"> <div className="danger-zone">
<ActionButton <ActionButton
label={t("auth.revokeAll")} label={"Revoke all devices"}
icon={<Cable size={15} />} icon={<Cable size={15} />}
path="/api/actions/revoke-sessions" path="/api/actions/revoke-sessions"
payload={() => ({ user_id: userID, revoke_all: true })} payload={() => ({ user_id: userID, revoke_all: true })}

View file

@ -1,9 +1,8 @@
import { Check, Loader2, Search, X } from "lucide-react"; import { Check, Loader2, Search, X } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { api, errorMessage } from "../api"; import { api, errorMessage } from "../api";
import { useI18n } from "../i18n";
import { channelKind, displayName, displayPhone, displayUsername } from "../lib/format"; 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"; import { Badge } from "./ui";
export function UserPicker({ export function UserPicker({
@ -15,7 +14,6 @@ export function UserPicker({
value: AccountRow | null; value: AccountRow | null;
onChange: (row: AccountRow | null) => void; onChange: (row: AccountRow | null) => void;
}) { }) {
const { t } = useI18n();
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [rows, setRows] = useState<AccountRow[]>([]); const [rows, setRows] = useState<AccountRow[]>([]);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
@ -48,7 +46,7 @@ export function UserPicker({
<span>{label}</span> <span>{label}</span>
{value ? ( {value ? (
<button className="link-button" type="button" onClick={() => onChange(null)}> <button className="link-button" type="button" onClick={() => onChange(null)}>
<X size={13} /> {t("common.clear")} <X size={13} /> {"Clear"}
</button> </button>
) : null} ) : null}
</div> </div>
@ -73,10 +71,10 @@ export function UserPicker({
void search(); void search();
} }
}} }}
placeholder={t("picker.userPlaceholder")} placeholder={"Search user_id / phone / username"}
/> />
<button className="btn compact-btn" type="button" onClick={search} disabled={busy}> <button className="btn compact-btn" type="button" onClick={search} disabled={busy}>
{busy ? <Loader2 size={14} className="spin" /> : t("common.search")} {busy ? <Loader2 size={14} className="spin" /> : "Search"}
</button> </button>
</div> </div>
{error && <div className="picker-error">{error}</div>} {error && <div className="picker-error">{error}</div>}
@ -91,10 +89,106 @@ export function UserPicker({
<span className="mono">{row.ID}</span> <span className="mono">{row.ID}</span>
<strong>{displayName(row)}</strong> <strong>{displayName(row)}</strong>
<span>{displayUsername(row.Username) || displayPhone(row.Phone) || "-"}</span> <span>{displayUsername(row.Username) || displayPhone(row.Phone) || "-"}</span>
{row.Verified ? <Badge tone="good">{t("picker.verified")}</Badge> : <Badge>{t("picker.regular")}</Badge>} {row.Verified ? <Badge tone="good">{"Verified"}</Badge> : <Badge>{"Regular"}</Badge>}
</button> </button>
))} ))}
{rows.length === 0 && !busy ? <div className="picker-empty">{t("common.noResults")}</div> : null} {rows.length === 0 && !busy ? <div className="picker-empty">{"No results"}</div> : null}
</div>
</div>
);
}
// 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 [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} /> {"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={"Bot username or id"}
/>
<button className="btn compact-btn" type="button" onClick={search} disabled={busy}>
{busy ? <Loader2 size={14} className="spin" /> : "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">{"System"}</Badge> : <Badge>{"Regular"}</Badge>}
</button>
))}
{rows.length === 0 && !busy ? <div className="picker-empty">{"No results"}</div> : null}
</div> </div>
</div> </div>
); );
@ -109,7 +203,6 @@ export function ChannelPicker({
value: ChannelRow | null; value: ChannelRow | null;
onChange: (row: ChannelRow | null) => void; onChange: (row: ChannelRow | null) => void;
}) { }) {
const { t } = useI18n();
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [rows, setRows] = useState<ChannelRow[]>([]); const [rows, setRows] = useState<ChannelRow[]>([]);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
@ -142,7 +235,7 @@ export function ChannelPicker({
<span>{label}</span> <span>{label}</span>
{value ? ( {value ? (
<button className="link-button" type="button" onClick={() => onChange(null)}> <button className="link-button" type="button" onClick={() => onChange(null)}>
<X size={13} /> {t("common.clear")} <X size={13} /> {"Clear"}
</button> </button>
) : null} ) : null}
</div> </div>
@ -153,7 +246,7 @@ export function ChannelPicker({
<strong>{value.Title || "-"}</strong> <strong>{value.Title || "-"}</strong>
<span className="mono">{value.ID}</span> <span className="mono">{value.ID}</span>
</div> </div>
<span>{displayUsername(value.Username) || channelKind(value, t)}</span> <span>{displayUsername(value.Username) || channelKind(value)}</span>
</div> </div>
) : null} ) : null}
<div className="picker-search"> <div className="picker-search">
@ -167,10 +260,10 @@ export function ChannelPicker({
void search(); void search();
} }
}} }}
placeholder={t("picker.channelPlaceholder")} placeholder={"Search channel_id / username / title"}
/> />
<button className="btn compact-btn" type="button" onClick={search} disabled={busy}> <button className="btn compact-btn" type="button" onClick={search} disabled={busy}>
{busy ? <Loader2 size={14} className="spin" /> : t("common.search")} {busy ? <Loader2 size={14} className="spin" /> : "Search"}
</button> </button>
</div> </div>
{error && <div className="picker-error">{error}</div>} {error && <div className="picker-error">{error}</div>}
@ -184,11 +277,11 @@ export function ChannelPicker({
> >
<span className="mono">{row.ID}</span> <span className="mono">{row.ID}</span>
<strong>{row.Title || "-"}</strong> <strong>{row.Title || "-"}</strong>
<span>{displayUsername(row.Username) || channelKind(row, t)}</span> <span>{displayUsername(row.Username) || channelKind(row)}</span>
{row.Verified ? <Badge tone="good">{t("picker.verified")}</Badge> : <Badge>{channelKind(row, t)}</Badge>} {row.Verified ? <Badge tone="good">{"Verified"}</Badge> : <Badge>{channelKind(row)}</Badge>}
</button> </button>
))} ))}
{rows.length === 0 && !busy ? <div className="picker-empty">{t("common.noResults")}</div> : null} {rows.length === 0 && !busy ? <div className="picker-empty">{"No results"}</div> : null}
</div> </div>
</div> </div>
); );

View file

@ -1,4 +1,6 @@
import { import {
AtSign,
BadgeCheck,
Bot, Bot,
ChevronDown, ChevronDown,
Database, Database,
@ -7,8 +9,11 @@ import {
MessageSquareText, MessageSquareText,
Server, Server,
Shield, Shield,
ShieldAlert,
ShieldCheck, ShieldCheck,
Smile, Smile,
Stamp,
Trophy,
Users, Users,
Gift, Gift,
Sticker, Sticker,
@ -16,20 +21,19 @@ import {
} from "lucide-react"; } from "lucide-react";
import { useEffect, useState, type ReactNode } from "react"; import { useEffect, useState, type ReactNode } from "react";
import { api } from "../api"; import { api } from "../api";
import { useI18n } from "../i18n"; import { permissionBotVerificationReview, permissionVerificationReview, useCan } from "../permissions";
import { type Navigate, type RouteState, routeSubtitle, routeTitle } from "../routing"; import { type Navigate, type RouteState, routeSubtitle, routeTitle } from "../routing";
import { ThemeSwitch } from "../theme"; import { ThemeSwitch } from "../theme";
import { AppLink } from "./AppLink"; import { AppLink } from "./AppLink";
export function BootScreen() { export function BootScreen() {
const { t } = useI18n();
return ( return (
<div className="boot-screen"> <div className="boot-screen">
<div className="brand compact brand-elevated"> <div className="brand compact brand-elevated">
<span className="brand-mark"><img src="/logo.png" alt="OwpenGram" /></span> <span className="brand-mark"><img src="/logo.png" alt="OwpenGram" /></span>
<span> <span>
<strong>OwpenGram</strong> <strong>OwpenGram</strong>
<small>{t("app.adminConsole")}</small> <small>{"Admin Console"}</small>
</span> </span>
</div> </div>
<div className="loader-bar" /> <div className="loader-bar" />
@ -50,7 +54,12 @@ export function Shell({
onLogout: () => void; onLogout: () => void;
children: ReactNode; 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 messagesActive = route.path.startsWith("/messages");
const [messagesOpen, setMessagesOpen] = useState(messagesActive); const [messagesOpen, setMessagesOpen] = useState(messagesActive);
@ -72,19 +81,28 @@ export function Shell({
<span className="brand-mark"><img src="/logo.png" alt="OwpenGram" /></span> <span className="brand-mark"><img src="/logo.png" alt="OwpenGram" /></span>
<span> <span>
<strong>OwpenGram</strong> <strong>OwpenGram</strong>
<small>{t("app.adminConsole")}</small> <small>{"Admin Console"}</small>
</span> </span>
</AppLink> </AppLink>
<div className="sidebar-label">{t("layout.navigation")}</div> <div className="sidebar-label">{"Navigation"}</div>
<nav className="nav-list" aria-label={t("layout.primaryNav")}> <nav className="nav-list" aria-label={"Primary navigation"}>
<NavLink icon={<LayoutDashboard size={16} />} href="/" route={route} navigate={navigate}>{t("layout.dashboard")}</NavLink> <NavLink icon={<LayoutDashboard size={16} />} href="/" route={route} navigate={navigate}>{"Overview"}</NavLink>
<NavLink icon={<Users size={16} />} href="/accounts" route={route} navigate={navigate}>{t("layout.accounts")}</NavLink> <NavLink icon={<Users size={16} />} href="/accounts" route={route} navigate={navigate}>{"Accounts"}</NavLink>
<NavLink icon={<ShieldCheck size={16} />} href="/channels" route={route} navigate={navigate}>{t("layout.channels")}</NavLink> <NavLink icon={<ShieldCheck size={16} />} href="/channels" route={route} navigate={navigate}>{"Supergroups / Channels"}</NavLink>
<NavLink icon={<Bot size={16} />} href="/bots" route={route} navigate={navigate}>{t("layout.bots")}</NavLink> <NavLink icon={<Bot size={16} />} href="/bots" route={route} navigate={navigate}>{"Bots"}</NavLink>
<NavLink icon={<Gift size={16} />} href="/gifts" route={route} navigate={navigate}>{t("layout.gifts")}</NavLink> <NavLink icon={<ShieldAlert size={16} />} href="/moderation" route={route} navigate={navigate}>{"Reports / Moderation"}</NavLink>
<NavLink icon={<Send size={16} />} href="/give-gifts" route={route} navigate={navigate}>{t("layout.giveGifts")}</NavLink> {canReviewVerification && (
<NavLink icon={<Sticker size={16} />} href="/stickers" route={route} navigate={navigate}>{t("layout.stickers")}</NavLink> <NavLink icon={<BadgeCheck size={16} />} href="/verification" route={route} navigate={navigate}>{"Verification"}</NavLink>
<NavLink icon={<Smile size={16} />} href="/emoji" route={route} navigate={navigate}>{t("layout.emoji")}</NavLink> )}
{canReviewBotVerification && (
<NavLink icon={<Stamp size={16} />} href="/bot-verification" route={route} navigate={navigate}>{"Third-party marks"}</NavLink>
)}
<NavLink icon={<AtSign size={16} />} href="/collectible-usernames" route={route} navigate={navigate}>{"NFT Usernames"}</NavLink>
<NavLink icon={<Trophy size={16} />} href="/account-ratings" route={route} navigate={navigate}>{"Account Rating"}</NavLink>
<NavLink icon={<Gift size={16} />} href="/gifts" route={route} navigate={navigate}>{"Star Gifts"}</NavLink>
<NavLink icon={<Send size={16} />} href="/give-gifts" route={route} navigate={navigate}>{"Give Gifts"}</NavLink>
<NavLink icon={<Sticker size={16} />} href="/stickers" route={route} navigate={navigate}>{"Stickers"}</NavLink>
<NavLink icon={<Smile size={16} />} href="/emoji" route={route} navigate={navigate}>{"Emoji"}</NavLink>
<div className={`nav-section ${messagesActive ? "active" : ""} ${messagesOpen ? "open" : ""}`}> <div className={`nav-section ${messagesActive ? "active" : ""} ${messagesOpen ? "open" : ""}`}>
<button <button
className="nav-section-toggle" className="nav-section-toggle"
@ -93,7 +111,7 @@ export function Shell({
onClick={() => setMessagesOpen((open) => !open)} onClick={() => setMessagesOpen((open) => !open)}
> >
<MessageSquareText size={16} /> <MessageSquareText size={16} />
<span>{t("layout.messages")}</span> <span>{"Messages"}</span>
<ChevronDown className="nav-section-chevron" size={15} /> <ChevronDown className="nav-section-chevron" size={15} />
</button> </button>
{messagesOpen && ( {messagesOpen && (
@ -104,7 +122,7 @@ export function Shell({
navigate={navigate} navigate={navigate}
activeWhen={(path) => path === "/messages" || path === "/messages/detail" || path.startsWith("/messages/private")} activeWhen={(path) => path === "/messages" || path === "/messages/detail" || path.startsWith("/messages/private")}
> >
{t("layout.privateMessages")} {"Private"}
</NavLink> </NavLink>
<NavLink <NavLink
href="/messages/groups" href="/messages/groups"
@ -112,30 +130,30 @@ export function Shell({
navigate={navigate} navigate={navigate}
activeWhen={(path) => path.startsWith("/messages/groups")} activeWhen={(path) => path.startsWith("/messages/groups")}
> >
{t("layout.groupMessages")} {"Groups"}
</NavLink> </NavLink>
</div> </div>
)} )}
</div> </div>
</nav> </nav>
<div className="sidebar-status"> <div className="sidebar-status">
<div className="sidebar-label">{t("layout.runtime")}</div> <div className="sidebar-label">{"Runtime"}</div>
<div className="runtime-row"><Server size={14} /><span>{t("layout.adminBackend")}</span><strong>{t("layout.ready")}</strong></div> <div className="runtime-row"><Server size={14} /><span>{"Admin backend"}</span><strong>{"Ready"}</strong></div>
<div className="runtime-row"><Database size={14} /><span>{t("layout.pgRead")}</span><strong>{t("layout.readOnly")}</strong></div> <div className="runtime-row"><Database size={14} /><span>{"PG read"}</span><strong>{"Read-only"}</strong></div>
<div className="runtime-row"><Shield size={14} /><span>{t("layout.writeOps")}</span><strong>{t("layout.dryRun")}</strong></div> <div className="runtime-row"><Shield size={14} /><span>{"Write operations"}</span><strong>{"Dry-run"}</strong></div>
</div> </div>
</aside> </aside>
<div className="workspace"> <div className="workspace">
<header className="topbar"> <header className="topbar">
<div> <div>
<div className="eyebrow">{routeSubtitle(route.path, t)}</div> <div className="eyebrow">{routeSubtitle(route.path)}</div>
<h1>{routeTitle(route.path, t)}</h1> <h1>{routeTitle(route.path)}</h1>
</div> </div>
<div className="topbar-actions"> <div className="topbar-actions">
<ThemeSwitch /> <ThemeSwitch />
<span className="actor-pill">{t("layout.actor", { actor })}</span> <span className="actor-pill">{`Actor: ${actor}`}</span>
<button className="btn ghost icon-text" type="button" onClick={logout} title={t("layout.logout")}> <button className="btn ghost icon-text" type="button" onClick={logout} title={"Log out"}>
<LogOut size={16} /> {t("layout.logout")} <LogOut size={16} /> {"Log out"}
</button> </button>
</div> </div>
</header> </header>

View file

@ -1,7 +1,6 @@
import { AtSign, LifeBuoy, Palette, Settings2, Smile } from "lucide-react"; import { AtSign, LifeBuoy, Palette, Settings2, Smile } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { ActionButton } from "./ActionButton"; import { ActionButton } from "./ActionButton";
import { useI18n } from "../i18n";
import { toInt } from "../lib/format"; import { toInt } from "../lib/format";
import type { ChannelRow } from "../types"; import type { ChannelRow } from "../types";
@ -9,10 +8,9 @@ type IDKey = "user_id" | "channel_id";
// SupportAction toggles the official-support flag (users/bots only). // SupportAction toggles the official-support flag (users/bots only).
export function SupportAction({ id, support, onDone }: { id: number; support: boolean; onDone: () => void }) { export function SupportAction({ id, support, onDone }: { id: number; support: boolean; onDone: () => void }) {
const { t } = useI18n();
return ( return (
<ActionButton <ActionButton
label={support ? t("attr.clearSupport") : t("attr.setSupport")} label={support ? "Clear support" : "Mark as support"}
icon={<LifeBuoy size={15} />} icon={<LifeBuoy size={15} />}
tone="neutral" tone="neutral"
path="/api/actions/set-support" path="/api/actions/set-support"
@ -30,16 +28,15 @@ export function UsernameAction({ idKey, id, path, current, onDone }: {
current: string; current: string;
onDone: () => void; onDone: () => void;
}) { }) {
const { t } = useI18n();
const [username, setUsername] = useState(current.replace(/^@/, "")); const [username, setUsername] = useState(current.replace(/^@/, ""));
return ( return (
<div className="attr-block"> <div className="attr-block">
<label className="duration-field"> <label className="duration-field">
<span>{t("attr.username")}</span> <span>{"Username"}</span>
<input value={username} onChange={(e) => setUsername(e.target.value)} placeholder="username" /> <input value={username} onChange={(e) => setUsername(e.target.value)} placeholder="username" />
</label> </label>
<ActionButton <ActionButton
label={t("attr.setUsername")} label={"Set username"}
icon={<AtSign size={15} />} icon={<AtSign size={15} />}
tone="neutral" tone="neutral"
path={path} path={path}
@ -57,25 +54,24 @@ export function ColorAction({ idKey, id, path, onDone }: {
path: string; path: string;
onDone: () => void; onDone: () => void;
}) { }) {
const { t } = useI18n();
const [forProfile, setForProfile] = useState(false); const [forProfile, setForProfile] = useState(false);
const [hasColor, setHasColor] = useState(true); const [hasColor, setHasColor] = useState(true);
const [color, setColor] = useState("0"); const [color, setColor] = useState("0");
const [bgEmoji, setBgEmoji] = useState(""); const [bgEmoji, setBgEmoji] = useState("");
return ( return (
<div className="attr-block"> <div className="attr-block">
<label className="checkline"><input type="checkbox" checked={forProfile} onChange={(e) => setForProfile(e.target.checked)} /> {t("attr.forProfile")}</label> <label className="checkline"><input type="checkbox" checked={forProfile} onChange={(e) => setForProfile(e.target.checked)} /> {"Profile color"}</label>
<label className="checkline"><input type="checkbox" checked={hasColor} onChange={(e) => setHasColor(e.target.checked)} /> {t("attr.hasColor")}</label> <label className="checkline"><input type="checkbox" checked={hasColor} onChange={(e) => setHasColor(e.target.checked)} /> {"Enable color"}</label>
<label className="duration-field"> <label className="duration-field">
<span>{t("attr.colorIndex")}</span> <span>{"Color index"}</span>
<input type="number" min="0" max="20" value={color} onChange={(e) => setColor(e.target.value)} /> <input type="number" min="0" max="20" value={color} onChange={(e) => setColor(e.target.value)} />
</label> </label>
<label className="duration-field"> <label className="duration-field">
<span>{t("attr.bgEmojiID")}</span> <span>{"Background emoji ID"}</span>
<input value={bgEmoji} onChange={(e) => setBgEmoji(e.target.value)} placeholder="0" /> <input value={bgEmoji} onChange={(e) => setBgEmoji(e.target.value)} placeholder="0" />
</label> </label>
<ActionButton <ActionButton
label={t("attr.setColor")} label={"Set color"}
icon={<Palette size={15} />} icon={<Palette size={15} />}
tone="neutral" tone="neutral"
path={path} path={path}
@ -99,21 +95,20 @@ export function EmojiStatusAction({ idKey, id, path, onDone }: {
path: string; path: string;
onDone: () => void; onDone: () => void;
}) { }) {
const { t } = useI18n();
const [documentID, setDocumentID] = useState(""); const [documentID, setDocumentID] = useState("");
const [until, setUntil] = useState("0"); const [until, setUntil] = useState("0");
return ( return (
<div className="attr-block"> <div className="attr-block">
<label className="duration-field"> <label className="duration-field">
<span>{t("attr.emojiDocID")}</span> <span>{"Emoji document ID"}</span>
<input value={documentID} onChange={(e) => setDocumentID(e.target.value)} placeholder="0 = clear" /> <input value={documentID} onChange={(e) => setDocumentID(e.target.value)} placeholder="0 = clear" />
</label> </label>
<label className="duration-field"> <label className="duration-field">
<span>{t("attr.emojiUntil")}</span> <span>{"Until (unix, 0 = permanent)"}</span>
<input type="number" min="0" value={until} onChange={(e) => setUntil(e.target.value)} /> <input type="number" min="0" value={until} onChange={(e) => setUntil(e.target.value)} />
</label> </label>
<ActionButton <ActionButton
label={t("attr.setEmojiStatus")} label={"Set emoji status"}
icon={<Smile size={15} />} icon={<Smile size={15} />}
tone="neutral" tone="neutral"
path={path} path={path}
@ -126,7 +121,6 @@ export function EmojiStatusAction({ idKey, id, path, onDone }: {
// ChannelSettingsAction force-applies moderation settings to a channel/supergroup. // ChannelSettingsAction force-applies moderation settings to a channel/supergroup.
export function ChannelSettingsAction({ channel, onDone }: { channel: ChannelRow; onDone: () => void }) { export function ChannelSettingsAction({ channel, onDone }: { channel: ChannelRow; onDone: () => void }) {
const { t } = useI18n();
const [gigagroup, setGigagroup] = useState(channel.Gigagroup); const [gigagroup, setGigagroup] = useState(channel.Gigagroup);
const [antispam, setAntispam] = useState(channel.AntiSpam); const [antispam, setAntispam] = useState(channel.AntiSpam);
const [hidden, setHidden] = useState(channel.ParticipantsHidden); const [hidden, setHidden] = useState(channel.ParticipantsHidden);
@ -163,18 +157,18 @@ export function ChannelSettingsAction({ channel, onDone }: { channel: ChannelRow
} }
return ( return (
<div className="attr-block"> <div className="attr-block">
<label className="checkline"><input type="checkbox" checked={gigagroup} onChange={(e) => setGigagroup(e.target.checked)} /> {t("attr.gigagroup")}</label> <label className="checkline"><input type="checkbox" checked={gigagroup} onChange={(e) => setGigagroup(e.target.checked)} /> {"Gigagroup"}</label>
<label className="checkline"><input type="checkbox" checked={antispam} onChange={(e) => setAntispam(e.target.checked)} /> {t("attr.antispam")}</label> <label className="checkline"><input type="checkbox" checked={antispam} onChange={(e) => setAntispam(e.target.checked)} /> {"Aggressive anti-spam"}</label>
<label className="checkline"><input type="checkbox" checked={hidden} onChange={(e) => setHidden(e.target.checked)} /> {t("attr.participantsHidden")}</label> <label className="checkline"><input type="checkbox" checked={hidden} onChange={(e) => setHidden(e.target.checked)} /> {"Hide members"}</label>
<label className="checkline"><input type="checkbox" checked={noforwards} onChange={(e) => setNoforwards(e.target.checked)} /> {t("attr.noforwards")}</label> <label className="checkline"><input type="checkbox" checked={noforwards} onChange={(e) => setNoforwards(e.target.checked)} /> {"Restrict forwarding"}</label>
<label className="checkline"><input type="checkbox" checked={joinToSend} onChange={(e) => setJoinToSend(e.target.checked)} /> {t("attr.joinToSend")}</label> <label className="checkline"><input type="checkbox" checked={joinToSend} onChange={(e) => setJoinToSend(e.target.checked)} /> {"Join to send messages"}</label>
<label className="checkline"><input type="checkbox" checked={joinRequest} onChange={(e) => setJoinRequest(e.target.checked)} /> {t("attr.joinRequest")}</label> <label className="checkline"><input type="checkbox" checked={joinRequest} onChange={(e) => setJoinRequest(e.target.checked)} /> {"Join by request"}</label>
<label className="duration-field"> <label className="duration-field">
<span>{t("attr.slowmode")}</span> <span>{"Slowmode (seconds)"}</span>
<input type="number" min="0" max="86400" value={slowmode} onChange={(e) => setSlowmode(e.target.value)} /> <input type="number" min="0" max="86400" value={slowmode} onChange={(e) => setSlowmode(e.target.value)} />
</label> </label>
<ActionButton <ActionButton
label={t("attr.applySettings")} label={"Apply settings"}
icon={<Settings2 size={15} />} icon={<Settings2 size={15} />}
tone="warn" tone="warn"
path="/api/actions/set-channel-settings" path="/api/actions/set-channel-settings"

View file

@ -1,18 +1,16 @@
import { ShieldAlert, ShieldX } from "lucide-react"; import { ShieldAlert, ShieldX } from "lucide-react";
import { useI18n } from "../i18n";
import { ActionButton } from "./ActionButton"; import { ActionButton } from "./ActionButton";
import { Badge } from "./ui"; import { Badge } from "./ui";
// ScamFakeBadges renders the SCAM/FAKE moderation labels when set. // ScamFakeBadges renders the SCAM/FAKE moderation labels when set.
export function ScamFakeBadges({ scam, fake }: { scam: boolean; fake: boolean }) { export function ScamFakeBadges({ scam, fake }: { scam: boolean; fake: boolean }) {
const { t } = useI18n();
if (!scam && !fake) { if (!scam && !fake) {
return null; return null;
} }
return ( return (
<> <>
{scam && <Badge tone="danger">{t("flags.scam")}</Badge>} {scam && <Badge tone="danger">{"SCAM"}</Badge>}
{fake && <Badge tone="danger">{t("flags.fake")}</Badge>} {fake && <Badge tone="danger">{"FAKE"}</Badge>}
</> </>
); );
} }
@ -35,11 +33,10 @@ export function ScamFakeActions({
fake: boolean; fake: boolean;
onDone: () => void; onDone: () => void;
}) { }) {
const { t } = useI18n();
return ( return (
<div className="action-stack"> <div className="action-stack">
<ActionButton <ActionButton
label={scam ? t("flags.clearScam") : t("flags.setScam")} label={scam ? "Clear SCAM" : "Mark as SCAM"}
icon={<ShieldAlert size={15} />} icon={<ShieldAlert size={15} />}
tone="danger" tone="danger"
path={path} path={path}
@ -47,7 +44,7 @@ export function ScamFakeActions({
onDone={onDone} onDone={onDone}
/> />
<ActionButton <ActionButton
label={fake ? t("flags.clearFake") : t("flags.setFake")} label={fake ? "Clear FAKE" : "Mark as FAKE"}
icon={<ShieldX size={15} />} icon={<ShieldX size={15} />}
tone="danger" tone="danger"
path={path} path={path}

View file

@ -1,8 +1,7 @@
import { CircleAlert } from "lucide-react"; import { CircleAlert } from "lucide-react";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { useI18n } from "../i18n"; import { displayUsername, formatDate } from "../lib/format";
import { formatDate } from "../lib/format"; import type { AccountUsername, AuditLogRow } from "../types";
import type { AuditLogRow } from "../types";
type Tone = "neutral" | "good" | "danger" | "warn"; type Tone = "neutral" | "good" | "danger" | "warn";
@ -92,11 +91,10 @@ export function Summary({ label, value, mono = false }: { label: string; value:
} }
export function AuditTable({ rows }: { rows: AuditLogRow[] }) { export function AuditTable({ rows }: { rows: AuditLogRow[] }) {
const { t } = useI18n();
return ( return (
<div className="table-wrap"> <div className="table-wrap">
<table className="data-table"> <table className="data-table">
<thead><tr><th>{t("audit.id")}</th><th>{t("audit.commandID")}</th><th>{t("audit.action")}</th><th>{t("audit.actor")}</th><th>{t("audit.status")}</th><th>{t("audit.dryRun")}</th><th>{t("audit.reason")}</th><th>{t("audit.time")}</th></tr></thead> <thead><tr><th>{"ID"}</th><th>{"Command ID"}</th><th>{"Action"}</th><th>{"Actor"}</th><th>{"Status"}</th><th>{"Dry-run"}</th><th>{"Reason"}</th><th>{"Time"}</th></tr></thead>
<tbody> <tbody>
{rows.map((row) => ( {rows.map((row) => (
<tr key={row.ID}> <tr key={row.ID}>
@ -105,7 +103,7 @@ export function AuditTable({ rows }: { rows: AuditLogRow[] }) {
<td>{row.Action}</td> <td>{row.Action}</td>
<td>{row.Actor}</td> <td>{row.Actor}</td>
<td>{row.Status}</td> <td>{row.Status}</td>
<td>{row.DryRun ? t("common.yes") : t("common.no")}</td> <td>{row.DryRun ? "Yes" : "No"}</td>
<td className="truncate">{row.Reason}</td> <td className="truncate">{row.Reason}</td>
<td>{formatDate(row.CreatedAt)}</td> <td>{formatDate(row.CreatedAt)}</td>
</tr> </tr>
@ -118,8 +116,7 @@ export function AuditTable({ rows }: { rows: AuditLogRow[] }) {
} }
export function EmptyRow({ colSpan }: { colSpan: number }) { export function EmptyRow({ colSpan }: { colSpan: number }) {
const { t } = useI18n(); return <tr><td colSpan={colSpan} className="empty-cell">{"No results"}</td></tr>;
return <tr><td colSpan={colSpan} className="empty-cell">{t("common.noResults")}</td></tr>;
} }
export function LoadingSurface({ label }: { label: string }) { export function LoadingSurface({ label }: { label: string }) {
@ -129,3 +126,32 @@ export function LoadingSurface({ label }: { label: string }) {
export function JsonBlock({ value }: { value: string }) { export function JsonBlock({ value }: { value: string }) {
return <pre className="json-block">{value || "{}"}</pre>; 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 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>{"inactive"}</em>}
</li>
))}
</ul>
</>
);
}

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,4 @@
import type { AccountRow, ChannelRow } from "../types"; import type { AccountRow, ChannelRow } from "../types";
import type { TFunction } from "../i18n";
export function displayPhone(value: string): string { export function displayPhone(value: string): string {
const phone = value.trim(); const phone = value.trim();
@ -17,17 +16,11 @@ export function displayName(row: Pick<AccountRow, "FirstName" | "LastName">): st
return `${row.FirstName || ""} ${row.LastName || ""}`.trim() || "-"; return `${row.FirstName || ""} ${row.LastName || ""}`.trim() || "-";
} }
export function channelKind(ch: ChannelRow, t?: TFunction): string { export function channelKind(ch: ChannelRow): string {
const translate = t ?? ((key: string) => ({ if (ch.Broadcast && !ch.Megagroup) return "Channel";
"channel.kind.broadcast": "Channel", if (ch.Megagroup && ch.Forum) return "Supergroup / Forum";
"channel.kind.forum": "Supergroup / Forum", if (ch.Megagroup) return "Supergroup";
"channel.kind.megagroup": "Supergroup", return "Channel / Group";
"channel.kind.generic": "Channel / Group"
})[key] ?? key);
if (ch.Broadcast && !ch.Megagroup) return translate("channel.kind.broadcast");
if (ch.Megagroup && ch.Forum) return translate("channel.kind.forum");
if (ch.Megagroup) return translate("channel.kind.megagroup");
return translate("channel.kind.generic");
} }
export function formatDate(value: string): string { export function formatDate(value: string): string {
@ -44,12 +37,131 @@ export function formatUnix(value: number): string {
return date.toLocaleString(); 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 { export function toInt(value: string): number {
if (!value.trim()) return 0; if (!value.trim()) return 0;
const parsed = Number.parseInt(value, 10); const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) ? parsed : 0; 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[] { export function parseIDs(value: string, invalidMessage = "msg ids invalid"): number[] {
const ids = value const ids = value
.split(/[\s,]+/) .split(/[\s,]+/)

View file

@ -1,16 +1,13 @@
import React from "react"; import React from "react";
import ReactDOM from "react-dom/client"; import ReactDOM from "react-dom/client";
import { App } from "./App"; import { App } from "./App";
import { I18nProvider } from "./i18n";
import { ThemeProvider } from "./theme"; import { ThemeProvider } from "./theme";
import "./styles.css"; import "./styles.css";
ReactDOM.createRoot(document.getElementById("root")!).render( ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode> <React.StrictMode>
<ThemeProvider> <ThemeProvider>
<I18nProvider> <App />
<App />
</I18nProvider>
</ThemeProvider> </ThemeProvider>
</React.StrictMode> </React.StrictMode>
); );

View file

@ -3,16 +3,14 @@ import { useEffect, useState } from "react";
import { api, errorMessage } from "../api"; import { api, errorMessage } from "../api";
import { ActionButton } from "../components/ActionButton"; import { ActionButton } from "../components/ActionButton";
import { AuthorizationTable } from "../components/AuthorizationTable"; 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 { ScamFakeActions, ScamFakeBadges } from "../components/flags";
import { ColorAction, EmojiStatusAction, SupportAction, UsernameAction } from "../components/attributes"; import { ColorAction, EmojiStatusAction, SupportAction, UsernameAction } from "../components/attributes";
import { useI18n } from "../i18n";
import { displayName, displayPhone, displayUsername, formatDate, formatUnix, toInt } from "../lib/format"; import { displayName, displayPhone, displayUsername, formatDate, formatUnix, toInt } from "../lib/format";
import type { Navigate } from "../routing"; import type { Navigate } from "../routing";
import type { AccountDetail } from "../types"; import type { AccountDetail } from "../types";
export function AccountDetailPage({ id, navigate }: { id: number; navigate: Navigate }) { export function AccountDetailPage({ id, navigate }: { id: number; navigate: Navigate }) {
const { t } = useI18n();
const [detail, setDetail] = useState<AccountDetail | null>(null); const [detail, setDetail] = useState<AccountDetail | null>(null);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
@ -48,15 +46,15 @@ export function AccountDetailPage({ id, navigate }: { id: number; navigate: Navi
return <Alert>{error}</Alert>; return <Alert>{error}</Alert>;
} }
if (!detail) { if (!detail) {
return <LoadingSurface label={busy ? t("account.loadingDetail") : t("account.waitingData")} />; return <LoadingSurface label={busy ? "Loading account detail" : "Waiting for data"} />;
} }
const account = detail.Account; const account = detail.Account;
return ( return (
<PageFrame <PageFrame
title={t("account.detailTitle", { id: account.ID })} title={`Account #${account.ID}`}
eyebrow={t("account.profile")} eyebrow={"Account Profile"}
actions={<button className="btn icon-text" onClick={() => navigate("/accounts")}><ArrowLeft size={15} /> {t("common.backToList")}</button>} actions={<button className="btn icon-text" onClick={() => navigate("/accounts")}><ArrowLeft size={15} /> {"Back to list"}</button>}
> >
<SplitLayout <SplitLayout
main={ main={
@ -64,56 +62,61 @@ export function AccountDetailPage({ id, navigate }: { id: number; navigate: Navi
<section className="entity-head"> <section className="entity-head">
<div> <div>
<div className="entity-title">{displayName(account)}</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> <div className="entity-subtitle">{displayUsername(account.Username) || "No username"} · {displayPhone(account.Phone) || "No phone"}</div>
{account.Collectibles?.length > 0 && (
<div className="entity-subtitle">
<UsernameCell username="" collectibles={account.Collectibles} />
</div>
)}
</div> </div>
<div className="entity-badges"> <div className="entity-badges">
{account.PremiumUntil > 0 ? <Badge tone="good">{t("account.premium")}</Badge> : <Badge>{t("account.notPremium")}</Badge>} {account.PremiumUntil > 0 ? <Badge tone="good">{"Premium"}</Badge> : <Badge>{"Not premium"}</Badge>}
{detail.Verified ? <Badge tone="good">{t("common.verified")}</Badge> : <Badge>{t("account.notVerified")}</Badge>} {detail.Verified ? <Badge tone="good">{"Verified"}</Badge> : <Badge>{"Not verified"}</Badge>}
<ScamFakeBadges scam={detail.Scam} fake={detail.Fake} /> <ScamFakeBadges scam={detail.Scam} fake={detail.Fake} />
{account.Frozen ? <Badge tone="danger">{t("account.accountFrozen")}</Badge> : <Badge>{t("account.accountActive")}</Badge>} {account.Frozen ? <Badge tone="danger">{"Account frozen"}</Badge> : <Badge>{"Account active"}</Badge>}
</div> </div>
</section> </section>
<div className="summary-grid"> <div className="summary-grid">
<Summary label={t("account.userID")} value={String(account.ID)} mono /> <Summary label={"User ID"} value={String(account.ID)} mono />
<Summary label={t("account.lastActive")} value={formatUnix(detail.LastSeenAt) || "-"} /> <Summary label={"Last active"} value={formatUnix(detail.LastSeenAt) || "-"} />
<Summary label={t("account.premiumUntil")} value={account.PremiumUntil > 0 ? formatUnix(account.PremiumUntil) : t("common.none")} /> <Summary label={"Premium expires"} value={account.PremiumUntil > 0 ? formatUnix(account.PremiumUntil) : "None"} />
<Summary label={t("account.starsBalance")} value={`${detail.StarsBalance} / ${detail.StarsGranted ? t("account.startingGrantApplied") : t("account.startingGrantPending")}`} /> <Summary label={"Stars balance"} value={`${detail.StarsBalance} / ${detail.StarsGranted ? "initial grant applied" : "initial grant pending"}`} />
<Summary label={t("common.updatedAt")} value={formatDate(account.UpdatedAt) || "-"} /> <Summary label={"Updated"} value={formatDate(account.UpdatedAt) || "-"} />
<Summary label={t("account.activeSessions")} value={String(detail.Authorizations.length)} /> <Summary label={"Authorized devices"} value={String(detail.Authorizations.length)} />
<Summary label={t("account.accountFlags")} value={`support=${detail.Support} bot=${detail.Bot}`} /> <Summary label={"Account flags"} value={`support=${detail.Support} bot=${detail.Bot}`} />
<Summary label={t("account.restriction")} value={detail.HasRestriction ? detail.Restriction.Reason || t("account.restricted") : t("common.none")} /> <Summary label={"Restriction"} value={detail.HasRestriction ? detail.Restriction.Reason || "Restricted" : "None"} />
<Summary label={t("account.freezeSince")} value={detail.Restriction.Since ? formatDate(detail.Restriction.Since) : t("common.none")} /> <Summary label={"Frozen since"} value={detail.Restriction.Since ? formatDate(detail.Restriction.Since) : "None"} />
<Summary label={t("account.freezeUntil")} value={detail.Restriction.Until ? formatDate(detail.Restriction.Until) : t("common.none")} /> <Summary label={"Appeal deadline"} value={detail.Restriction.Until ? formatDate(detail.Restriction.Until) : "None"} />
<Summary label={t("account.freezeAppealURL")} value={detail.Restriction.AppealURL || t("common.none")} /> <Summary label={"Appeal URL"} value={detail.Restriction.AppealURL || "None"} />
<Summary label={t("account.createdAt")} value={formatDate(account.CreatedAt) || "-"} /> <Summary label={"Created"} value={formatDate(account.CreatedAt) || "-"} />
</div> </div>
{detail.About && <p className="about-text">{detail.About}</p>} {detail.About && <p className="about-text">{detail.About}</p>}
<section className="section-block"> <section className="section-block">
<SectionHead title={t("account.authorizationsTitle")} text={t("account.authorizationsCount", { count: detail.Authorizations.length })} /> <SectionHead title={"Authorized Devices"} text={`${detail.Authorizations.length} authorizations`} />
<AuthorizationTable rows={detail.Authorizations} userID={account.ID} onDone={load} /> <AuthorizationTable rows={detail.Authorizations} userID={account.ID} onDone={load} />
</section> </section>
<section className="section-block"> <section className="section-block">
<SectionHead title={t("account.recentAdminOps")} text={t("account.recent30Audit")} /> <SectionHead title={"Recent Admin Actions"} text={"Last 30 audit rows"} />
<AuditTable rows={detail.AuditLogs} /> <AuditTable rows={detail.AuditLogs} />
</section> </section>
</div> </div>
} }
side={ side={
<section className="action-dock"> <section className="action-dock">
<div className="dock-title">{t("account.actionDock")}</div> <div className="dock-title">{"Account Actions"}</div>
<label className="duration-field"> <label className="duration-field">
<span>{t("account.freezeUntil")}</span> <span>{"Appeal deadline"}</span>
<input <input
aria-label={t("account.freezeUntilAria")} aria-label={"Freeze appeal deadline"}
value={freezeUntil} value={freezeUntil}
onChange={(event) => setFreezeUntil(event.target.value)} onChange={(event) => setFreezeUntil(event.target.value)}
type="datetime-local" type="datetime-local"
/> />
</label> </label>
<label className="duration-field"> <label className="duration-field">
<span>{t("account.freezeAppealURL")}</span> <span>{"Appeal URL"}</span>
<input <input
aria-label={t("account.freezeAppealURLAria")} aria-label={"Freeze appeal URL"}
value={freezeAppealURL} value={freezeAppealURL}
onChange={(event) => setFreezeAppealURL(event.target.value)} onChange={(event) => setFreezeAppealURL(event.target.value)}
type="url" type="url"
@ -121,7 +124,7 @@ export function AccountDetailPage({ id, navigate }: { id: number; navigate: Navi
/> />
</label> </label>
<ActionButton <ActionButton
label={account.Frozen ? t("account.updateFreeze") : t("account.freezeAccount")} label={account.Frozen ? "Update freeze" : "Freeze account"}
icon={<CircleAlert size={15} />} icon={<CircleAlert size={15} />}
path="/api/actions/set-frozen" path="/api/actions/set-frozen"
payload={() => ({ payload={() => ({
@ -134,7 +137,7 @@ export function AccountDetailPage({ id, navigate }: { id: number; navigate: Navi
/> />
{account.Frozen && ( {account.Frozen && (
<ActionButton <ActionButton
label={t("account.unfreezeAccount")} label={"Unfreeze account"}
icon={<CircleAlert size={15} />} icon={<CircleAlert size={15} />}
path="/api/actions/set-frozen" path="/api/actions/set-frozen"
payload={() => ({ user_id: account.ID, frozen: false })} payload={() => ({ user_id: account.ID, frozen: false })}
@ -142,9 +145,9 @@ export function AccountDetailPage({ id, navigate }: { id: number; navigate: Navi
/> />
)} )}
<label className="duration-field"> <label className="duration-field">
<span>{t("account.premiumMonths")}</span> <span>{"Premium duration (months)"}</span>
<input <input
aria-label={t("account.premiumMonthsAria")} aria-label={"Set premium duration in months"}
value={months} value={months}
onChange={(event) => setMonths(event.target.value)} onChange={(event) => setMonths(event.target.value)}
type="number" type="number"
@ -154,7 +157,7 @@ export function AccountDetailPage({ id, navigate }: { id: number; navigate: Navi
</label> </label>
<div className="action-stack"> <div className="action-stack">
<ActionButton <ActionButton
label={t("account.setPremium")} label={"Set premium"}
icon={<Sparkles size={15} />} icon={<Sparkles size={15} />}
tone="warn" tone="warn"
path="/api/actions/grant-premium" path="/api/actions/grant-premium"
@ -162,7 +165,7 @@ export function AccountDetailPage({ id, navigate }: { id: number; navigate: Navi
onDone={load} onDone={load}
/> />
<ActionButton <ActionButton
label={t("account.clearPremium")} label={"Clear premium"}
icon={<Sparkles size={15} />} icon={<Sparkles size={15} />}
tone="warn" tone="warn"
path="/api/actions/grant-premium" path="/api/actions/grant-premium"
@ -170,9 +173,9 @@ export function AccountDetailPage({ id, navigate }: { id: number; navigate: Navi
onDone={load} onDone={load}
/> />
<label className="duration-field"> <label className="duration-field">
<span>{t("account.starsAmount")}</span> <span>{"Stars to grant"}</span>
<input <input
aria-label={t("account.starsAmountAria")} aria-label={"Set Stars amount to grant"}
value={starsAmount} value={starsAmount}
onChange={(event) => setStarsAmount(event.target.value)} onChange={(event) => setStarsAmount(event.target.value)}
type="number" type="number"
@ -181,7 +184,7 @@ export function AccountDetailPage({ id, navigate }: { id: number; navigate: Navi
/> />
</label> </label>
<ActionButton <ActionButton
label={t("account.grantStars")} label={"Grant Stars"}
icon={<Star size={15} />} icon={<Star size={15} />}
tone="warn" tone="warn"
path="/api/actions/grant-stars" path="/api/actions/grant-stars"
@ -189,7 +192,7 @@ export function AccountDetailPage({ id, navigate }: { id: number; navigate: Navi
onDone={load} onDone={load}
/> />
<ActionButton <ActionButton
label={detail.Verified ? t("account.clearVerified") : t("account.setVerified")} label={detail.Verified ? "Clear verified" : "Set verified"}
icon={<BadgeCheck size={15} />} icon={<BadgeCheck size={15} />}
tone="warn" tone="warn"
path="/api/actions/set-verified" path="/api/actions/set-verified"
@ -198,7 +201,7 @@ export function AccountDetailPage({ id, navigate }: { id: number; navigate: Navi
/> />
</div> </div>
<ScamFakeActions idKey="user_id" id={account.ID} path="/api/actions/set-account-flags" scam={detail.Scam} fake={detail.Fake} onDone={load} /> <ScamFakeActions idKey="user_id" id={account.ID} path="/api/actions/set-account-flags" scam={detail.Scam} fake={detail.Fake} onDone={load} />
<div className="dock-title">{t("attr.attributes")}</div> <div className="dock-title">{"Attributes"}</div>
<SupportAction id={account.ID} support={detail.Support} onDone={load} /> <SupportAction id={account.ID} support={detail.Support} onDone={load} />
<UsernameAction idKey="user_id" id={account.ID} path="/api/actions/set-account-username" current={account.Username} onDone={load} /> <UsernameAction idKey="user_id" id={account.ID} path="/api/actions/set-account-username" current={account.Username} onDone={load} />
<ColorAction idKey="user_id" id={account.ID} path="/api/actions/set-account-color" onDone={load} /> <ColorAction idKey="user_id" id={account.ID} path="/api/actions/set-account-color" onDone={load} />

View file

@ -0,0 +1,265 @@
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 { 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 [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 ? "Loading account rating…" : "Waiting for data"} />;
}
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={`Rating of ${displayUsername(rating.Username) || rating.FirstName || rating.UserID}`}
eyebrow={"Rating / Component breakdown"}
actions={
<>
<button className="btn icon-text" type="button" onClick={() => navigate("/account-ratings")}>
<ArrowLeft size={15} /> {"Back to list"}
</button>
<button className="btn icon-text" type="button" onClick={load} disabled={busy}>
<RefreshCw size={15} className={busy ? "spin" : ""} /> {"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 || "Unnamed bot"}</div>
<div className="entity-subtitle">{"User ID"}: {rating.UserID}</div>
</div>
<div className="entity-badges">
<LevelBadge level={rating.Level} />
{pending !== 0 && <Badge tone="warn">{`Pending ${formatSigned(rating.PendingStars)}`}</Badge>}
</div>
</section>
<div className="metric-row">
<Metric label={"Points"} value={formatQuantity(rating.Stars)} mono />
<Metric label={"Level"} value={String(rating.Level)} tone="good" />
<Metric
label={"Next level threshold"}
value={rating.HasNextLevel ? formatQuantity(rating.NextLevelStars) : "Max level reached"}
mono={rating.HasNextLevel}
/>
<Metric
label={"Points to next level"}
value={rating.HasNextLevel ? formatQuantity(String(progress.remaining)) : "-"}
mono
tone={rating.HasNextLevel && progress.percent >= 80 ? "good" : "neutral"}
/>
</div>
<section className="section-block">
<SectionHead title={"How the rating adds up"} text={"Contribution of every source: stars, activity, moderation penalties and manual corrections."} />
<Breakdown rating={rating} />
<div className="summary-grid">
<Summary label={"Current level threshold"} value={formatQuantity(rating.CurrentLevelStars)} mono />
<Summary
label={"Next level threshold"}
value={rating.HasNextLevel ? formatQuantity(rating.NextLevelStars) : "Max level reached"}
mono={rating.HasNextLevel}
/>
<Summary label={"Computed"} value={formatDate(rating.ComputedAt) || "-"} />
<Summary label={"Updated"} value={formatDate(rating.UpdatedAt) || "-"} />
</div>
<div className="progress-wide">
<RatingProgress row={rating} />
</div>
</section>
{pending !== 0 && (
<section className="section-block">
<SectionHead title={"Pending points"} text={"Already earned, but counted towards the rating only on the date below."} />
<div className="summary-grid">
<Summary label={"Pending"} value={formatSigned(rating.PendingStars)} mono />
<Summary label={"Applied on"} value={formatDate(rating.PendingDate) || "-"} />
</div>
</section>
)}
<section className="section-block">
<SectionHead title={"Rating events"} text={"Every rating change with its source, actor and reason."} />
<div className="table-wrap">
<table className="data-table">
<thead>
<tr>
<th>{"ID"}</th>
<th>{"Source"}</th>
<th>{"Change"}</th>
<th>{"Reason"}</th>
<th>{"Actor"}</th>
<th>{"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">{"Rating operations"}</div>
<button className="btn icon-text" type="button" onClick={() => navigate(`/accounts/${rating.UserID}`)}>
<User size={15} /> {"Open account"}
</button>
<div className="action-stack">
<ActionButton
label={"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">{"Rebuilds the rating from stars, activity, penalties and manual corrections."}</p>
<div className="dock-title">{"Manual correction"}</div>
<label className="duration-field">
<span>{"Value (negative allowed)"}</span>
<input
value={adjustment}
onChange={(event) => setAdjustment(event.target.value)}
type="number"
step="1"
placeholder="-500"
/>
</label>
<div className="action-stack">
<ActionButton
label={"Apply correction"}
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">{"The value is added to the manual component; a negative number lowers the rating."}</p>
</section>
}
/>
</PageFrame>
);
}
function Breakdown({ rating }: { rating: AccountRatingRow }) {
// 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: "Stars", hint: "Purchased and received stars", value: toNumeric(rating.StarsComponent) },
{ key: "activity", label: "Activity", hint: "Messages, sessions and long-term engagement", value: toNumeric(rating.ActivityComponent) },
{ key: "penalty", label: "Penalties", hint: "Moderation decisions and restrictions", value: -toNumeric(rating.PenaltyComponent) },
{ key: "manual", label: "Manual corrections", hint: "Adjustments made by admins", 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>{"Total rating"}</strong></div>
<div className="breakdown-value mono">{formatQuantity(rating.Stars)}</div>
</div>
</div>
{pending === 0 && sum !== total && (
<Alert>{`Components add up to ${formatQuantity(String(sum))} while the stored rating is ${formatQuantity(rating.Stars)}. Recompute to resolve the drift.`}</Alert>
)}
{pending !== 0 && <p className="bot-create-note">{`Components already include ${formatSigned(rating.PendingStars)} that reaches the score only on the date below.`}</p>}
</>
);
}
const ratingKindLabels: Record<AccountRatingEventKind, string> = {
stars: "Stars",
activity: "Activity",
moderation: "Moderation",
manual: "Manual",
recompute: "Recompute"
};
function EventKind({ kind }: { kind: AccountRatingEventKind }) {
const tone = kind === "moderation" ? "danger" : kind === "manual" ? "warn" : kind === "recompute" ? "neutral" : "good";
return <Badge tone={tone}>{ratingKindLabels[kind]}</Badge>;
}

View file

@ -0,0 +1,163 @@
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 { displayUsername, formatDate, formatQuantity, toNumeric } from "../lib/format";
import type { Navigate } from "../routing";
import type { AccountRatingRow } from "../types";
export function AccountRatingsPage({ navigate }: { navigate: Navigate }) {
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={"Account rating leaderboard"}
eyebrow={"Rating / Leaderboard"}
actions={
<button className="btn icon-text" type="button" onClick={() => load(false)} disabled={busy}>
<RefreshCw size={15} className={busy ? "spin" : ""} /> {"Refresh"}
</button>
}
>
{error && <Alert>{error}</Alert>}
<div className="metric-row">
<Metric label={"Loaded rows"} value={String(rows.length)} />
<Metric label={"Top level"} value={String(topLevel)} tone="good" />
<Metric label={"Average level"} value={avgLevel} />
<Metric label={"With pending points"} 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={"Search by username, name or user ID"} />
</label>
<label className="field-inline">
<span>{"Min level"}</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>{"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} />} {"Search"}
</button>
</form>
</QueryPanel>
<div className="table-wrap">
<table className="data-table">
<thead>
<tr>
<th>{"User ID"}</th>
<th>{"Username"}</th>
<th>{"Level"}</th>
<th>{"Points"}</th>
<th>{"Progress to next level"}</th>
<th>{"Pending"}</th>
<th>{"Computed"}</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} /> {"Details"} <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} />} {"Load more"}
</button>
</div>
)}
</PageFrame>
);
}
export function LevelBadge({ level }: { level: number }) {
const tone = level >= 10 ? "good" : level >= 5 ? "warn" : "neutral";
return <Badge tone={tone}>{`Level ${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 }) {
if (!row.HasNextLevel) {
return <span className="progress-note">{"Max level reached"}</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>{`${formatQuantity(String(remaining))} left to reach ${formatQuantity(String(target))}`}</small>
</div>
);
}

View file

@ -2,10 +2,9 @@ import { ChevronLeft, ChevronRight, Loader2, RefreshCw, Search } from "lucide-re
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { api, errorMessage } from "../api"; import { api, errorMessage } from "../api";
import { Avatar } from "../components/Avatar"; import { Avatar } from "../components/Avatar";
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 { ScamFakeBadges } from "../components/flags";
import { useI18n } from "../i18n"; import { displayName, displayPhone, formatDate, formatUnix } from "../lib/format";
import { displayName, displayPhone, displayUsername, formatDate, formatUnix } from "../lib/format";
import { accountMetrics } from "../lib/metrics"; import { accountMetrics } from "../lib/metrics";
import type { Navigate } from "../routing"; import type { Navigate } from "../routing";
import type { AccountListResponse, AccountStatsResponse } from "../types"; import type { AccountListResponse, AccountStatsResponse } from "../types";
@ -16,7 +15,6 @@ type AccountPageSize = 10 | 20 | 50 | 100;
const zeroCursor: Cursor = { beforeID: 0, beforeActiveUS: 0 }; const zeroCursor: Cursor = { beforeID: 0, beforeActiveUS: 0 };
export function AccountsPage({ navigate }: { navigate: Navigate }) { export function AccountsPage({ navigate }: { navigate: Navigate }) {
const { t } = useI18n();
const [q, setQ] = useState(""); const [q, setQ] = useState("");
const [limit, setLimit] = useState<AccountPageSize>(50); const [limit, setLimit] = useState<AccountPageSize>(50);
const [data, setData] = useState<AccountListResponse | null>(null); const [data, setData] = useState<AccountListResponse | null>(null);
@ -98,8 +96,8 @@ export function AccountsPage({ navigate }: { navigate: Navigate }) {
return ( return (
<PageFrame <PageFrame
title={t("account.pageTitle")} title={"Accounts"}
eyebrow={data?.listing === false ? t("account.queryResults") : t("account.recentActive")} eyebrow={data?.listing === false ? "Search results" : "Recently active accounts"}
actions={ actions={
<button <button
className="btn" className="btn"
@ -110,24 +108,24 @@ export function AccountsPage({ navigate }: { navigate: Navigate }) {
}} }}
disabled={busy} disabled={busy}
> >
<RefreshCw size={15} /> {t("common.refresh")} <RefreshCw size={15} /> {"Refresh"}
</button> </button>
} }
> >
{error && <Alert>{error}</Alert>} {error && <Alert>{error}</Alert>}
<div className="metric-row"> <div className="metric-row">
<Metric label={t("account.totalUsers")} value={stats ? String(stats.total) : "…"} /> <Metric label={"Total users"} value={stats ? String(stats.total) : "…"} />
<Metric label={t("account.onlineNow")} value={stats ? String(stats.online) : "…"} tone="good" /> <Metric label={"Online now"} value={stats ? String(stats.online) : "…"} tone="good" />
<Metric label={t("account.onlineDevices")} value={String(metrics.devices)} /> <Metric label={"Online device records"} value={String(metrics.devices)} />
</div> </div>
<QueryPanel> <QueryPanel>
<form className="toolbar" onSubmit={(event) => { event.preventDefault(); void loadFresh(); }}> <form className="toolbar" onSubmit={(event) => { event.preventDefault(); void loadFresh(); }}>
<label className="searchbox"> <label className="searchbox">
<Search size={15} /> <Search size={15} />
<input value={q} onChange={(event) => setQ(event.target.value)} placeholder={t("account.searchPlaceholder")} /> <input value={q} onChange={(event) => setQ(event.target.value)} placeholder={"User ID / phone / username"} />
</label> </label>
<label className="gift-page-size"> <label className="gift-page-size">
<span>{t("common.limit")}</span> <span>{"Limit"}</span>
<select value={String(limit)} onChange={(event) => setLimit(Number(event.target.value) as AccountPageSize)}> <select value={String(limit)} onChange={(event) => setLimit(Number(event.target.value) as AccountPageSize)}>
<option value="10">10</option> <option value="10">10</option>
<option value="20">20</option> <option value="20">20</option>
@ -136,13 +134,13 @@ export function AccountsPage({ navigate }: { navigate: Navigate }) {
</select> </select>
</label> </label>
<button className="btn primary icon-text" type="submit" disabled={busy}> <button className="btn primary icon-text" type="submit" disabled={busy}>
{busy ? <Loader2 size={15} className="spin" /> : <Search size={15} />} {t("common.search")} {busy ? <Loader2 size={15} className="spin" /> : <Search size={15} />} {"Search"}
</button> </button>
<button className="btn icon-text" type="button" onClick={() => void loadPrev()} disabled={!canGoPrev}> <button className="btn icon-text" type="button" onClick={() => void loadPrev()} disabled={!canGoPrev}>
<ChevronLeft size={15} /> {t("common.previous")} <ChevronLeft size={15} /> {"Previous page"}
</button> </button>
<button className="btn icon-text" type="button" onClick={() => void loadNext()} disabled={!canGoNext}> <button className="btn icon-text" type="button" onClick={() => void loadNext()} disabled={!canGoNext}>
<ChevronRight size={15} /> {t("common.next")} <ChevronRight size={15} /> {"Next page"}
</button> </button>
</form> </form>
</QueryPanel> </QueryPanel>
@ -151,17 +149,17 @@ export function AccountsPage({ navigate }: { navigate: Navigate }) {
<thead> <thead>
<tr> <tr>
<th className="avatar-col"></th> <th className="avatar-col"></th>
<th>{t("account.userID")}</th> <th>{"User ID"}</th>
<th>{t("account.phone")}</th> <th>{"Phone"}</th>
<th>{t("common.username")}</th> <th>{"Username"}</th>
<th>{t("common.name")}</th> <th>{"Name"}</th>
<th>{t("account.loginEmail")}</th> <th>{"Login email"}</th>
<th>{t("common.device")}</th> <th>{"Device"}</th>
<th>{t("account.lastActive")}</th> <th>{"Last active"}</th>
<th>{t("account.premium")}</th> <th>{"Premium"}</th>
<th>{t("common.verified")}</th> <th>{"Verified"}</th>
<th>{t("account.frozen")}</th> <th>{"Frozen"}</th>
<th>{t("common.updatedAt")}</th> <th>{"Updated"}</th>
<th></th> <th></th>
</tr> </tr>
</thead> </thead>
@ -171,16 +169,16 @@ export function AccountsPage({ navigate }: { navigate: Navigate }) {
<td className="avatar-col"><Avatar userID={row.ID} firstName={row.FirstName} lastName={row.LastName} username={row.Username} /></td> <td className="avatar-col"><Avatar userID={row.ID} firstName={row.FirstName} lastName={row.LastName} username={row.Username} /></td>
<td className="mono">{row.ID}</td> <td className="mono">{row.ID}</td>
<td>{displayPhone(row.Phone)}</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>{displayName(row)}</td>
<td>{row.LoginEmail || <span className="muted-cell">{t("common.none")}</span>}</td> <td>{row.LoginEmail || <span className="muted-cell">{"None"}</span>}</td>
<td>{row.DeviceCount}</td> <td>{row.DeviceCount}</td>
<td>{formatDate(row.LastActiveAt)}</td> <td>{formatDate(row.LastActiveAt)}</td>
<td>{row.PremiumUntil > 0 ? <Badge tone="good">{t("account.premium")} {formatUnix(row.PremiumUntil)}</Badge> : <Badge>{t("common.none")}</Badge>}</td> <td>{row.PremiumUntil > 0 ? <Badge tone="good">{"Premium"} {formatUnix(row.PremiumUntil)}</Badge> : <Badge>{"None"}</Badge>}</td>
<td>{row.Verified ? <Badge tone="good">{t("common.verified")}</Badge> : <Badge>{t("account.notVerified")}</Badge>} <ScamFakeBadges scam={row.Scam} fake={row.Fake} /></td> <td>{row.Verified ? <Badge tone="good">{"Verified"}</Badge> : <Badge>{"Not verified"}</Badge>} <ScamFakeBadges scam={row.Scam} fake={row.Fake} /></td>
<td>{row.Frozen ? <Badge tone="danger">{t("account.frozen")}</Badge> : <Badge>{t("common.normal")}</Badge>}</td> <td>{row.Frozen ? <Badge tone="danger">{"Frozen"}</Badge> : <Badge>{"Normal"}</Badge>}</td>
<td>{formatDate(row.UpdatedAt)}</td> <td>{formatDate(row.UpdatedAt)}</td>
<td><button className="row-link" onClick={() => navigate(`/accounts/${row.ID}`)}>{t("common.detail")} <ChevronRight size={14} /></button></td> <td><button className="row-link" onClick={() => navigate(`/accounts/${row.ID}`)}>{"Details"} <ChevronRight size={14} /></button></td>
</tr> </tr>
))} ))}
{(!data || data.rows.length === 0) && <EmptyRow colSpan={12} />} {(!data || data.rows.length === 0) && <EmptyRow colSpan={12} />}

View file

@ -5,13 +5,11 @@ import { ActionButton } from "../components/ActionButton";
import { Alert, AuditTable, Badge, LoadingSurface, PageFrame, SectionHead, SplitLayout, Summary } from "../components/ui"; import { Alert, AuditTable, Badge, LoadingSurface, PageFrame, SectionHead, SplitLayout, Summary } from "../components/ui";
import { ScamFakeActions, ScamFakeBadges } from "../components/flags"; import { ScamFakeActions, ScamFakeBadges } from "../components/flags";
import { ColorAction, EmojiStatusAction, UsernameAction } from "../components/attributes"; import { ColorAction, EmojiStatusAction, UsernameAction } from "../components/attributes";
import { useI18n } from "../i18n";
import { displayUsername, formatDate } from "../lib/format"; import { displayUsername, formatDate } from "../lib/format";
import type { Navigate } from "../routing"; import type { Navigate } from "../routing";
import type { BotDetail } from "../types"; import type { BotDetail } from "../types";
export function BotDetailPage({ id, navigate }: { id: number; navigate: Navigate }) { export function BotDetailPage({ id, navigate }: { id: number; navigate: Navigate }) {
const { t } = useI18n();
const [detail, setDetail] = useState<BotDetail | null>(null); const [detail, setDetail] = useState<BotDetail | null>(null);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
@ -36,51 +34,51 @@ export function BotDetailPage({ id, navigate }: { id: number; navigate: Navigate
return <Alert>{error}</Alert>; return <Alert>{error}</Alert>;
} }
if (!detail) { if (!detail) {
return <LoadingSurface label={busy ? t("bots.loadingDetail") : t("account.waitingData")} />; return <LoadingSurface label={busy ? "Loading bot detail" : "Waiting for data"} />;
} }
const bot = detail.Bot; const bot = detail.Bot;
return ( return (
<PageFrame <PageFrame
title={t("bots.detailTitle", { id: bot.ID })} title={`Bot #${bot.ID}`}
eyebrow={t("bots.profile")} eyebrow={"Bot Profile"}
actions={<button className="btn icon-text" onClick={() => navigate("/bots")}><ArrowLeft size={15} /> {t("common.backToList")}</button>} actions={<button className="btn icon-text" onClick={() => navigate("/bots")}><ArrowLeft size={15} /> {"Back to list"}</button>}
> >
<SplitLayout <SplitLayout
main={ main={
<div className="stacked-sections"> <div className="stacked-sections">
<section className="entity-head"> <section className="entity-head">
<div> <div>
<div className="entity-title">{bot.FirstName || t("bots.unnamed")}</div> <div className="entity-title">{bot.FirstName || "Unnamed bot"}</div>
<div className="entity-subtitle">{displayUsername(bot.Username) || t("account.noUsername")}</div> <div className="entity-subtitle">{displayUsername(bot.Username) || "No username"}</div>
</div> </div>
<div className="entity-badges"> <div className="entity-badges">
<Badge tone={bot.System ? "warn" : "neutral"}>{bot.System ? t("bots.system") : t("bots.user")}</Badge> <Badge tone={bot.System ? "warn" : "neutral"}>{bot.System ? "System" : "User"}</Badge>
{bot.Verified ? <Badge tone="good">{t("common.verified")}</Badge> : <Badge>{t("account.notVerified")}</Badge>} {bot.Verified ? <Badge tone="good">{"Verified"}</Badge> : <Badge>{"Not verified"}</Badge>}
<ScamFakeBadges scam={bot.Scam} fake={bot.Fake} /> <ScamFakeBadges scam={bot.Scam} fake={bot.Fake} />
</div> </div>
</section> </section>
<div className="summary-grid"> <div className="summary-grid">
<Summary label={t("bots.botID")} value={String(bot.ID)} mono /> <Summary label={"Bot ID"} value={String(bot.ID)} mono />
<Summary label={t("bots.owner")} value={bot.OwnerUserID > 0 ? `${bot.OwnerUserID} ${displayUsername(detail.OwnerUsername)}`.trim() : t("common.none")} /> <Summary label={"Owner"} value={bot.OwnerUserID > 0 ? `${bot.OwnerUserID} ${displayUsername(detail.OwnerUsername)}`.trim() : "None"} />
<Summary label={t("bots.type")} value={bot.System ? t("bots.system") : t("bots.user")} /> <Summary label={"Type"} value={bot.System ? "System" : "User"} />
<Summary label={t("common.updatedAt")} value={formatDate(bot.UpdatedAt) || "-"} /> <Summary label={"Updated"} value={formatDate(bot.UpdatedAt) || "-"} />
<Summary label={t("account.createdAt")} value={formatDate(bot.CreatedAt) || "-"} /> <Summary label={"Created"} value={formatDate(bot.CreatedAt) || "-"} />
</div> </div>
{detail.About && <p className="about-text">{detail.About}</p>} {detail.About && <p className="about-text">{detail.About}</p>}
{detail.Description && detail.Description.trim() !== detail.About.trim() && <p className="about-text">{detail.Description}</p>} {detail.Description && detail.Description.trim() !== detail.About.trim() && <p className="about-text">{detail.Description}</p>}
<section className="section-block"> <section className="section-block">
<SectionHead title={t("account.recentAdminOps")} text={t("account.recent30Audit")} /> <SectionHead title={"Recent Admin Actions"} text={"Last 30 audit rows"} />
<AuditTable rows={detail.AuditLogs} /> <AuditTable rows={detail.AuditLogs} />
</section> </section>
</div> </div>
} }
side={ side={
<section className="action-dock"> <section className="action-dock">
<div className="dock-title">{t("bots.actionDock")}</div> <div className="dock-title">{"Bot Actions"}</div>
<div className="action-stack"> <div className="action-stack">
<ActionButton <ActionButton
label={bot.Verified ? t("account.clearVerified") : t("account.setVerified")} label={bot.Verified ? "Clear verified" : "Set verified"}
icon={<BadgeCheck size={15} />} icon={<BadgeCheck size={15} />}
tone="neutral" tone="neutral"
path="/api/actions/set-verified" path="/api/actions/set-verified"
@ -89,23 +87,23 @@ export function BotDetailPage({ id, navigate }: { id: number; navigate: Navigate
/> />
</div> </div>
<ScamFakeActions idKey="user_id" id={bot.ID} path="/api/actions/set-account-flags" scam={bot.Scam} fake={bot.Fake} onDone={load} /> <ScamFakeActions idKey="user_id" id={bot.ID} path="/api/actions/set-account-flags" scam={bot.Scam} fake={bot.Fake} onDone={load} />
<div className="dock-title">{t("attr.attributes")}</div> <div className="dock-title">{"Attributes"}</div>
<UsernameAction idKey="user_id" id={bot.ID} path="/api/actions/set-account-username" current={bot.Username} onDone={load} /> <UsernameAction idKey="user_id" id={bot.ID} path="/api/actions/set-account-username" current={bot.Username} onDone={load} />
<ColorAction idKey="user_id" id={bot.ID} path="/api/actions/set-account-color" onDone={load} /> <ColorAction idKey="user_id" id={bot.ID} path="/api/actions/set-account-color" onDone={load} />
<EmojiStatusAction idKey="user_id" id={bot.ID} path="/api/actions/set-account-emoji-status" onDone={load} /> <EmojiStatusAction idKey="user_id" id={bot.ID} path="/api/actions/set-account-emoji-status" onDone={load} />
{bot.System ? ( {bot.System ? (
<p className="bot-create-note">{t("bots.systemHint")}</p> <p className="bot-create-note">{"System bots are built in and cannot be deleted."}</p>
) : ( ) : (
<div className="danger-zone"> <div className="danger-zone">
<ActionButton <ActionButton
label={t("bots.delete")} label={"Delete bot"}
icon={<Trash2 size={15} />} icon={<Trash2 size={15} />}
tone="danger" tone="danger"
path="/api/actions/delete-bot" path="/api/actions/delete-bot"
payload={() => ({ bot_user_id: bot.ID })} payload={() => ({ bot_user_id: bot.ID })}
onDone={() => navigate("/bots")} onDone={() => navigate("/bots")}
/> />
<p className="bot-create-note">{t("bots.deleteHint")}</p> <p className="bot-create-note">{"Permanently deletes this user-created bot and invalidates its token. This cannot be undone."}</p>
</div> </div>
)} )}
</section> </section>

View file

@ -0,0 +1,966 @@
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 { 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"];
export const statusLabels: Record<CustomVerificationRequestStatus, string> = {
pending: "Pending",
approved: "Approved",
rejected: "Rejected",
revoked: "Mark revoked"
};
export const peerTypeLabels: Record<BotVerificationPeerType, string> = {
user: "Account",
channel: "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 { 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: "Applications", icon: <Stamp size={15} /> },
{ key: "verifiers", label: "Verifiers", icon: <Building2 size={15} /> },
{ key: "icons", label: "Icon catalogue", icon: <Sticker size={15} /> },
{ key: "marks", label: "Granted marks", icon: <BadgeCheck size={15} /> }
];
return (
<PageFrame
title={"Third-party verification"}
eyebrow={"Third-party verification / Verifiers, icons, marks"}
actions={
canSeeOfficial ? (
<button className="btn icon-text" type="button" onClick={() => navigate("/verification")}>
<ExternalLink size={15} /> {"Official verification"}
</button>
) : undefined
}
>
{error && <Alert>{error}</Alert>}
{rosterDenied && <Alert>{"The server refused the verifier roster and the icon catalogue for this session (403), so both lists are empty here — applications can still be reviewed."}</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={"A verifier company's icon — not the official checkmark"} text={"A third-party mark is a verifier bot's own icon, drawn right BEFORE the name of an account, a bot or a channel, plus one line of description in the profile. It says “this verifier vouches for this peer”, and nothing more."} />
<p className="bot-create-note">{"The icon is a custom emoji document. The client fetches it through messages.getCustomEmojiDocuments, so a document id that resolves to nothing renders as no badge at all — which is why marks are granted from the catalogue below rather than from a typed number."}</p>
<p className="bot-create-note">{"The official checkmark is a different mechanism, granted by the platform in the Verification section. The two are stored, shown and taken away separately, and neither one implies the other."}</p>
{!canManage && <p className="bot-create-note">{"This session can read the section and decide applications, but not change verifiers or the icon catalogue — that needs the botverification.manage permission."}</p>}
</section>
<div className="toolbar" role="group" aria-label={"Third-party verification"}>
{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 [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={"Application queue"}
text={"Applications filed with a verifier bot by the owner of the peer. The counters cover the whole queue, not the page below."}
action={
<button className="btn icon-text" type="button" onClick={refresh} disabled={busy}>
<RefreshCw size={15} className={busy ? "spin" : ""} /> {"Refresh"}
</button>
}
/>
{error && <Alert>{error}</Alert>}
<div className="metric-row">
{statuses.map((item) => (
<Metric
key={item}
label={statusLabels[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={"Application id, peer id, username or title"} />
</label>
<label className="field-inline">
<span>{"Status"}</span>
<select value={status} onChange={(event) => setStatus(event.target.value as StatusFilter)}>
<option value="all">{"All statuses"}</option>
{statuses.map((item) => (
<option key={item} value={item}>{statusLabels[item]}</option>
))}
</select>
</label>
<label className="field-inline">
<span>{"Verifier"}</span>
<VerifierOptions value={verifierBotID} verifiers={verifiers} onChange={setVerifierBotID} />
</label>
<label className="field-inline">
<span>{"Peer type"}</span>
<select value={peerType} onChange={(event) => setPeerType(event.target.value as PeerTypeFilter)}>
<option value="all">{"All types"}</option>
{peerTypes.map((item) => (
<option key={item} value={item}>{peerTypeLabels[item]}</option>
))}
</select>
</label>
<label className="field-inline">
<span>{"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} />} {"Search"}
</button>
</form>
</QueryPanel>
<div className="table-wrap">
<table className="data-table">
<thead>
<tr>
<th>{"ID"}</th>
<th>{"Verifier"}</th>
<th>{"Peer"}</th>
<th>{"Applicant"}</th>
<th>{"Stated reason"}</th>
<th>{"Status"}</th>
<th>{"Filed"}</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">
{peerTypeLabels[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} /> {"Details"} <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} />} {"Load more"}
</button>
</div>
)}
</>
);
}
// ---------------------------------------------------------------------------
// Verifiers
// ---------------------------------------------------------------------------
function VerifiersBlock({
verifiers,
icons,
canManage,
onChanged,
navigate
}: {
verifiers: BotVerifierRow[];
icons: VerificationIconRow[];
canManage: boolean;
onChanged: () => void;
navigate: Navigate;
}) {
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} (${"Retired"})`
});
}
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 ? "Update verifier" : "Grant verifier status"}
text={"The bot gets an icon from the catalogue and a company name to vouch under. The same call updates an existing verifier, which is why it carries a version."}
action={
editing ? (
<button className="btn icon-text" type="button" onClick={resetForm}>
{"Cancel update"}
</button>
) : undefined
}
/>
{editing ? (
<p className="bot-create-note">
{`Updating ${displayUsername(editing.BotUsername) || editing.BotID} — version ${editing.Version} is sent as the optimistic lock, so a row somebody else changed meanwhile is refused instead of overwritten.`}
</p>
) : (
<BotPicker label={"Bot"} value={bot} onChange={setBot} />
)}
<div className="bot-create-fields">
<label className="duration-field">
<span>{"Icon from the catalogue"}</span>
<select value={iconDocumentID} onChange={(event) => setIconDocumentID(event.target.value)}>
<option value="">{"Pick an icon"}</option>
{iconOptions.map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
</label>
<label className="duration-field">
<span>{"Company"}</span>
<input
value={company}
onChange={(event) => setCompany(event.target.value)}
placeholder={"Acme Verification Ltd"}
/>
</label>
<label className="duration-field">
<span>{"Default description"}</span>
<input
value={defaultDescription}
onChange={(event) => setDefaultDescription(event.target.value)}
placeholder={"Verified by Acme"}
/>
</label>
</div>
<label className="checkline">
<input type="checkbox" checked={canModify} onChange={(event) => setCanModify(event.target.checked)} />
{"The verifier may replace the description per peer"}
</label>
<p className="bot-create-note">{"This is botVerifierSettings.can_modify_custom_description: with it off, every mark this verifier grants carries the default description above, whatever the applicant asked for."}</p>
{activeIcons.length === 0 && <Alert>{"The catalogue has no active icon, so there is nothing to grant. Add one in the icon catalogue first."}</Alert>}
<div className="bot-create-actions">
<span className="bot-create-note">{"The bot can mark peers as soon as the row exists and is enabled."}</span>
<ActionButton
label={editing ? "Update verifier" : "Grant verifier status"}
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={"Verifier bots"}
text={"Bots allowed to hand out their own mark. Verifier status is granted per deployment, so every row here is a badge printer an operator switched on by hand."}
action={
<button className="btn icon-text" type="button" onClick={onChanged}>
<RefreshCw size={15} /> {"Refresh"}
</button>
}
/>
<div className="table-wrap">
<table className="data-table">
<thead>
<tr>
<th>{"Bot"}</th>
<th>{"Company"}</th>
<th>{"Icon"}</th>
<th>{"Own description"}</th>
<th>{"Status"}</th>
<th>{"Marks"}</th>
<th>{"Granted by"}</th>
<th>{"Updated"}</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 || "Not set"}</div>
</td>
<td>
{row.IconName || "-"}
<div className="entity-subtitle mono">{row.IconDocumentID}</div>
</td>
<td>{row.CanModifyCustomDescription ? "Yes" : "No"}</td>
<td>
{row.Enabled
? <Badge tone="good">{"Enabled"}</Badge>
: <Badge tone="warn">{"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)}>
{"Edit"}
</button>
<ActionButton
label={row.Enabled ? "Disable" : "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={"Revoke status"}
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">{"Disabling is the per-verifier kill switch: the marks already granted keep rendering, but the bot can no longer mark anything new and its settings stop being projected into botInfo."}</p>
<p className="bot-create-note">{"Revoking verifier status removes the row and every mark this verifier granted — the icon disappears from all of its peers at once."}</p>
</section>
</>
);
}
// ---------------------------------------------------------------------------
// Icon catalogue
// ---------------------------------------------------------------------------
function IconsBlock({
icons,
verifiers,
canManage,
onChanged
}: {
icons: VerificationIconRow[];
verifiers: BotVerifierRow[];
canManage: boolean;
onChanged: () => void;
}) {
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={"Add or rename an icon"} text={"The document id has to name a real custom emoji document on this deployment; the Emoji section lists them with their ids. Adding an id that already exists renames it instead of duplicating it."} />
<div className="bot-create-fields">
<label className="duration-field">
<span>{"Document ID"}</span>
<input
value={documentID}
onChange={(event) => setDocumentID(event.target.value)}
inputMode="numeric"
placeholder="5361371319611781774"
/>
</label>
<label className="duration-field">
<span>{"Name"}</span>
<input
value={name}
onChange={(event) => setName(event.target.value)}
placeholder={"Acme blue tick"}
/>
</label>
<label className="duration-field">
<span>{"Owner"}</span>
<select value={ownerBotID} onChange={(event) => setOwnerBotID(event.target.value)}>
<option value="">{"Shared"}</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">{"A document id that resolves to nothing produces an invisible badge: the peer is marked in the database and the client draws nothing."}</p>
<p className="bot-create-note">{"A shared icon may be granted to any verifier; picking an owner reserves it for that one bot."}</p>
<div className="bot-create-actions">
<span className="bot-create-note">{"Adding an icon grants nothing by itself — it only makes the document available to grant."}</span>
<ActionButton
label={"Save icon"}
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={"Icon catalogue"}
text={"The custom emoji documents a verifier may mark with. Nothing else can be used as an icon, so the catalogue is where a wrong badge is prevented rather than fixed."}
action={
<button className="btn icon-text" type="button" onClick={onChanged}>
<RefreshCw size={15} /> {"Refresh"}
</button>
}
/>
<div className="table-wrap">
<table className="data-table">
<thead>
<tr>
<th>{"Document ID"}</th>
<th>{"Name"}</th>
<th>{"Owner"}</th>
<th>{"Status"}</th>
<th>{"Verifiers using it"}</th>
<th>{"Filed"}</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>{"Shared"}</Badge>}
</td>
<td>
{row.Active
? <Badge tone="good">{"Active"}</Badge>
: <Badge tone="warn">{"Retired"}</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 ? "Retire" : "Activate"}
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">{"Retiring an icon stops it from being granted to anybody new. Marks already carrying it keep it: the icon is copied onto the mark when it is granted."}</p>
</section>
</>
);
}
// ---------------------------------------------------------------------------
// Granted marks
// ---------------------------------------------------------------------------
function MarksBlock({
verifiers,
canManage,
navigate
}: {
verifiers: BotVerifierRow[];
canManage: boolean;
navigate: Navigate;
}) {
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={"Granted marks"}
text={"Every peer currently carrying a third-party mark, whoever granted it — an operator decision, the verifier bot itself, or the peer's owner through bots.setCustomVerification."}
action={
<button className="btn icon-text" type="button" onClick={() => load(false)} disabled={busy}>
<RefreshCw size={15} className={busy ? "spin" : ""} /> {"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={"Peer id, username or title"} />
</label>
<label className="field-inline">
<span>{"Verifier"}</span>
<VerifierOptions value={verifierBotID} verifiers={verifiers} onChange={setVerifierBotID} />
</label>
<label className="field-inline">
<span>{"Peer type"}</span>
<select value={peerType} onChange={(event) => setPeerType(event.target.value as PeerTypeFilter)}>
<option value="all">{"All types"}</option>
{peerTypes.map((item) => (
<option key={item} value={item}>{peerTypeLabels[item]}</option>
))}
</select>
</label>
<label className="field-inline">
<span>{"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} />} {"Search"}
</button>
</form>
</QueryPanel>
<div className="table-wrap">
<table className="data-table">
<thead>
<tr>
<th>{"ID"}</th>
<th>{"Verifier"}</th>
<th>{"Peer"}</th>
<th>{"Description"}</th>
<th>{"Icon"}</th>
<th>{"Filed"}</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">
{peerTypeLabels[row.PeerType]} · {row.PeerID}
</div>
</td>
<td className="truncate">{row.Description || "Not set"}</td>
<td className="mono">{row.IconDocumentID}</td>
<td>{formatDate(row.CreatedAt) || "-"}</td>
{canManage && (
<td>
<div className="row-actions">
<ActionButton
label={"Remove mark"}
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">{"Removing a mark clears the icon and the description from the peer. The application it came from keeps its history."}</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} />} {"Load more"}
</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;
}) {
return (
<select value={value} onChange={(event) => onChange(event.target.value)}>
<option value="">{"All verifiers"}</option>
{verifiers.map((row) => (
<option key={row.BotID} value={row.BotID}>
{`${row.CompanyName || row.BotID} · ${displayUsername(row.BotUsername) || row.BotID}`
+ (row.Enabled ? "" : ` (${"disabled"})`)}
</option>
))}
</select>
);
}
export function RequestStatusBadge({ status }: { status: CustomVerificationRequestStatus }) {
return <Badge tone={statusTone(status)}>{statusLabels[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,358 @@
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 { displayUsername, formatDate } from "../lib/format";
import type { Navigate } from "../routing";
import type { BotVerifierRow, CustomVerificationRequestDetail } from "../types";
import { RequestStatusBadge, peerHref, peerLabel, peerTypeLabels, statusLabels } from "./BotVerificationPage";
export function BotVerificationRequestPage({ id, navigate }: { id: string; navigate: Navigate }) {
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 "Another admin has already changed this application. The data has been reloaded — check the status before deciding again.";
}
return undefined;
}
if (error && !detail) {
return <Alert>{error}</Alert>;
}
if (!detail) {
return <LoadingSurface label={"Loading the application…"} />;
}
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={`Application #${request.ID}`}
eyebrow={"Third-party verification / Review"}
actions={
<>
<button className="btn icon-text" type="button" onClick={() => navigate("/bot-verification")}>
<ArrowLeft size={15} /> {"Back to list"}
</button>
<button className="btn icon-text" type="button" onClick={refresh} disabled={busy}>
<RefreshCw size={15} className={busy ? "spin" : ""} /> {"Refresh"}
</button>
</>
}
>
{error && <Alert>{error}</Alert>}
{conflict && <Alert>{"Another admin has already changed this application. The data has been reloaded — check the status before deciding again."}</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} · {peerTypeLabels[request.PeerType]}:{request.PeerID} · v{request.Version}
</div>
</div>
<div className="entity-badges">
<RequestStatusBadge status={request.Status} />
{markActive
? <Badge tone="good"><BadgeCheck size={12} /> {"Mark is live"}</Badge>
: <Badge tone="neutral">{"No mark on the peer"}</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={"A verifier company's icon — not the official checkmark"} text={"A third-party mark is a verifier bot's own icon, drawn right BEFORE the name of an account, a bot or a channel, plus one line of description in the profile. It says “this verifier vouches for this peer”, and nothing more."} />
<p className="bot-create-note">{"The icon is a custom emoji document. The client fetches it through messages.getCustomEmojiDocuments, so a document id that resolves to nothing renders as no badge at all — which is why marks are granted from the catalogue below rather than from a typed number."}</p>
</section>
<section className="section-block">
<SectionHead
title={"Verifier"}
text={"The company whose icon the peer would carry, as its row stands right now."}
action={
<button className="btn icon-text" type="button" onClick={() => navigate(`/bots/${request.VerifierBotID}`)}>
<Building2 size={15} /> {"Open verifier bot"}
</button>
}
/>
<div className="summary-grid">
<Summary label={"Company"} value={verifier?.CompanyName || "-"} />
<Summary label={"Bot"} value={displayUsername(request.VerifierBotUsername) || "-"} />
<Summary label={"Verifier bot ID"} value={request.VerifierBotID} mono />
<Summary label={"Document ID"} value={verifier?.IconDocumentID || "-"} mono />
<Summary label={"Name"} value={verifier?.IconName || "-"} />
<Summary
label={"Own description"}
value={verifier?.CanModifyCustomDescription ? "Yes" : "No"}
/>
</div>
<FieldBlock label={"Default description"}>
{verifier?.DefaultDescription
? <p className="about-text">{verifier.DefaultDescription}</p>
: <p className="bot-create-note">{"Not set"}</p>}
</FieldBlock>
{!verifier && <Alert>{"The verifier row is gone: its status was revoked after this application was filed. There is no icon to grant, so the application can only be rejected."}</Alert>}
{verifier && !verifier.Enabled && <Alert>{"This verifier is disabled. It cannot mark anything new until an operator enables it again."}</Alert>}
</section>
<section className="section-block">
<SectionHead
title={"Peer"}
text={"The account, bot or channel the icon would be attached to."}
action={
<button
className="btn icon-text"
type="button"
onClick={() => navigate(peerHref(request.PeerType, request.PeerID))}
>
<ExternalLink size={15} /> {"Open peer"}
</button>
}
/>
<div className="summary-grid">
<Summary label={"Type"} value={peerTypeLabels[request.PeerType]} />
<Summary label={"Username"} value={displayUsername(request.PeerUsername) || "-"} />
<Summary label={"Title"} value={request.PeerTitle || "-"} />
<Summary label={"Peer ID"} value={request.PeerID} mono />
</div>
</section>
<section className="section-block">
<SectionHead
title={"Applicant"}
text={"Who filed the application with the verifier bot."}
action={
<button
className="btn icon-text"
type="button"
onClick={() => navigate(`/accounts/${request.ApplicantUserID}`)}
>
<User size={15} /> {"Open account"}
</button>
}
/>
<div className="summary-grid">
<Summary label={"Username"} value={displayUsername(request.ApplicantUsername) || "-"} />
<Summary label={"User ID"} value={request.ApplicantUserID} mono />
<Summary label={"Filed"} value={formatDate(request.CreatedAt) || "-"} />
<Summary label={"Updated"} value={formatDate(request.UpdatedAt) || "-"} />
</div>
</section>
<section className="section-block">
<SectionHead title={"Application"} text={"What the applicant wrote, rendered as plain text."} />
<div className="stacked-sections">
<div className="summary-grid">
<Summary label={"Correlation ID"} value={request.CorrelationID || "-"} mono />
<Summary label={"Status"} value={statusLabels[request.Status]} />
</div>
<FieldBlock label={"Stated reason"}>
{request.Reason
? <p className="about-text">{request.Reason}</p>
: <p className="bot-create-note">{"Not set"}</p>}
</FieldBlock>
<FieldBlock label={"Requested description"}>
{requestedDescription
? <p className="about-text">{requestedDescription}</p>
: <p className="bot-create-note">{"Not set"}</p>}
</FieldBlock>
<FieldBlock label={"Description the mark would carry"}>
{effectiveDescription
? <p className="about-text">{effectiveDescription}</p>
: <p className="bot-create-note">{"Not set"}</p>}
</FieldBlock>
<p className="bot-create-note">{"Resolved the same way the backend resolves it: the applicant's wording only when this verifier may set its own description, otherwise the verifier's default."}</p>
{requestedDescription !== "" && !descriptionAllowed && (
<p className="bot-create-note">{"This verifier may not set a per-peer description, so the requested wording is ignored and the default is applied."}</p>
)}
</div>
</section>
<section className="section-block">
<SectionHead title={"Decision"} text={"What was decided, by whom, and with which wording."} />
<div className="stacked-sections">
<div className="summary-grid">
<Summary label={"Decided by"} value={request.DecidedBy || "-"} />
<Summary label={"Approved"} value={formatDate(request.ApprovedAt) || "-"} />
<Summary label={"Rejected"} value={formatDate(request.RejectedAt) || "-"} />
<Summary label={"Version (optimistic lock)"} value={request.Version} mono />
</div>
<FieldBlock label={"Decision reason"}>
{request.DecisionReason
? <p className="about-text">{request.DecisionReason}</p>
: <p className="bot-create-note">{"No decision yet"}</p>}
</FieldBlock>
{/* The internal note is the operator handover text and is labelled as
admin-only wherever it appears. */}
<FieldBlock label={`${"Internal note"} · ${"admins only"}`}>
{request.InternalNote
? <p className="about-text">{request.InternalNote}</p>
: <p className="bot-create-note">{"Not set"}</p>}
</FieldBlock>
</div>
</section>
</div>
}
side={
<section className="action-dock">
<div className="dock-title"><Stamp size={14} /> {"Decision"}</div>
{!canDecide && !canRevoke && <p className="bot-create-note">{"This status has no available actions."}</p>}
{(canDecide || canRevoke) && (
<>
<label className="duration-field">
<span>{"Internal note"}</span>
<textarea
value={note}
onChange={(event) => setNote(event.target.value)}
rows={3}
placeholder={"Handover note for other admins"}
/>
</label>
<p className="bot-create-note">{"Optional. Stored with the decision and visible to admins only — never sent to the applicant."}</p>
</>
)}
{canDecide && (
<>
{!verifier && <Alert>{"The verifier row is gone: its status was revoked after this application was filed. There is no icon to grant, so the application can only be rejected."}</Alert>}
{verifier && !verifier.Enabled && <Alert>{"This verifier is disabled. It cannot mark anything new until an operator enables it again."}</Alert>}
{markActive && <p className="bot-create-note">{"This peer already carries this verifier's mark; approving refreshes the description and records the decision."}</p>}
<div className="action-stack">
<ActionButton
label={"Approve"}
icon={<CheckCircle2 size={15} />}
tone="neutral"
path={`/api/botverification/requests/${request.ID}/approve`}
payload={decisionPayload}
onDone={afterDecision}
onError={handleActionError}
/>
<ActionButton
label={"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">{"Puts the verifier's icon before the peer's name and its description in the profile, and messages the applicant."}</p>
<p className="bot-create-note">{"The reason is mandatory: it is the wording the applicant is told, so write what exactly was missing."}</p>
</>
)}
{canRevoke && (
<>
<div className="dock-title"><ShieldOff size={14} /> {"Danger zone"}</div>
<div className="danger-zone">
<ActionButton
label={"Revoke mark"}
icon={<Ban size={15} />}
tone="danger"
path={`/api/botverification/requests/${request.ID}/revoke`}
payload={decisionPayload}
onDone={afterDecision}
onError={handleActionError}
/>
<p className="bot-create-note">{"Takes the icon and the description off the peer and closes the application as revoked. The official checkmark, if the peer has one, is untouched."}</p>
{!markActive && <p className="bot-create-note">{"The peer carries no mark right now — revoking only closes the application."}</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

@ -4,13 +4,11 @@ import { api, errorMessage } from "../api";
import { ActionButton } from "../components/ActionButton"; import { ActionButton } from "../components/ActionButton";
import { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel } from "../components/ui"; import { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel } from "../components/ui";
import { ScamFakeBadges } from "../components/flags"; import { ScamFakeBadges } from "../components/flags";
import { useI18n } from "../i18n";
import { displayUsername, formatDate, toInt } from "../lib/format"; import { displayUsername, formatDate, toInt } from "../lib/format";
import type { Navigate } from "../routing"; import type { Navigate } from "../routing";
import type { BotListResponse } from "../types"; import type { BotListResponse } from "../types";
export function BotsPage({ navigate }: { navigate: Navigate }) { export function BotsPage({ navigate }: { navigate: Navigate }) {
const { t } = useI18n();
const [q, setQ] = useState(""); const [q, setQ] = useState("");
const [limit, setLimit] = useState("50"); const [limit, setLimit] = useState("50");
const [data, setData] = useState<BotListResponse | null>(null); const [data, setData] = useState<BotListResponse | null>(null);
@ -52,31 +50,31 @@ export function BotsPage({ navigate }: { navigate: Navigate }) {
return ( return (
<PageFrame <PageFrame
title={t("bots.pageTitle")} title={"Bots"}
eyebrow={data?.listing === false ? t("bots.queryResults") : t("bots.recent")} eyebrow={data?.listing === false ? "Search results" : "Recently created bots"}
actions={ actions={
<button className="btn" type="button" onClick={() => load(false)} disabled={busy}> <button className="btn" type="button" onClick={() => load(false)} disabled={busy}>
<RefreshCw size={15} /> {t("common.refresh")} <RefreshCw size={15} /> {"Refresh"}
</button> </button>
} }
> >
{error && <Alert>{error}</Alert>} {error && <Alert>{error}</Alert>}
<div className="metric-row"> <div className="metric-row">
<Metric label={t("bots.currentPage")} value={String(rows.length)} /> <Metric label={"Bots on page"} value={String(rows.length)} />
<Metric label={t("common.verified")} value={String(verified)} tone="good" /> <Metric label={"Verified"} value={String(verified)} tone="good" />
<Metric label={t("bots.system")} value={String(systemCount)} /> <Metric label={"System"} value={String(systemCount)} />
</div> </div>
<section className="section-block"> <section className="section-block">
<div className="section-head"> <div className="section-head">
<div> <div>
<h2>{t("bots.createTitle")}</h2> <h2>{"Create a system bot"}</h2>
<p>{t("bots.createHint")}</p> <p>{"Provision a bot account owned by the given user. The token is shown once after confirmation."}</p>
</div> </div>
</div> </div>
<div className="bot-create-fields"> <div className="bot-create-fields">
<label className="duration-field"> <label className="duration-field">
<span>{t("bots.ownerUserID")}</span> <span>{"Owner user ID"}</span>
<input <input
value={ownerID} value={ownerID}
onChange={(event) => setOwnerID(event.target.value)} onChange={(event) => setOwnerID(event.target.value)}
@ -86,18 +84,18 @@ export function BotsPage({ navigate }: { navigate: Navigate }) {
/> />
</label> </label>
<label className="duration-field"> <label className="duration-field">
<span>{t("bots.name")}</span> <span>{"Display name"}</span>
<input value={botName} onChange={(event) => setBotName(event.target.value)} placeholder={t("bots.namePlaceholder")} maxLength={64} /> <input value={botName} onChange={(event) => setBotName(event.target.value)} placeholder={"e.g. Service Bot"} maxLength={64} />
</label> </label>
<label className="duration-field"> <label className="duration-field">
<span>{t("bots.username")}</span> <span>{"Username"}</span>
<input value={botUsername} onChange={(event) => setBotUsername(event.target.value)} placeholder="my_service_bot" /> <input value={botUsername} onChange={(event) => setBotUsername(event.target.value)} placeholder="my_service_bot" />
</label> </label>
</div> </div>
<div className="bot-create-actions"> <div className="bot-create-actions">
<span className="bot-create-note">{t("bots.usernameHint")}</span> <span className="bot-create-note">{"Username must be 5-32 characters and end with 'bot'."}</span>
<ActionButton <ActionButton
label={t("bots.create")} label={"Create bot"}
icon={<Plus size={15} />} icon={<Plus size={15} />}
tone="neutral" tone="neutral"
path="/api/actions/create-bot" path="/api/actions/create-bot"
@ -115,18 +113,18 @@ export function BotsPage({ navigate }: { navigate: Navigate }) {
<form className="toolbar" onSubmit={(event) => { event.preventDefault(); void load(false); }}> <form className="toolbar" onSubmit={(event) => { event.preventDefault(); void load(false); }}>
<label className="searchbox"> <label className="searchbox">
<Search size={15} /> <Search size={15} />
<input value={q} onChange={(event) => setQ(event.target.value)} placeholder={t("bots.searchPlaceholder")} /> <input value={q} onChange={(event) => setQ(event.target.value)} placeholder={"Bot ID / username"} />
</label> </label>
<label className="field-inline"> <label className="field-inline">
<span>{t("common.limit")}</span> <span>{"Limit"}</span>
<input className="small-input" value={limit} onChange={(event) => setLimit(event.target.value)} type="number" min="1" max="100" /> <input className="small-input" value={limit} onChange={(event) => setLimit(event.target.value)} type="number" min="1" max="100" />
</label> </label>
<button className="btn primary icon-text" type="submit" disabled={busy}> <button className="btn primary icon-text" type="submit" disabled={busy}>
{busy ? <Loader2 size={15} className="spin" /> : <Search size={15} />} {t("common.search")} {busy ? <Loader2 size={15} className="spin" /> : <Search size={15} />} {"Search"}
</button> </button>
{data?.listing && data.has_more && ( {data?.listing && data.has_more && (
<button className="btn icon-text" type="button" onClick={() => load(true)} disabled={busy}> <button className="btn icon-text" type="button" onClick={() => load(true)} disabled={busy}>
<ChevronRight size={15} /> {t("messages.nextPage")} <ChevronRight size={15} /> {"Next page"}
</button> </button>
)} )}
</form> </form>
@ -136,13 +134,13 @@ export function BotsPage({ navigate }: { navigate: Navigate }) {
<table className="data-table"> <table className="data-table">
<thead> <thead>
<tr> <tr>
<th>{t("bots.botID")}</th> <th>{"Bot ID"}</th>
<th>{t("common.username")}</th> <th>{"Username"}</th>
<th>{t("common.name")}</th> <th>{"Name"}</th>
<th>{t("bots.owner")}</th> <th>{"Owner"}</th>
<th>{t("common.verified")}</th> <th>{"Verified"}</th>
<th>{t("bots.type")}</th> <th>{"Type"}</th>
<th>{t("account.createdAt")}</th> <th>{"Created"}</th>
<th></th> <th></th>
</tr> </tr>
</thead> </thead>
@ -153,10 +151,10 @@ export function BotsPage({ navigate }: { navigate: Navigate }) {
<td>{displayUsername(row.Username) || "-"}</td> <td>{displayUsername(row.Username) || "-"}</td>
<td>{row.FirstName || "-"}</td> <td>{row.FirstName || "-"}</td>
<td className="mono">{row.OwnerUserID > 0 ? row.OwnerUserID : "-"}</td> <td className="mono">{row.OwnerUserID > 0 ? row.OwnerUserID : "-"}</td>
<td>{row.Verified ? <Badge tone="good"><BadgeCheck size={12} /> {t("common.verified")}</Badge> : <Badge>{t("account.notVerified")}</Badge>} <ScamFakeBadges scam={row.Scam} fake={row.Fake} /></td> <td>{row.Verified ? <Badge tone="good"><BadgeCheck size={12} /> {"Verified"}</Badge> : <Badge>{"Not verified"}</Badge>} <ScamFakeBadges scam={row.Scam} fake={row.Fake} /></td>
<td>{row.System ? <Badge tone="warn">{t("bots.system")}</Badge> : <Badge>{t("bots.user")}</Badge>}</td> <td>{row.System ? <Badge tone="warn">{"System"}</Badge> : <Badge>{"User"}</Badge>}</td>
<td>{formatDate(row.CreatedAt)}</td> <td>{formatDate(row.CreatedAt)}</td>
<td><button className="row-link" onClick={() => navigate(`/bots/${row.ID}`)}><Bot size={14} /> {t("common.detail")} <ChevronRight size={14} /></button></td> <td><button className="row-link" onClick={() => navigate(`/bots/${row.ID}`)}><Bot size={14} /> {"Details"} <ChevronRight size={14} /></button></td>
</tr> </tr>
))} ))}
{rows.length === 0 && <EmptyRow colSpan={8} />} {rows.length === 0 && <EmptyRow colSpan={8} />}

View file

@ -3,7 +3,6 @@ import { useEffect, useState } from "react";
import { api, errorMessage } from "../api"; import { api, errorMessage } from "../api";
import { ActionButton } from "../components/ActionButton"; import { ActionButton } from "../components/ActionButton";
import { Alert, AuditTable, Badge, JsonBlock, LoadingSurface, PageFrame, SectionHead, SplitLayout, Summary } from "../components/ui"; import { Alert, AuditTable, Badge, JsonBlock, LoadingSurface, PageFrame, SectionHead, SplitLayout, Summary } from "../components/ui";
import { useI18n } from "../i18n";
import { ScamFakeActions, ScamFakeBadges } from "../components/flags"; import { ScamFakeActions, ScamFakeBadges } from "../components/flags";
import { ChannelSettingsAction, ColorAction, EmojiStatusAction, UsernameAction } from "../components/attributes"; import { ChannelSettingsAction, ColorAction, EmojiStatusAction, UsernameAction } from "../components/attributes";
import { channelKind, displayUsername, formatDate, formatUnix } from "../lib/format"; import { channelKind, displayUsername, formatDate, formatUnix } from "../lib/format";
@ -11,7 +10,6 @@ import type { Navigate } from "../routing";
import type { ChannelDetail } from "../types"; import type { ChannelDetail } from "../types";
export function ChannelDetailPage({ id, navigate }: { id: number; navigate: Navigate }) { export function ChannelDetailPage({ id, navigate }: { id: number; navigate: Navigate }) {
const { t } = useI18n();
const [detail, setDetail] = useState<ChannelDetail | null>(null); const [detail, setDetail] = useState<ChannelDetail | null>(null);
const [error, setError] = useState(""); const [error, setError] = useState("");
@ -32,15 +30,15 @@ export function ChannelDetailPage({ id, navigate }: { id: number; navigate: Navi
return <Alert>{error}</Alert>; return <Alert>{error}</Alert>;
} }
if (!detail) { if (!detail) {
return <LoadingSurface label={t("channel.loadingDetail")} />; return <LoadingSurface label={"Loading channel detail"} />;
} }
const ch = detail.Channel; const ch = detail.Channel;
return ( return (
<PageFrame <PageFrame
title={`${channelKind(ch, t)} #${ch.ID}`} title={`${channelKind(ch)} #${ch.ID}`}
eyebrow={t("channel.detailProfile")} eyebrow={"Channel Profile"}
actions={<button className="btn icon-text" onClick={() => navigate("/channels")}><ArrowLeft size={15} /> {t("common.backToList")}</button>} actions={<button className="btn icon-text" onClick={() => navigate("/channels")}><ArrowLeft size={15} /> {"Back to list"}</button>}
> >
<SplitLayout <SplitLayout
main={ main={
@ -48,41 +46,41 @@ export function ChannelDetailPage({ id, navigate }: { id: number; navigate: Navi
<section className="entity-head"> <section className="entity-head">
<div> <div>
<div className="entity-title">{ch.Title || "-"}</div> <div className="entity-title">{ch.Title || "-"}</div>
<div className="entity-subtitle">{displayUsername(ch.Username) || t("account.noUsername")} · {t("channel.creator", { id: ch.CreatorUserID })}</div> <div className="entity-subtitle">{displayUsername(ch.Username) || "No username"} · {`Creator ${ch.CreatorUserID}`}</div>
</div> </div>
<div className="entity-badges"> <div className="entity-badges">
<Badge>{channelKind(ch, t)}</Badge> <Badge>{channelKind(ch)}</Badge>
{ch.Verified ? <Badge tone="good">{t("common.verified")}</Badge> : <Badge>{t("account.notVerified")}</Badge>} {ch.Verified ? <Badge tone="good">{"Verified"}</Badge> : <Badge>{"Not verified"}</Badge>}
<ScamFakeBadges scam={ch.Scam} fake={ch.Fake} /> <ScamFakeBadges scam={ch.Scam} fake={ch.Fake} />
{ch.Deleted ? <Badge tone="danger">{t("common.deleted")}</Badge> : <Badge>{t("common.valid")}</Badge>} {ch.Deleted ? <Badge tone="danger">{"Deleted"}</Badge> : <Badge>{"Valid"}</Badge>}
</div> </div>
</section> </section>
<div className="summary-grid"> <div className="summary-grid">
<Summary label={t("channel.channelID")} value={String(ch.ID)} mono /> <Summary label={"Channel ID"} value={String(ch.ID)} mono />
<Summary label="access_hash" value={String(ch.AccessHash)} mono /> <Summary label="access_hash" value={String(ch.AccessHash)} mono />
<Summary label={t("common.members")} value={`${ch.ParticipantsCount} / ${t("common.admins")} ${ch.AdminsCount}`} /> <Summary label={"Members"} value={`${ch.ParticipantsCount} / ${"Admins"} ${ch.AdminsCount}`} />
<Summary label={t("channel.governance")} value={t("channel.governanceValue", { banned: ch.BannedCount, kicked: ch.KickedCount })} /> <Summary label={"Moderation"} value={`Banned ${ch.BannedCount} / Kicked ${ch.KickedCount}`} />
<Summary label={t("channel.flags")} value={`broadcast=${ch.Broadcast} megagroup=${ch.Megagroup} forum=${ch.Forum}`} /> <Summary label={"Channel flags"} value={`broadcast=${ch.Broadcast} megagroup=${ch.Megagroup} forum=${ch.Forum}`} />
<Summary label="top / pinned / PTS" value={`${ch.TopMessageID} / ${ch.PinnedMessageID} / ${ch.PTS}`} /> <Summary label="top / pinned / PTS" value={`${ch.TopMessageID} / ${ch.PinnedMessageID} / ${ch.PTS}`} />
<Summary label={t("account.createdAt")} value={formatUnix(ch.Date) || "-"} /> <Summary label={"Created"} value={formatUnix(ch.Date) || "-"} />
<Summary label={t("common.updatedAt")} value={formatDate(ch.UpdatedAt) || "-"} /> <Summary label={"Updated"} value={formatDate(ch.UpdatedAt) || "-"} />
</div> </div>
{ch.About && <p className="about-text">{ch.About}</p>} {ch.About && <p className="about-text">{ch.About}</p>}
<section className="section-block"> <section className="section-block">
<SectionHead title={t("account.recentAdminOps")} text={t("account.recent30Audit")} /> <SectionHead title={"Recent Admin Actions"} text={"Last 30 audit rows"} />
<AuditTable rows={detail.AuditLogs} /> <AuditTable rows={detail.AuditLogs} />
</section> </section>
<section className="section-block"> <section className="section-block">
<SectionHead title={t("channel.rawRow")} text={t("channel.rawRowText")} /> <SectionHead title={"Channel Raw Row"} text={"Database read-only snapshot"} />
<JsonBlock value={detail.ChannelJSON} /> <JsonBlock value={detail.ChannelJSON} />
</section> </section>
</div> </div>
} }
side={ side={
<section className="action-dock"> <section className="action-dock">
<div className="dock-title">{t("channel.actionDock")}</div> <div className="dock-title">{"Channel Actions"}</div>
<ActionButton <ActionButton
label={ch.Verified ? t("channel.clearVerified") : t("channel.setVerified")} label={ch.Verified ? "Clear verified" : "Set verified"}
icon={<BadgeCheck size={15} />} icon={<BadgeCheck size={15} />}
tone="warn" tone="warn"
path="/api/actions/set-channel-verified" path="/api/actions/set-channel-verified"
@ -90,9 +88,9 @@ export function ChannelDetailPage({ id, navigate }: { id: number; navigate: Navi
onDone={load} onDone={load}
/> />
<ScamFakeActions idKey="channel_id" id={ch.ID} path="/api/actions/set-channel-flags" scam={ch.Scam} fake={ch.Fake} onDone={load} /> <ScamFakeActions idKey="channel_id" id={ch.ID} path="/api/actions/set-channel-flags" scam={ch.Scam} fake={ch.Fake} onDone={load} />
<div className="dock-title">{t("attr.settings")}</div> <div className="dock-title">{"Settings"}</div>
<ChannelSettingsAction channel={ch} onDone={load} /> <ChannelSettingsAction channel={ch} onDone={load} />
<div className="dock-title">{t("attr.attributes")}</div> <div className="dock-title">{"Attributes"}</div>
<UsernameAction idKey="channel_id" id={ch.ID} path="/api/actions/set-channel-username" current={ch.Username} onDone={load} /> <UsernameAction idKey="channel_id" id={ch.ID} path="/api/actions/set-channel-username" current={ch.Username} onDone={load} />
<ColorAction idKey="channel_id" id={ch.ID} path="/api/actions/set-channel-color" onDone={load} /> <ColorAction idKey="channel_id" id={ch.ID} path="/api/actions/set-channel-color" onDone={load} />
<EmojiStatusAction idKey="channel_id" id={ch.ID} path="/api/actions/set-channel-emoji-status" onDone={load} /> <EmojiStatusAction idKey="channel_id" id={ch.ID} path="/api/actions/set-channel-emoji-status" onDone={load} />

View file

@ -3,14 +3,12 @@ import { useEffect, useState } from "react";
import { api, errorMessage } from "../api"; import { api, errorMessage } from "../api";
import { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel } from "../components/ui"; import { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel } from "../components/ui";
import { ScamFakeBadges } from "../components/flags"; import { ScamFakeBadges } from "../components/flags";
import { useI18n } from "../i18n";
import { channelKind, displayUsername, formatDate } from "../lib/format"; import { channelKind, displayUsername, formatDate } from "../lib/format";
import { channelMetrics } from "../lib/metrics"; import { channelMetrics } from "../lib/metrics";
import type { Navigate } from "../routing"; import type { Navigate } from "../routing";
import type { ChannelListResponse } from "../types"; import type { ChannelListResponse } from "../types";
export function ChannelsPage({ navigate }: { navigate: Navigate }) { export function ChannelsPage({ navigate }: { navigate: Navigate }) {
const { t } = useI18n();
const [q, setQ] = useState(""); const [q, setQ] = useState("");
const [limit, setLimit] = useState("50"); const [limit, setLimit] = useState("50");
const [data, setData] = useState<ChannelListResponse | null>(null); const [data, setData] = useState<ChannelListResponse | null>(null);
@ -50,37 +48,37 @@ export function ChannelsPage({ navigate }: { navigate: Navigate }) {
return ( return (
<PageFrame <PageFrame
title={t("channel.pageTitle")} title={"Supergroups and Channels"}
eyebrow={data?.listing === false ? t("account.queryResults") : t("channel.recentUpdated")} eyebrow={data?.listing === false ? "Search results" : "Recently updated"}
actions={ actions={
<button className="btn" type="button" onClick={() => load(false)} disabled={busy}> <button className="btn" type="button" onClick={() => load(false)} disabled={busy}>
<RefreshCw size={15} /> {t("common.refresh")} <RefreshCw size={15} /> {"Refresh"}
</button> </button>
} }
> >
{error && <Alert>{error}</Alert>} {error && <Alert>{error}</Alert>}
<div className="metric-row"> <div className="metric-row">
<Metric label={t("channel.currentPage")} value={String(data?.rows.length ?? 0)} /> <Metric label={"Entities on page"} value={String(data?.rows.length ?? 0)} />
<Metric label={t("channel.megagroups")} value={String(metrics.megagroups)} /> <Metric label={"Supergroups"} value={String(metrics.megagroups)} />
<Metric label={t("channel.broadcasts")} value={String(metrics.broadcasts)} /> <Metric label={"Channels"} value={String(metrics.broadcasts)} />
<Metric label={t("channel.verifiedCount")} value={String(metrics.verified)} tone="good" /> <Metric label={"Verified"} value={String(metrics.verified)} tone="good" />
</div> </div>
<QueryPanel> <QueryPanel>
<form className="toolbar" onSubmit={(event) => { event.preventDefault(); void load(false); }}> <form className="toolbar" onSubmit={(event) => { event.preventDefault(); void load(false); }}>
<label className="searchbox"> <label className="searchbox">
<Search size={15} /> <Search size={15} />
<input value={q} onChange={(event) => setQ(event.target.value)} placeholder={t("channel.searchPlaceholder")} /> <input value={q} onChange={(event) => setQ(event.target.value)} placeholder={"Channel ID / username / title"} />
</label> </label>
<label className="field-inline"> <label className="field-inline">
<span>{t("common.limit")}</span> <span>{"Limit"}</span>
<input className="small-input" value={limit} onChange={(event) => setLimit(event.target.value)} type="number" min="1" max="100" /> <input className="small-input" value={limit} onChange={(event) => setLimit(event.target.value)} type="number" min="1" max="100" />
</label> </label>
<button className="btn primary icon-text" type="submit" disabled={busy}> <button className="btn primary icon-text" type="submit" disabled={busy}>
{busy ? <Loader2 size={15} className="spin" /> : <Search size={15} />} {t("common.search")} {busy ? <Loader2 size={15} className="spin" /> : <Search size={15} />} {"Search"}
</button> </button>
{data?.listing && data.has_more && ( {data?.listing && data.has_more && (
<button className="btn icon-text" type="button" onClick={() => load(true)} disabled={busy}> <button className="btn icon-text" type="button" onClick={() => load(true)} disabled={busy}>
<ChevronRight size={15} /> {t("messages.nextPage")} <ChevronRight size={15} /> {"Next page"}
</button> </button>
)} )}
</form> </form>
@ -89,15 +87,15 @@ export function ChannelsPage({ navigate }: { navigate: Navigate }) {
<table className="data-table"> <table className="data-table">
<thead> <thead>
<tr> <tr>
<th>{t("channel.channelID")}</th> <th>{"Channel ID"}</th>
<th>{t("channel.kind")}</th> <th>{"Kind"}</th>
<th>{t("common.username")}</th> <th>{"Username"}</th>
<th>{t("channel.title")}</th> <th>{"Title"}</th>
<th>{t("common.members")}</th> <th>{"Members"}</th>
<th>{t("common.admins")}</th> <th>{"Admins"}</th>
<th>PTS</th> <th>PTS</th>
<th>{t("common.verified")}</th> <th>{"Verified"}</th>
<th>{t("common.updatedAt")}</th> <th>{"Updated"}</th>
<th></th> <th></th>
</tr> </tr>
</thead> </thead>
@ -105,15 +103,15 @@ export function ChannelsPage({ navigate }: { navigate: Navigate }) {
{data?.rows.map((row) => ( {data?.rows.map((row) => (
<tr key={row.ID}> <tr key={row.ID}>
<td className="mono">{row.ID}</td> <td className="mono">{row.ID}</td>
<td>{channelKind(row, t)}</td> <td>{channelKind(row)}</td>
<td>{displayUsername(row.Username)}</td> <td>{displayUsername(row.Username)}</td>
<td>{row.Title}</td> <td>{row.Title}</td>
<td>{row.ParticipantsCount}</td> <td>{row.ParticipantsCount}</td>
<td>{row.AdminsCount}</td> <td>{row.AdminsCount}</td>
<td>{row.PTS}</td> <td>{row.PTS}</td>
<td>{row.Verified ? <Badge tone="good">{t("common.verified")}</Badge> : <Badge>{t("account.notVerified")}</Badge>} <ScamFakeBadges scam={row.Scam} fake={row.Fake} /></td> <td>{row.Verified ? <Badge tone="good">{"Verified"}</Badge> : <Badge>{"Not verified"}</Badge>} <ScamFakeBadges scam={row.Scam} fake={row.Fake} /></td>
<td>{formatDate(row.UpdatedAt)}</td> <td>{formatDate(row.UpdatedAt)}</td>
<td><button className="row-link" onClick={() => navigate(`/channels/${row.ID}`)}>{t("common.detail")} <ChevronRight size={14} /></button></td> <td><button className="row-link" onClick={() => navigate(`/channels/${row.ID}`)}>{"Details"} <ChevronRight size={14} /></button></td>
</tr> </tr>
))} ))}
{(!data || data.rows.length === 0) && <EmptyRow colSpan={10} />} {(!data || data.rows.length === 0) && <EmptyRow colSpan={10} />}

View file

@ -0,0 +1,257 @@
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 { 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 [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 ? "Loading collectible username…" : "Waiting for data"} />;
}
const asset = detail.asset;
const transfers = detail.transfers ?? [];
const vaultLabel = "Vault";
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={`Collectible ${displayUsername(asset.Username)}`}
eyebrow={"NFT usernames / Asset"}
actions={
<>
<button className="btn icon-text" type="button" onClick={() => navigate("/collectible-usernames")}>
<ArrowLeft size={15} /> {"Back to list"}
</button>
<button className="btn icon-text" type="button" onClick={load} disabled={busy}>
<RefreshCw size={15} className={busy ? "spin" : ""} /> {"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">{`Asset #${asset.ID}`}</div>
</div>
<div className="entity-badges">
<UsernameStatus status={asset.Status} />
<Badge tone={asset.TransferCount > 0 ? "warn" : "neutral"}>
{`${asset.TransferCount} transfers`}
</Badge>
{asset.Status === "owned" && (
<Badge tone={asset.RegistryActive ? "good" : "warn"}>
{asset.RegistryActive ? "Active in profile" : "Hidden in profile"}
</Badge>
)}
</div>
</section>
<div className="summary-grid">
<Summary label={"Owner"} value={ownerLabel(asset, vaultLabel)} />
<Summary label={"Price"} value={priceLabel(asset)} mono />
<Summary label={"Purchase date (UTC)"} value={formatDate(asset.PurchaseDate) || "-"} />
<Summary
label={"Original owner"}
value={peerLabel(asset.OriginalOwnerPeerType, asset.OriginalOwnerPeerID, vaultLabel, asset.OriginalOwnerUsername)}
/>
<Summary label={"Transfers"} value={String(asset.TransferCount)} mono />
<Summary label={"Created"} value={formatDate(asset.CreatedAt) || "-"} />
<Summary label={"Updated"} value={formatDate(asset.UpdatedAt) || "-"} />
</div>
<div className="toolbar">
{hasOwner && (
<button className="row-link" type="button" onClick={openOwner}>
{asset.OwnerPeerType === "channel" ? "Open owner channel" : "Open owner account"}
</button>
)}
{asset.URL && (
<a className="row-link" href={asset.URL} target="_blank" rel="noreferrer noopener">
<ExternalLink size={14} /> {"Open marketplace page"}
</a>
)}
</div>
{!burned && (
<section className="section-block">
<SectionHead title={"Transfer ownership"} text={"Pick the recipient; the transfer is appended to the provenance history."} />
<div className="toolbar" role="group" aria-label={"Recipient type"}>
<button type="button" className={`btn ${recipientKind === "user" ? "primary" : ""}`} onClick={() => setRecipientKind("user")}>
{"To user"}
</button>
<button type="button" className={`btn ${recipientKind === "channel" ? "primary" : ""}`} onClick={() => setRecipientKind("channel")}>
{"To channel"}
</button>
</div>
{recipientKind === "user"
? <UserPicker label={"To user"} value={recipientUser} onChange={setRecipientUser} />
: <ChannelPicker label={"To channel"} value={recipientChannel} onChange={setRecipientChannel} />}
<div className="bot-create-actions">
<span className="bot-create-note">{"The current owner loses the username immediately after confirmation."}</span>
<ActionButton
label={"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={"Provenance history"} text={"Mint, transfer, revoke and burn events in chronological order."} />
<div className="table-wrap">
<table className="data-table">
<thead>
<tr>
<th>{"ID"}</th>
<th>{"Event"}</th>
<th>{"From"}</th>
<th>{"To"}</th>
<th>{"Price"}</th>
<th>{"Actor"}</th>
<th>{"Reason"}</th>
<th>{"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">{"Asset operations"}</div>
{burned ? (
<p className="bot-create-note">{"This username is burned — no further operations are possible."}</p>
) : (
<>
<div className="action-stack">
<ActionButton
label={"Revoke to vault"}
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">{"Takes the username away from its owner and returns it to the vault; it can be issued again later."}</p>
<div className="danger-zone">
<ActionButton
label={"Burn permanently"}
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">{"Irreversible: the username is destroyed and can never be issued again."}</p>
<ActionButton
label={"Delete record"}
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">{"Erases the asset and its ownership history, and frees the username for a fresh issue. Use this for a username issued by mistake; a burn keeps the history instead."}</p>
</div>
</>
)}
</section>
}
/>
</PageFrame>
);
}
const usernameKindLabels: Record<CollectibleUsernameTransferKind, string> = {
mint: "Mint",
transfer: "Transfer",
burn: "Burn",
revoke: "Revoke"
};
function TransferKind({ kind }: { kind: CollectibleUsernameTransferKind }) {
const tone = kind === "burn" ? "danger" : kind === "revoke" ? "warn" : kind === "mint" ? "good" : "neutral";
return <Badge tone={tone}>{usernameKindLabels[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,302 @@
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 { 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 [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={"Collectible usernames"}
eyebrow={"NFT usernames / Registry"}
actions={
<button className="btn icon-text" type="button" onClick={() => load(false)} disabled={busy}>
<RefreshCw size={15} className={busy ? "spin" : ""} /> {"Refresh"}
</button>
}
>
{error && <Alert>{error}</Alert>}
<div className="metric-row">
<Metric label={"Loaded rows"} value={String(rows.length)} />
<Metric label={"In vault"} value={String(vaultCount)} />
<Metric label={"Held by owners"} value={String(ownedCount)} tone="good" />
<Metric label={"Burned"} value={String(burnedCount)} tone={burnedCount ? "danger" : "neutral"} />
</div>
<section className="section-block">
<SectionHead title={"Mint a collectible username"} text={"Creates the asset together with its purchase record. Keep the owner as vault to mint it unassigned."} />
<div className="toolbar" role="group" aria-label={"Owner type"}>
<button type="button" className={`btn ${ownerKind === "vault" ? "primary" : ""}`} onClick={() => setOwnerKind("vault")}>
<Vault size={15} /> {"Vault (no owner)"}
</button>
<button type="button" className={`btn ${ownerKind === "user" ? "primary" : ""}`} onClick={() => setOwnerKind("user")}>
{"User owner"}
</button>
<button type="button" className={`btn ${ownerKind === "channel" ? "primary" : ""}`} onClick={() => setOwnerKind("channel")}>
{"Channel owner"}
</button>
</div>
{ownerKind === "user" && <UserPicker label={"User owner"} value={owner} onChange={setOwner} />}
{ownerKind === "channel" && <ChannelPicker label={"Channel owner"} value={ownerChannel} onChange={setOwnerChannel} />}
<div className="bot-create-fields">
<label className="duration-field">
<span>{"Username"}</span>
<input value={mintUsername} onChange={(event) => setMintUsername(event.target.value)} placeholder="durov" />
</label>
<label className="duration-field">
<span>{"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>{`Amount (${currency})`}</span>
<input value={amount} onChange={(event) => setAmount(event.target.value)} inputMode="decimal" placeholder="1000" />
</label>
<label className="duration-field">
<span>{"Crypto currency"}</span>
<select value={cryptoCurrency} onChange={(event) => setCryptoCurrency(event.target.value)}>
<option value="">{"None"}</option>
<option value="TON">TON</option>
</select>
</label>
{cryptoCurrency !== "" && (
<label className="duration-field">
<span>{`Crypto amount (${cryptoCurrency})`}</span>
<input value={cryptoAmount} onChange={(event) => setCryptoAmount(event.target.value)} inputMode="decimal" placeholder="12.5" />
</label>
)}
<label className="duration-field">
<span>{"Marketplace URL"}</span>
<input value={url} onChange={(event) => setUrl(event.target.value)} placeholder="https://fragment.com/username/durov" />
</label>
<label className="duration-field">
<span>{"Purchase date (UTC)"}</span>
<input value={purchaseDate} onChange={(event) => setPurchaseDate(event.target.value)} type="date" />
</label>
<label className="duration-field">
<span>{"Purchase time (UTC)"}</span>
<input
value={purchaseTime}
onChange={(event) => setPurchaseTime(event.target.value)}
type="time"
step={60}
disabled={!purchaseDate}
/>
</label>
</div>
<p className="bot-create-note">
{`Amounts are typed in whole ${currency} and stored as the smallest units the API and fragment.collectibleInfo carry, so clients render the price you meant. Up to ${String(currencyExponent(currency))} decimal places. Clients will show: ${formatCurrency(minorAmount ?? "0", currency)}.`}
</p>
{amountInvalid && <Alert>{`That is not a valid ${currency} amount: digits only, with at most ${String(currencyExponent(currency))} decimal places.`}</Alert>}
{cryptoCurrency !== "" && cryptoAmountInvalid && (
<Alert>{`That is not a valid ${cryptoCurrency} amount: digits only, with at most ${String(currencyExponent(cryptoCurrency))} decimal places.`}</Alert>
)}
<div className="bot-create-actions">
<span className="bot-create-note">{"Username, currency and amount are required; the dry-run checks availability first."}</span>
<ActionButton
disabled={amountInvalid || cryptoAmountInvalid}
label={"Mint username"}
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={"Search by username"} />
</label>
<label className="field-inline">
<span>{"Status"}</span>
<select value={status} onChange={(event) => setStatus(event.target.value as StatusFilter)}>
<option value="all">{"All statuses"}</option>
<option value="vault">{"Vault"}</option>
<option value="owned">{"Owned"}</option>
<option value="burned">{"Burned"}</option>
</select>
</label>
<label className="field-inline">
<span>{"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} />} {"Search"}
</button>
</form>
</QueryPanel>
<div className="table-wrap">
<table className="data-table">
<thead>
<tr>
<th>{"Username"}</th>
<th>{"Status"}</th>
<th>{"Owner"}</th>
<th>{"Price"}</th>
<th>{"Purchase date (UTC)"}</th>
<th>{"Transfers"}</th>
<th>{"Updated"}</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, "Vault")}</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} /> {"Details"} <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} />} {"Load more"}
</button>
</div>
)}
</PageFrame>
);
}
export function UsernameStatus({ status }: { status: CollectibleUsernameStatus }) {
if (status === "owned") return <Badge tone="good">{"Owned"}</Badge>;
if (status === "burned") return <Badge tone="danger"><Flame size={12} /> {"Burned"}</Badge>;
return <Badge><Vault size={12} /> {"Vault"}</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

@ -3,14 +3,11 @@ import { useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { api, errorMessage } from "../api"; import { api, errorMessage } from "../api";
import { Alert } from "../components/ui"; import { Alert } from "../components/ui";
import { useI18n } from "../i18n";
// Real Telegram sticker/emoji packs are created with at least one item, so // Real Telegram sticker/emoji packs are created with at least one item, so
// this form collects the title/short name plus a single starting file — // this form collects the title/short name plus a single starting file —
// exactly like CreateStickerSet's domain-level requirement. More stickers // exactly like CreateStickerSet's domain-level requirement. More stickers
// get added afterward from the pack's own preview modal. // get added afterward from the pack's own preview modal.
export function CreateStickerSetModal({ kind, onClose, onCreated }: { kind: "stickers" | "emoji"; onClose: () => void; onCreated: () => void }) { export function CreateStickerSetModal({ kind, onClose, onCreated }: { kind: "stickers" | "emoji"; onClose: () => void; onCreated: () => void }) {
const { t } = useI18n();
const noun = kind === "emoji" ? "emoji" : "sticker"; const noun = kind === "emoji" ? "emoji" : "sticker";
const [title, setTitle] = useState(""); const [title, setTitle] = useState("");
const [shortName, setShortName] = useState(""); const [shortName, setShortName] = useState("");
@ -22,11 +19,11 @@ export function CreateStickerSetModal({ kind, onClose, onCreated }: { kind: "sti
async function submit() { async function submit() {
if (!title.trim() || !shortName.trim() || !emoji.trim() || !file) { if (!title.trim() || !shortName.trim() || !emoji.trim() || !file) {
setError(t("stickers.createFieldsRequired", { noun })); setError(`Title, short name, emoji and a first ${noun} file are required.`);
return; return;
} }
if (!reason.trim()) { if (!reason.trim()) {
setError(t("action.reasonRequired")); setError("Please enter an operation reason");
return; return;
} }
setBusy(true); setBusy(true);
@ -50,33 +47,33 @@ export function CreateStickerSetModal({ kind, onClose, onCreated }: { kind: "sti
return createPortal( return createPortal(
<div className="modal-backdrop" role="presentation"> <div className="modal-backdrop" role="presentation">
<section className="modal command-modal" role="dialog" aria-modal="true" aria-label={t("stickers.createTitle", { noun })}> <section className="modal command-modal" role="dialog" aria-modal="true" aria-label={`Create a new ${noun} pack`}>
<div className="modal-head"> <div className="modal-head">
<div> <div>
<div className="eyebrow">{t("stickers.createEyebrow")}</div> <div className="eyebrow">{"New set"}</div>
<h2>{t("stickers.createTitle", { noun })}</h2> <h2>{`Create a new ${noun} pack`}</h2>
</div> </div>
<button className="icon-btn" type="button" onClick={onClose} disabled={busy} aria-label={t("action.close")}><X size={15} /></button> <button className="icon-btn" type="button" onClick={onClose} disabled={busy} aria-label={"Close"}><X size={15} /></button>
</div> </div>
<div className="command-body"> <div className="command-body">
<div className="gift-fields-grid"> <div className="gift-fields-grid">
<label><span>{t("stickers.title")}</span><input value={title} maxLength={64} onChange={(event) => setTitle(event.target.value)} /></label> <label><span>{"Title"}</span><input value={title} maxLength={64} onChange={(event) => setTitle(event.target.value)} /></label>
<label><span>{t("stickers.shortName")}</span><input value={shortName} maxLength={32} onChange={(event) => setShortName(event.target.value)} placeholder={t("stickers.shortNamePlaceholder")} /></label> <label><span>{"Short name"}</span><input value={shortName} maxLength={32} onChange={(event) => setShortName(event.target.value)} placeholder={"lowercase_short_name"} /></label>
<label><span>{t("stickers.emoji")}</span><input value={emoji} onChange={(event) => setEmoji(event.target.value)} placeholder={t("stickers.emojiPlaceholder")} /></label> <label><span>{"Emoji"}</span><input value={emoji} onChange={(event) => setEmoji(event.target.value)} placeholder={"e.g. 😀"} /></label>
</div> </div>
<label className={`gift-file-picker ${file ? "has-file" : ""}`}> <label className={`gift-file-picker ${file ? "has-file" : ""}`}>
<input type="file" accept=".tgs,.json,.webp,application/json,application/x-tgsticker,image/webp" onChange={(event) => setFile(event.target.files?.[0] ?? null)} /> <input type="file" accept=".tgs,.json,.webp,application/json,application/x-tgsticker,image/webp" onChange={(event) => setFile(event.target.files?.[0] ?? null)} />
<span className="gift-file-copy"><span className="gift-field-label">{t("stickers.firstSticker", { noun })}</span><strong>{file ? file.name : t("stickers.filePrompt")}</strong></span> <span className="gift-file-copy"><span className="gift-field-label">{`First ${noun}`}</span><strong>{file ? file.name : "Choose a TGS, Lottie JSON, or WebP file"}</strong></span>
<span className="gift-file-action">{file ? t("gifts.changeFile") : t("gifts.chooseFile")}</span> <span className="gift-file-action">{file ? "Change file" : "Choose file"}</span>
</label> </label>
<label className="gift-reason-field"><span>{t("gifts.reason")}</span><input value={reason} placeholder={t("gifts.reasonPlaceholder")} onChange={(event) => setReason(event.target.value)} /></label> <label className="gift-reason-field"><span>{"Audit reason"}</span><input value={reason} placeholder={"Briefly describe why this gift is being imported"} onChange={(event) => setReason(event.target.value)} /></label>
{error && <Alert>{error}</Alert>} {error && <Alert>{error}</Alert>}
</div> </div>
<div className="modal-actions"> <div className="modal-actions">
<button className="btn" type="button" onClick={onClose} disabled={busy}>{t("common.close")}</button> <button className="btn" type="button" onClick={onClose} disabled={busy}>{"Close"}</button>
<button className="btn primary" type="button" onClick={submit} disabled={busy}> <button className="btn primary" type="button" onClick={submit} disabled={busy}>
{busy ? <Loader2 className="spin" size={15} /> : <Upload size={15} />} {busy ? <Loader2 className="spin" size={15} /> : <Upload size={15} />}
{t("stickers.create", { noun })} {`Create ${noun} pack`}
</button> </button>
</div> </div>
</section> </section>

View file

@ -2,34 +2,32 @@ import { CheckCircle2, ChevronRight, Clock3, FileJson, KeyRound, MessageSquareTe
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { AppLink } from "../components/AppLink"; import { AppLink } from "../components/AppLink";
import { StatusItem } from "../components/ui"; import { StatusItem } from "../components/ui";
import { useI18n } from "../i18n";
import type { Navigate } from "../routing"; import type { Navigate } from "../routing";
export function Dashboard({ navigate }: { navigate: Navigate }) { export function Dashboard({ navigate }: { navigate: Navigate }) {
const { t } = useI18n();
return ( return (
<div className="dashboard-layout"> <div className="dashboard-layout">
<section className="overview-band"> <section className="overview-band">
<div> <div>
<div className="eyebrow">{t("dashboard.eyebrow")}</div> <div className="eyebrow">{"Runtime Overview"}</div>
<h2>{t("dashboard.title")}</h2> <h2>{"Console Overview"}</h2>
</div> </div>
<div className="overview-metrics"> <div className="overview-metrics">
<StatusItem label={t("dashboard.readPath")} value={t("dashboard.readPathValue")} tone="neutral" /> <StatusItem label={"Read path"} value={"PG read-only"} tone="neutral" />
<StatusItem label={t("dashboard.writePath")} value="Admin API" tone="good" /> <StatusItem label={"Write path"} value="Admin API" tone="good" />
<StatusItem label={t("dashboard.executionPolicy")} value={t("dashboard.dryRunFirst")} tone="warn" /> <StatusItem label={"Execution policy"} value={"Dry-run first"} tone="warn" />
</div> </div>
</section> </section>
<div className="command-grid"> <div className="command-grid">
<Launcher icon={<Users />} title={t("route.accounts")} text={t("dashboard.accountsText")} href="/accounts" navigate={navigate} /> <Launcher icon={<Users />} title={"Accounts"} text={"Account status, premium, verification, sessions."} href="/accounts" navigate={navigate} />
<Launcher icon={<ShieldCheck />} title={t("route.channels")} text={t("dashboard.channelsText")} href="/channels" navigate={navigate} /> <Launcher icon={<ShieldCheck />} title={"Supergroups and Channels"} text={"Public entities, member counts, verification state."} href="/channels" navigate={navigate} />
<Launcher icon={<MessageSquareText />} title={t("route.messages")} text={t("dashboard.messagesText")} href="/messages" navigate={navigate} /> <Launcher icon={<MessageSquareText />} title={"Message Audit"} text={"Message boxes, updates, outbox state."} href="/messages" navigate={navigate} />
</div> </div>
<section className="work-strip"> <section className="work-strip">
<div className="strip-item"><CheckCircle2 size={16} /><span>{t("dashboard.strip.dryRun")}</span></div> <div className="strip-item"><CheckCircle2 size={16} /><span>{"All dangerous actions start with dry-run"}</span></div>
<div className="strip-item"><KeyRound size={16} /><span>{t("dashboard.strip.token")}</span></div> <div className="strip-item"><KeyRound size={16} /><span>{"Browser never stores internal tokens"}</span></div>
<div className="strip-item"><Clock3 size={16} /><span>{t("dashboard.strip.pagination")}</span></div> <div className="strip-item"><Clock3 size={16} /><span>{"Lists use cursor pagination"}</span></div>
<div className="strip-item"><FileJson size={16} /><span>{t("dashboard.strip.snapshot")}</span></div> <div className="strip-item"><FileJson size={16} /><span>{"Detail pages retain raw state snapshots"}</span></div>
</section> </section>
</div> </div>
); );

View file

@ -3,7 +3,6 @@ import { useEffect, useState } from "react";
import { api, errorMessage } from "../api"; import { api, errorMessage } from "../api";
import { StaticLottie } from "../components/StaticLottie"; import { StaticLottie } from "../components/StaticLottie";
import { Alert, Metric, PageFrame, QueryPanel } from "../components/ui"; import { Alert, Metric, PageFrame, QueryPanel } from "../components/ui";
import { useI18n } from "../i18n";
import type { EmojiListResponse, EmojiRow } from "../types"; import type { EmojiListResponse, EmojiRow } from "../types";
function formatBytes(value: number): string { function formatBytes(value: number): string {
@ -40,7 +39,6 @@ function EmojiPreview({ row }: { row: EmojiRow }) {
} }
function EmojiCard({ row }: { row: EmojiRow }) { function EmojiCard({ row }: { row: EmojiRow }) {
const { t } = useI18n();
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
async function copy() { async function copy() {
@ -58,18 +56,17 @@ function EmojiCard({ row }: { row: EmojiRow }) {
<div className="emoji-preview"><EmojiPreview row={row} /></div> <div className="emoji-preview"><EmojiPreview row={row} /></div>
<div className="emoji-meta"> <div className="emoji-meta">
<span className="emoji-alt">{row.Alt || "—"}</span> <span className="emoji-alt">{row.Alt || "—"}</span>
<button className="emoji-id" type="button" onClick={copy} title={t("emoji.copyID")}> <button className="emoji-id" type="button" onClick={copy} title={"Copy document ID"}>
<span className="mono">{row.DocumentID}</span> <span className="mono">{row.DocumentID}</span>
{copied ? <Check size={12} /> : <Copy size={12} />} {copied ? <Check size={12} /> : <Copy size={12} />}
</button> </button>
<span className="emoji-sub">{row.SetTitle || t("emoji.noSet")} · {formatBytes(row.Size)}</span> <span className="emoji-sub">{row.SetTitle || "No set"} · {formatBytes(row.Size)}</span>
</div> </div>
</div> </div>
); );
} }
export function EmojiPage() { export function EmojiPage() {
const { t } = useI18n();
const [q, setQ] = useState(""); const [q, setQ] = useState("");
const [data, setData] = useState<EmojiListResponse | null>(null); const [data, setData] = useState<EmojiListResponse | null>(null);
const [cursor, setCursor] = useState(0); const [cursor, setCursor] = useState(0);
@ -104,37 +101,37 @@ export function EmojiPage() {
return ( return (
<PageFrame <PageFrame
title={t("emoji.pageTitle")} title={"Custom Emoji"}
eyebrow={data?.listing === false ? t("emoji.queryResults") : t("emoji.recent")} eyebrow={data?.listing === false ? "Search results" : "Custom emoji catalog"}
actions={ actions={
<button className="btn" type="button" onClick={() => load(false)} disabled={busy}> <button className="btn" type="button" onClick={() => load(false)} disabled={busy}>
<RefreshCw size={15} /> {t("common.refresh")} <RefreshCw size={15} /> {"Refresh"}
</button> </button>
} }
> >
{error && <Alert>{error}</Alert>} {error && <Alert>{error}</Alert>}
<div className="metric-row"> <div className="metric-row">
<Metric label={t("emoji.currentPage")} value={String(rows.length)} /> <Metric label={"Emoji on page"} value={String(rows.length)} />
</div> </div>
<QueryPanel> <QueryPanel>
<form className="toolbar" onSubmit={(event) => { event.preventDefault(); void load(false); }}> <form className="toolbar" onSubmit={(event) => { event.preventDefault(); void load(false); }}>
<label className="searchbox"> <label className="searchbox">
<Search size={15} /> <Search size={15} />
<input value={q} onChange={(event) => setQ(event.target.value)} placeholder={t("emoji.searchPlaceholder")} /> <input value={q} onChange={(event) => setQ(event.target.value)} placeholder={"Document ID or emoji"} />
</label> </label>
<button className="btn primary icon-text" type="submit" disabled={busy}> <button className="btn primary icon-text" type="submit" disabled={busy}>
{busy ? <Loader2 size={15} className="spin" /> : <Search size={15} />} {t("common.search")} {busy ? <Loader2 size={15} className="spin" /> : <Search size={15} />} {"Search"}
</button> </button>
{data?.listing && data.has_more && ( {data?.listing && data.has_more && (
<button className="btn icon-text" type="button" onClick={() => load(true)} disabled={busy}> <button className="btn icon-text" type="button" onClick={() => load(true)} disabled={busy}>
<ChevronRight size={15} /> {t("messages.nextPage")} <ChevronRight size={15} /> {"Next page"}
</button> </button>
)} )}
</form> </form>
</QueryPanel> </QueryPanel>
<p className="about-text">{t("emoji.hint")}</p> <p className="about-text">{"Document IDs here can be pasted into the Emoji status field on account, bot and channel profiles."}</p>
{rows.length === 0 ? ( {rows.length === 0 ? (
<div className="empty-panel">{t("common.noResults")}</div> <div className="empty-panel">{"No results"}</div>
) : ( ) : (
<div className="emoji-grid"> <div className="emoji-grid">
{rows.map((row) => <EmojiCard key={row.DocumentID} row={row} />)} {rows.map((row) => <EmojiCard key={row.DocumentID} row={row} />)}

View file

@ -4,7 +4,6 @@ import { useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { api, errorMessage } from "../api"; import { api, errorMessage } from "../api";
import { Alert, Badge } from "../components/ui"; import { Alert, Badge } from "../components/ui";
import { useI18n } from "../i18n";
import type { CommandResult, StarGiftCollectibleAttributeRow, StarGiftCollectiblePreview, StarGiftRow } from "../types"; import type { CommandResult, StarGiftCollectibleAttributeRow, StarGiftCollectiblePreview, StarGiftRow } from "../types";
type AnimationData = Record<string, unknown>; type AnimationData = Record<string, unknown>;
@ -106,8 +105,25 @@ async function parseAnimationFile(file: File): Promise<AnimationData> {
const colorNumber = (value: string) => Number.parseInt(value.replace("#", ""), 16); const colorNumber = (value: string) => Number.parseInt(value.replace("#", ""), 16);
const rarityLabel = (attribute: StarGiftCollectibleAttributeRow) => attribute.rarity_kind === "permille" ? `${attribute.rarity_permille}` : attribute.rarity_kind; const rarityLabel = (attribute: StarGiftCollectibleAttributeRow) => attribute.rarity_kind === "permille" ? `${attribute.rarity_permille}` : attribute.rarity_kind;
const collectibleGroupLabels: Record<"models" | "patterns", string> = {
models: "Models",
patterns: "Patterns"
};
const collectibleAttributeLabels: Record<"model" | "pattern" | "backdrop", string> = {
model: "Model",
pattern: "Pattern",
backdrop: "Backdrop"
};
const collectibleColorLabels: Record<"center" | "edge" | "pattern" | "text", string> = {
center: "Center",
edge: "Edge",
pattern: "Pattern",
text: "Text"
};
export function GiftCollectiblesModal({ gift, onClose, onPublished }: { gift: StarGiftRow; onClose: () => void; onPublished: () => void }) { export function GiftCollectiblesModal({ gift, onClose, onPublished }: { gift: StarGiftRow; onClose: () => void; onPublished: () => void }) {
const { t } = useI18n();
const [active, setActive] = useState<StarGiftCollectiblePreview | null>(null); const [active, setActive] = useState<StarGiftCollectiblePreview | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
@ -160,11 +176,11 @@ export function GiftCollectiblesModal({ gift, onClose, onPublished }: { gift: St
} }
function buildForm(confirm: boolean, commandID = "") { function buildForm(confirm: boolean, commandID = "") {
if (!reason.trim()) throw new Error(t("action.reasonRequired")); if (!reason.trim()) throw new Error("Please enter an operation reason");
if (models.length < 2 || patterns.length < 2 || backdrops.length < 2) throw new Error(t("collectibles.minimumAttributes")); if (models.length < 2 || patterns.length < 2 || backdrops.length < 2) throw new Error("Models, patterns, and backdrops must each contain at least two attributes.");
const backdropIDs = backdrops.map((row) => Number(row.backdropID)); const backdropIDs = backdrops.map((row) => Number(row.backdropID));
if (new Set(backdropIDs).size !== backdropIDs.length) throw new Error(t("collectibles.duplicateBackdropID")); if (new Set(backdropIDs).size !== backdropIDs.length) throw new Error("Backdrop IDs must be unique within the pool.");
for (const row of [...models, ...patterns]) if (!row.file) throw new Error(t("collectibles.fileRequired")); for (const row of [...models, ...patterns]) if (!row.file) throw new Error("Every model and pattern needs a TGS or Lottie file.");
const form = new FormData(); const form = new FormData();
const animatedMetadata = (rows: AnimatedDraft[]) => rows.map((row) => ({ name: row.name.trim(), rarity_permille: Number(row.rarity), sort_order: Number(row.sortOrder), file_key: row.key })); const animatedMetadata = (rows: AnimatedDraft[]) => rows.map((row) => ({ name: row.name.trim(), rarity_permille: Number(row.rarity), sort_order: Number(row.sortOrder), file_key: row.key }));
form.set("metadata", JSON.stringify({ form.set("metadata", JSON.stringify({
@ -200,18 +216,18 @@ export function GiftCollectiblesModal({ gift, onClose, onPublished }: { gift: St
const renderAnimatedRows = (kind: "models" | "patterns", rows: AnimatedDraft[], setRows: (rows: AnimatedDraft[]) => void) => ( const renderAnimatedRows = (kind: "models" | "patterns", rows: AnimatedDraft[], setRows: (rows: AnimatedDraft[]) => void) => (
<section className="collectible-section"> <section className="collectible-section">
<div className="collectible-section-head"> <div className="collectible-section-head">
<div><strong>{t(`collectibles.${kind}`)}</strong><span>{t("collectibles.rarityHint")}</span></div> <div><strong>{collectibleGroupLabels[kind]}</strong><span>{"Permille values are relative regular-upgrade weights; their total does not need to equal 1000."}</span></div>
<div className="collectible-section-tools"><Badge tone={rarityTotals[kind] > 0 ? "good" : "neutral"}>{rarityTotals[kind]}</Badge><button className="btn compact-btn" type="button" onClick={() => { setRows(rebalanceRarity([...rows, newAnimated(kind === "models" ? "model" : "pattern", rows.length)])); invalidate(); }}><Plus size={13} />{t("collectibles.addAttribute")}</button></div> <div className="collectible-section-tools"><Badge tone={rarityTotals[kind] > 0 ? "good" : "neutral"}>{rarityTotals[kind]}</Badge><button className="btn compact-btn" type="button" onClick={() => { setRows(rebalanceRarity([...rows, newAnimated(kind === "models" ? "model" : "pattern", rows.length)])); invalidate(); }}><Plus size={13} />{"Add"}</button></div>
</div> </div>
<div className="collectible-rows"> <div className="collectible-rows">
{rows.map((row, index) => <div className="collectible-row animated" key={row.key}> {rows.map((row, index) => <div className="collectible-row animated" key={row.key}>
<div className="collectible-row-index">{index + 1}</div> <div className="collectible-row-index">{index + 1}</div>
<label><span>{t("common.name")}</span><input value={row.name} maxLength={128} onChange={(e) => updateAnimated(kind, row.key, { name: e.target.value })} /></label> <label><span>{"Name"}</span><input value={row.name} maxLength={128} onChange={(e) => updateAnimated(kind, row.key, { name: e.target.value })} /></label>
<label><span>{t("collectibles.rarity")}</span><input type="number" min="1" max="1000" value={row.rarity} onChange={(e) => updateAnimated(kind, row.key, { rarity: e.target.value })} /></label> <label><span>{"Rarity ‰"}</span><input type="number" min="1" max="1000" value={row.rarity} onChange={(e) => updateAnimated(kind, row.key, { rarity: e.target.value })} /></label>
<label><span>{t("gifts.sortOrder")}</span><input type="number" value={row.sortOrder} onChange={(e) => updateAnimated(kind, row.key, { sortOrder: e.target.value })} /></label> <label><span>{"Sort order"}</span><input type="number" value={row.sortOrder} onChange={(e) => updateAnimated(kind, row.key, { sortOrder: e.target.value })} /></label>
<label className="collectible-file"><span>{t("gifts.animation")}</span><input type="file" accept=".tgs,.json,.lottie,application/json,application/x-tgsticker" onChange={(e) => void chooseFile(kind, row, e.target.files?.[0] ?? null)} /><em><FileJson2 size={13} />{row.file?.name ?? t("gifts.chooseFile")}</em></label> <label className="collectible-file"><span>{"Animation file"}</span><input type="file" accept=".tgs,.json,.lottie,application/json,application/x-tgsticker" onChange={(e) => void chooseFile(kind, row, e.target.files?.[0] ?? null)} /><em><FileJson2 size={13} />{row.file?.name ?? "Choose file"}</em></label>
<div className="collectible-inline-preview">{row.animation ? <AnimationPreview data={row.animation} compact /> : <Sparkles size={16} />}</div> <div className="collectible-inline-preview">{row.animation ? <AnimationPreview data={row.animation} compact /> : <Sparkles size={16} />}</div>
<button className="icon-btn danger" type="button" disabled={rows.length <= 2} onClick={() => { setRows(rebalanceRarity(rows.filter((value) => value.key !== row.key))); invalidate(); }} aria-label={t("collectibles.remove")}><Trash2 size={14} /></button> <button className="icon-btn danger" type="button" disabled={rows.length <= 2} onClick={() => { setRows(rebalanceRarity(rows.filter((value) => value.key !== row.key))); invalidate(); }} aria-label={"Remove attribute"}><Trash2 size={14} /></button>
{row.fileError && <span className="collectible-file-error">{row.fileError}</span>} {row.fileError && <span className="collectible-file-error">{row.fileError}</span>}
</div>)} </div>)}
</div> </div>
@ -219,51 +235,51 @@ export function GiftCollectiblesModal({ gift, onClose, onPublished }: { gift: St
); );
return createPortal(<div className="modal-backdrop" role="presentation"> return createPortal(<div className="modal-backdrop" role="presentation">
<section className="modal command-modal collectible-modal" role="dialog" aria-modal="true" aria-label={t("collectibles.title", { id: gift.GiftID })}> <section className="modal command-modal collectible-modal" role="dialog" aria-modal="true" aria-label={`Collectible pool · Gift #${gift.GiftID}`}>
<div className="modal-head"> <div className="modal-head">
<div><div className="eyebrow">{t("collectibles.eyebrow")}</div><h2>{t("collectibles.title", { id: gift.GiftID })}</h2><p>{gift.Title || `Gift #${gift.GiftID}`}</p></div> <div><div className="eyebrow">{"Unique gift attributes"}</div><h2>{`Collectible pool · Gift #${gift.GiftID}`}</h2><p>{gift.Title || `Gift #${gift.GiftID}`}</p></div>
<button className="icon-btn" type="button" onClick={onClose} disabled={busy} aria-label={t("action.close")}><X size={15} /></button> <button className="icon-btn" type="button" onClick={onClose} disabled={busy} aria-label={"Close"}><X size={15} /></button>
</div> </div>
<div className="command-body collectible-modal-body"> <div className="command-body collectible-modal-body">
{loading ? <div className="collectible-loading"><Loader2 className="spin" />{t("common.loading")}</div> : active?.found ? <section className="collectible-active"> {loading ? <div className="collectible-loading"><Loader2 className="spin" />{"Loading"}</div> : active?.found ? <section className="collectible-active">
<div className="collectible-active-head"><div><Gem size={18} /><div><strong>{t("collectibles.activeRevision", { revision: active.revision ?? 0 })}</strong><span>{active.slug_prefix} · {active.upgrade_stars} · {active.issued} / {active.supply_total}</span></div></div><Badge tone="good">{t("collectibles.published")}</Badge></div> <div className="collectible-active-head"><div><Gem size={18} /><div><strong>{`Published revision ${active.revision ?? 0}`}</strong><span>{active.slug_prefix} · {active.upgrade_stars} · {active.issued} / {active.supply_total}</span></div></div><Badge tone="good">{"Published"}</Badge></div>
<div className="collectible-active-grid"> <div className="collectible-active-grid">
{[...(active.models ?? []), ...(active.patterns ?? [])].map((attribute) => <article key={`${attribute.kind}-${attribute.id}`}><RemoteAnimation giftID={gift.GiftID} attribute={attribute} /><div><strong>{attribute.name}{attribute.crafted && <Badge>crafted</Badge>}</strong><span>{t(`collectibles.${attribute.kind}`)} · {rarityLabel(attribute)}</span></div></article>)} {[...(active.models ?? []), ...(active.patterns ?? [])].map((attribute) => <article key={`${attribute.kind}-${attribute.id}`}><RemoteAnimation giftID={gift.GiftID} attribute={attribute} /><div><strong>{attribute.name}{attribute.crafted && <Badge>crafted</Badge>}</strong><span>{collectibleAttributeLabels[attribute.kind]} · {rarityLabel(attribute)}</span></div></article>)}
{(active.backdrops ?? []).map((attribute) => <article key={`backdrop-${attribute.id}`}><div className="collectible-backdrop-preview" style={{ background: `radial-gradient(circle, #${(attribute.center_color ?? 0).toString(16).padStart(6, "0")}, #${(attribute.edge_color ?? 0).toString(16).padStart(6, "0")})`, color: `#${(attribute.text_color ?? 0xffffff).toString(16).padStart(6, "0")}` }}>Aa</div><div><strong>{attribute.name}</strong><span>{t("collectibles.backdrop")} · {rarityLabel(attribute)}</span></div></article>)} {(active.backdrops ?? []).map((attribute) => <article key={`backdrop-${attribute.id}`}><div className="collectible-backdrop-preview" style={{ background: `radial-gradient(circle, #${(attribute.center_color ?? 0).toString(16).padStart(6, "0")}, #${(attribute.edge_color ?? 0).toString(16).padStart(6, "0")})`, color: `#${(attribute.text_color ?? 0xffffff).toString(16).padStart(6, "0")}` }}>Aa</div><div><strong>{attribute.name}</strong><span>{"Backdrop"} · {rarityLabel(attribute)}</span></div></article>)}
</div> </div>
</section> : <div className="collectible-empty"><Gem size={22} /><div><strong>{t("collectibles.noPool")}</strong><span>{t("collectibles.noPoolHint")}</span></div></div>} </section> : <div className="collectible-empty"><Gem size={22} /><div><strong>{"No collectible pool published"}</strong><span>{"Publish models, patterns and backdrops to enable upgrades."}</span></div></div>}
<section className="collectible-definition"> <section className="collectible-definition">
<div className="collectible-definition-head"><div><strong>{t("collectibles.publishNew")}</strong><span>{t("collectibles.immutableHint")}</span></div><div className="gift-format-chips"><span>TGS</span><span>Lottie JSON</span></div></div> <div className="collectible-definition-head"><div><strong>{"Publish a new immutable revision"}</strong><span>{"Dry-run checks every file and rarity total before the revision becomes active."}</span></div><div className="gift-format-chips"><span>TGS</span><span>Lottie JSON</span></div></div>
<div className="gift-fields-grid collectible-main-fields"> <div className="gift-fields-grid collectible-main-fields">
<label><span>{t("collectibles.upgradeStars")}</span><input type="number" min="1" value={upgradeStars} onChange={(e) => { setUpgradeStars(e.target.value); invalidate(); }} /></label> <label><span>{"Upgrade price in Stars"}</span><input type="number" min="1" value={upgradeStars} onChange={(e) => { setUpgradeStars(e.target.value); invalidate(); }} /></label>
<label><span>{t("collectibles.supply")}</span><input type="number" min="1" value={supplyTotal} onChange={(e) => { setSupplyTotal(e.target.value); invalidate(); }} /></label> <label><span>{"Unique supply"}</span><input type="number" min="1" value={supplyTotal} onChange={(e) => { setSupplyTotal(e.target.value); invalidate(); }} /></label>
<label><span>{t("collectibles.slug")}</span><input value={slugPrefix} maxLength={48} onChange={(e) => { setSlugPrefix(e.target.value.toLowerCase()); invalidate(); }} /></label> <label><span>{"Public slug prefix"}</span><input value={slugPrefix} maxLength={48} onChange={(e) => { setSlugPrefix(e.target.value.toLowerCase()); invalidate(); }} /></label>
<label><span>{t("gifts.reason")}</span><input value={reason} maxLength={1000} placeholder={t("gifts.reasonPlaceholder")} onChange={(e) => setReason(e.target.value)} /></label> <label><span>{"Audit reason"}</span><input value={reason} maxLength={1000} placeholder={"Briefly describe why this gift is being imported"} onChange={(e) => setReason(e.target.value)} /></label>
</div> </div>
{renderAnimatedRows("models", models, setModels)} {renderAnimatedRows("models", models, setModels)}
{renderAnimatedRows("patterns", patterns, setPatterns)} {renderAnimatedRows("patterns", patterns, setPatterns)}
<section className="collectible-section"> <section className="collectible-section">
<div className="collectible-section-head"><div><strong>{t("collectibles.backdrops")}</strong><span>{t("collectibles.colorHint")}</span></div><div className="collectible-section-tools"><Badge tone={rarityTotals.backdrops > 0 ? "good" : "neutral"}>{rarityTotals.backdrops}</Badge><button className="btn compact-btn" type="button" onClick={() => { setBackdrops(rebalanceRarity([...backdrops, newBackdrop(backdrops)])); invalidate(); }}><Plus size={13} />{t("collectibles.addAttribute")}</button></div></div> <div className="collectible-section-head"><div><strong>{"Backdrops"}</strong><span>{"Colors are stored as 24-bit RGB values."}</span></div><div className="collectible-section-tools"><Badge tone={rarityTotals.backdrops > 0 ? "good" : "neutral"}>{rarityTotals.backdrops}</Badge><button className="btn compact-btn" type="button" onClick={() => { setBackdrops(rebalanceRarity([...backdrops, newBackdrop(backdrops)])); invalidate(); }}><Plus size={13} />{"Add"}</button></div></div>
<div className="collectible-rows">{backdrops.map((row, index) => <div className="collectible-row backdrop" key={row.key}> <div className="collectible-rows">{backdrops.map((row, index) => <div className="collectible-row backdrop" key={row.key}>
<div className="collectible-row-index">{index + 1}</div> <div className="collectible-row-index">{index + 1}</div>
<label><span>{t("common.name")}</span><input value={row.name} maxLength={128} onChange={(e) => { setBackdrops(backdrops.map((value) => value.key === row.key ? { ...value, name: e.target.value } : value)); invalidate(); }} /></label> <label><span>{"Name"}</span><input value={row.name} maxLength={128} onChange={(e) => { setBackdrops(backdrops.map((value) => value.key === row.key ? { ...value, name: e.target.value } : value)); invalidate(); }} /></label>
<label><span>{t("collectibles.backdropID")}</span><input type="number" min="0" value={row.backdropID} onChange={(e) => { setBackdrops(backdrops.map((value) => value.key === row.key ? { ...value, backdropID: e.target.value } : value)); invalidate(); }} /></label> <label><span>{"Backdrop ID"}</span><input type="number" min="0" value={row.backdropID} onChange={(e) => { setBackdrops(backdrops.map((value) => value.key === row.key ? { ...value, backdropID: e.target.value } : value)); invalidate(); }} /></label>
<label><span>{t("collectibles.rarity")}</span><input type="number" min="1" max="1000" value={row.rarity} onChange={(e) => { setBackdrops(backdrops.map((value) => value.key === row.key ? { ...value, rarity: e.target.value } : value)); invalidate(); }} /></label> <label><span>{"Rarity ‰"}</span><input type="number" min="1" max="1000" value={row.rarity} onChange={(e) => { setBackdrops(backdrops.map((value) => value.key === row.key ? { ...value, rarity: e.target.value } : value)); invalidate(); }} /></label>
<label><span>{t("gifts.sortOrder")}</span><input type="number" value={row.sortOrder} onChange={(e) => { setBackdrops(backdrops.map((value) => value.key === row.key ? { ...value, sortOrder: e.target.value } : value)); invalidate(); }} /></label> <label><span>{"Sort order"}</span><input type="number" value={row.sortOrder} onChange={(e) => { setBackdrops(backdrops.map((value) => value.key === row.key ? { ...value, sortOrder: e.target.value } : value)); invalidate(); }} /></label>
{(["center", "edge", "pattern", "text"] as const).map((field) => <label className="collectible-color" key={field}><span>{t(`collectibles.color.${field}`)}</span><input type="color" value={row[field]} onChange={(e) => { setBackdrops(backdrops.map((value) => value.key === row.key ? { ...value, [field]: e.target.value } : value)); invalidate(); }} /></label>)} {(["center", "edge", "pattern", "text"] as const).map((field) => <label className="collectible-color" key={field}><span>{collectibleColorLabels[field]}</span><input type="color" value={row[field]} onChange={(e) => { setBackdrops(backdrops.map((value) => value.key === row.key ? { ...value, [field]: e.target.value } : value)); invalidate(); }} /></label>)}
<div className="collectible-backdrop-preview" style={{ background: `radial-gradient(circle, ${row.center}, ${row.edge})`, color: row.text }}>Aa</div> <div className="collectible-backdrop-preview" style={{ background: `radial-gradient(circle, ${row.center}, ${row.edge})`, color: row.text }}>Aa</div>
<button className="icon-btn danger" type="button" disabled={backdrops.length <= 2} onClick={() => { setBackdrops(rebalanceRarity(backdrops.filter((value) => value.key !== row.key))); invalidate(); }} aria-label={t("collectibles.remove")}><Trash2 size={14} /></button> <button className="icon-btn danger" type="button" disabled={backdrops.length <= 2} onClick={() => { setBackdrops(rebalanceRarity(backdrops.filter((value) => value.key !== row.key))); invalidate(); }} aria-label={"Remove attribute"}><Trash2 size={14} /></button>
</div>)}</div> </div>)}</div>
</section> </section>
</section> </section>
{error && <Alert>{error}</Alert>} {error && <Alert>{error}</Alert>}
{preview && <div className="gift-validation"><div className="gift-validation-head"><CheckCircle2 size={17} /><div><strong>{t("collectibles.validationReady")}</strong><span>{t("collectibles.validationHint")}</span></div></div><pre>{JSON.stringify(preview.details, null, 2)}</pre></div>} {preview && <div className="gift-validation"><div className="gift-validation-head"><CheckCircle2 size={17} /><div><strong>{"Attribute pool is valid"}</strong><span>{"Review the normalized assets, then publish this immutable revision."}</span></div></div><pre>{JSON.stringify(preview.details, null, 2)}</pre></div>}
</div> </div>
<div className="modal-actions"> <div className="modal-actions">
<button className="btn" type="button" onClick={onClose} disabled={busy}>{t("common.close")}</button> <button className="btn" type="button" onClick={onClose} disabled={busy}>{"Close"}</button>
<button className="btn" type="button" onClick={validate} disabled={busy}>{busy ? <Loader2 className="spin" size={15} /> : <ShieldCheck size={15} />}{t("gifts.validate")}</button> <button className="btn" type="button" onClick={validate} disabled={busy}>{busy ? <Loader2 className="spin" size={15} /> : <ShieldCheck size={15} />}{"Dry-run validation"}</button>
<button className="btn primary" type="button" onClick={publish} disabled={busy || !preview}><Upload size={15} />{t("collectibles.publish")}</button> <button className="btn primary" type="button" onClick={publish} disabled={busy || !preview}><Upload size={15} />{"Publish revision"}</button>
</div> </div>
</section> </section>
</div>, document.body); </div>, document.body);

View file

@ -5,7 +5,6 @@ import { createPortal } from "react-dom";
import { api, APIError, errorMessage } from "../api"; import { api, APIError, errorMessage } from "../api";
import { ActionButton } from "../components/ActionButton"; import { ActionButton } from "../components/ActionButton";
import { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel } from "../components/ui"; import { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel } from "../components/ui";
import { useI18n } from "../i18n";
import { formatDate } from "../lib/format"; import { formatDate } from "../lib/format";
import type { CommandResult, DefaultGiftRow, OfficialStarGiftRow, StarGiftRow } from "../types"; import type { CommandResult, DefaultGiftRow, OfficialStarGiftRow, StarGiftRow } from "../types";
import { GiftCollectiblesModal } from "./GiftCollectiblesModal"; import { GiftCollectiblesModal } from "./GiftCollectiblesModal";
@ -13,6 +12,13 @@ import { GiftCollectiblesModal } from "./GiftCollectiblesModal";
type OfficialGiftCategory = "all" | "upgrade" | "craft" | "basic"; type OfficialGiftCategory = "all" | "upgrade" | "craft" | "basic";
type GiftPageSize = 10 | 20 | 50 | 100 | "all"; type GiftPageSize = 10 | 20 | 50 | 100 | "all";
const officialCategoryLabels: Record<OfficialGiftCategory, string> = {
all: "All",
upgrade: "Upgradable",
craft: "Craftable",
basic: "Not upgradable"
};
// The demo pool only has 3 placeholder gifts left after pruning to one per // The demo pool only has 3 placeholder gifts left after pruning to one per
// capability tier (Spark/Star/Coin); hide the tab until real custom designs // capability tier (Spark/Star/Coin); hide the tab until real custom designs
// replace them. Flip back to true to re-enable. // replace them. Flip back to true to re-enable.
@ -105,7 +111,6 @@ function OfficialLottiePreview({ sourceGiftID }: { sourceGiftID: string }) {
} }
export function GiftsPage() { export function GiftsPage() {
const { t } = useI18n();
const [gifts, setGifts] = useState<StarGiftRow[]>([]); const [gifts, setGifts] = useState<StarGiftRow[]>([]);
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [importOpen, setImportOpen] = useState(false); const [importOpen, setImportOpen] = useState(false);
@ -236,7 +241,7 @@ export function GiftsPage() {
async function bulkSetEnabled(nextEnabled: boolean) { async function bulkSetEnabled(nextEnabled: boolean) {
if (!bulkReason.trim()) { if (!bulkReason.trim()) {
setBulkError(t("action.reasonRequired")); setBulkError("Please enter an operation reason");
return; return;
} }
setBulkBusy(true); setBulkBusy(true);
@ -257,7 +262,7 @@ export function GiftsPage() {
} }
setBulkBusy(false); setBulkBusy(false);
if (failed > 0) { if (failed > 0) {
setBulkError(t("gifts.bulkStatusFailed", { failed, total: ids.length })); setBulkError(`${failed} of ${ids.length} failed`);
} else { } else {
setSelected(new Set()); setSelected(new Set());
setBulkReason(""); setBulkReason("");
@ -266,8 +271,8 @@ export function GiftsPage() {
} }
function uploadForm(confirm: boolean, commandID = "") { function uploadForm(confirm: boolean, commandID = "") {
if (!file) throw new Error(t("gifts.fileRequired")); if (!file) throw new Error("Choose a TGS or Lottie file first");
if (!reason.trim()) throw new Error(t("action.reasonRequired")); if (!reason.trim()) throw new Error("Please enter an operation reason");
const form = new FormData(); const form = new FormData();
form.set("metadata", JSON.stringify({ form.set("metadata", JSON.stringify({
command_id: commandID, command_id: commandID,
@ -285,14 +290,14 @@ export function GiftsPage() {
} }
function defaultPayload(confirm: boolean, commandID = "") { function defaultPayload(confirm: boolean, commandID = "") {
if (!selectedDefaultID) throw new Error(t("gifts.defaultRequired")); if (!selectedDefaultID) throw new Error("Choose a default gift first");
if (!reason.trim()) throw new Error(t("action.reasonRequired")); if (!reason.trim()) throw new Error("Please enter an operation reason");
return { command_id: commandID, reason: reason.trim(), confirm, id: selectedDefaultID }; return { command_id: commandID, reason: reason.trim(), confirm, id: selectedDefaultID };
} }
function officialPayload(confirm: boolean, commandID = "") { function officialPayload(confirm: boolean, commandID = "") {
if (!sourceGiftID) throw new Error(t("gifts.officialRequired")); if (!sourceGiftID) throw new Error("Choose an official gift first");
if (!reason.trim()) throw new Error(t("action.reasonRequired")); if (!reason.trim()) throw new Error("Please enter an operation reason");
return { return {
command_id: commandID, reason: reason.trim(), confirm, command_id: commandID, reason: reason.trim(), confirm,
source_gift_id: sourceGiftID, gift_id: giftID, title: title.trim(), source_gift_id: sourceGiftID, gift_id: giftID, title: title.trim(),
@ -304,7 +309,7 @@ export function GiftsPage() {
function chooseOfficial(gift: OfficialStarGiftRow) { function chooseOfficial(gift: OfficialStarGiftRow) {
setSourceGiftID(gift.source_gift_id); setSourceGiftID(gift.source_gift_id);
setTitle(gift.title || t("gifts.officialUnnamed", { id: gift.source_gift_id })); setTitle(gift.title || `Unnamed official gift #${gift.source_gift_id}`);
setStars(String(gift.stars)); setStars(String(gift.stars));
setConvertStars(String(gift.convert_stars)); setConvertStars(String(gift.convert_stars));
setIncludeCollectible(gift.can_upgrade); setIncludeCollectible(gift.can_upgrade);
@ -343,7 +348,7 @@ export function GiftsPage() {
async function runBulkImport() { async function runBulkImport() {
if (!bulkImportOpen) return; if (!bulkImportOpen) return;
if (!bulkImportReason.trim()) { setBulkImportError(t("action.reasonRequired")); return; } if (!bulkImportReason.trim()) { setBulkImportError("Please enter an operation reason"); return; }
const source = bulkImportOpen; const source = bulkImportOpen;
setBulkImportBusy(true); setBulkImportError(""); setBulkImportResult(null); setBulkImportBusy(true); setBulkImportError(""); setBulkImportResult(null);
setBulkImportProgress({ done: 0, total: bulkImportItems.length }); setBulkImportProgress({ done: 0, total: bulkImportItems.length });
@ -440,60 +445,60 @@ export function GiftsPage() {
: Boolean(file); : Boolean(file);
return ( return (
<PageFrame title={t("gifts.pageTitle")} eyebrow={t("gifts.eyebrow")} actions={<> <PageFrame title={"Star Gift Catalog"} eyebrow={"Catalog, immutable revisions and animation assets"} actions={<>
<button className="btn" type="button" onClick={() => load()} disabled={busy}><RefreshCw size={15} /> {t("common.refresh")}</button> <button className="btn" type="button" onClick={() => load()} disabled={busy}><RefreshCw size={15} /> {"Refresh"}</button>
<button className="btn primary" type="button" onClick={startImport}><Plus size={15} /> {t("gifts.add")}</button> <button className="btn primary" type="button" onClick={startImport}><Plus size={15} /> {"Add gift"}</button>
</>}> </>}>
{error && <Alert>{error}</Alert>} {error && <Alert>{error}</Alert>}
<div className="metric-row gift-metrics"> <div className="metric-row gift-metrics">
<Metric label={t("gifts.total")} value={String(gifts.length)} /> <Metric label={"Catalog entries"} value={String(gifts.length)} />
<Metric label={t("gifts.enabled")} value={String(gifts.filter((gift) => gift.Enabled).length)} tone="good" /> <Metric label={"Enabled"} value={String(gifts.filter((gift) => gift.Enabled).length)} tone="good" />
<Metric label={t("gifts.received")} value={gifts.reduce((sum, gift) => sum + BigInt(gift.ReceivedCount), 0n).toString()} /> <Metric label={"Received gifts"} value={gifts.reduce((sum, gift) => sum + BigInt(gift.ReceivedCount), 0n).toString()} />
<Metric label={t("gifts.formats")} value="TGS / Lottie" /> <Metric label={"Accepted formats"} value="TGS / Lottie" />
</div> </div>
<QueryPanel> <QueryPanel>
<div className="toolbar"> <div className="toolbar">
<label className="searchbox"><Search size={15} /><input value={query} onChange={(event) => setQuery(event.target.value)} placeholder={t("gifts.searchPlaceholder")} /></label> <label className="searchbox"><Search size={15} /><input value={query} onChange={(event) => setQuery(event.target.value)} placeholder={"Search gift ID, title or format"} /></label>
<label className="gift-page-size"><span>{t("gifts.perPage")}</span> <label className="gift-page-size"><span>{"Per page"}</span>
<select value={String(pageSize)} onChange={(event) => setPageSize(event.target.value === "all" ? "all" : (Number(event.target.value) as GiftPageSize))}> <select value={String(pageSize)} onChange={(event) => setPageSize(event.target.value === "all" ? "all" : (Number(event.target.value) as GiftPageSize))}>
<option value="10">10</option> <option value="10">10</option>
<option value="20">20</option> <option value="20">20</option>
<option value="50">50</option> <option value="50">50</option>
<option value="100">100</option> <option value="100">100</option>
<option value="all">{t("gifts.perPageAll")}</option> <option value="all">{"All"}</option>
</select> </select>
</label> </label>
<span className="gift-list-summary">{t("gifts.listSummary", { shown: visibleGifts.length, total: gifts.length })}</span> <span className="gift-list-summary">{`Showing ${visibleGifts.length} of ${gifts.length}`}</span>
</div> </div>
</QueryPanel> </QueryPanel>
{selected.size > 0 && <div className="gift-bulk-toolbar"> {selected.size > 0 && <div className="gift-bulk-toolbar">
<span className="gift-bulk-count">{t("gifts.bulkSelected", { count: selected.size })}</span> <span className="gift-bulk-count">{`${selected.size} selected`}</span>
<label className="gift-reason-field gift-bulk-reason"><span>{t("gifts.reason")}</span><input value={bulkReason} placeholder={t("gifts.reasonPlaceholder")} onChange={(e) => setBulkReason(e.target.value)} /></label> <label className="gift-reason-field gift-bulk-reason"><span>{"Audit reason"}</span><input value={bulkReason} placeholder={"Briefly describe why this gift is being imported"} onChange={(e) => setBulkReason(e.target.value)} /></label>
<button className="btn" type="button" onClick={() => bulkSetEnabled(true)} disabled={bulkBusy}> <button className="btn" type="button" onClick={() => bulkSetEnabled(true)} disabled={bulkBusy}>
{bulkBusy ? <Loader2 className="spin" size={14} /> : <CheckCircle2 size={14} />} {t("gifts.bulkEnable")} {bulkBusy ? <Loader2 className="spin" size={14} /> : <CheckCircle2 size={14} />} {"Enable selected"}
</button> </button>
<button className="btn" type="button" onClick={() => bulkSetEnabled(false)} disabled={bulkBusy}> <button className="btn" type="button" onClick={() => bulkSetEnabled(false)} disabled={bulkBusy}>
{bulkBusy ? <Loader2 className="spin" size={14} /> : <Pause size={14} />} {t("gifts.bulkDisable")} {bulkBusy ? <Loader2 className="spin" size={14} /> : <Pause size={14} />} {"Disable selected"}
</button> </button>
<button className="btn" type="button" onClick={() => { setSelected(new Set()); setBulkError(""); }} disabled={bulkBusy}>{t("common.close")}</button> <button className="btn" type="button" onClick={() => { setSelected(new Set()); setBulkError(""); }} disabled={bulkBusy}>{"Close"}</button>
{bulkError && <span className="gift-bulk-error">{bulkError}</span>} {bulkError && <span className="gift-bulk-error">{bulkError}</span>}
</div>} </div>}
<div className="table-wrap gift-table-wrap"> <div className="table-wrap gift-table-wrap">
<table className="data-table gift-table"> <table className="data-table gift-table">
<thead><tr><th className="gift-select-col"><input type="checkbox" checked={allVisibleSelected} onChange={toggleSelectAllVisible} aria-label={t("gifts.bulkSelectAll")} /></th><th>{t("gifts.animation")}</th><th>{t("gifts.idRevision")}</th><th>{t("gifts.title")}</th><th>{t("gifts.price")}</th><th>{t("gifts.source")}</th><th>{t("gifts.received")}</th><th>{t("common.status")}</th><th>{t("common.updatedAt")}</th><th>{t("common.actions")}</th></tr></thead> <thead><tr><th className="gift-select-col"><input type="checkbox" checked={allVisibleSelected} onChange={toggleSelectAllVisible} aria-label={"Select all visible gifts"} /></th><th>{"Animation file"}</th><th>{"ID / Revision"}</th><th>{"Display title"}</th><th>{"Price / Conversion"}</th><th>{"Source"}</th><th>{"Received gifts"}</th><th>{"Status"}</th><th>{"Updated"}</th><th>{"Actions"}</th></tr></thead>
<tbody> <tbody>
{pagedGifts.map((gift) => ( {pagedGifts.map((gift) => (
<tr className={gift.Enabled ? "" : "gift-row-disabled"} key={gift.GiftID}> <tr className={gift.Enabled ? "" : "gift-row-disabled"} key={gift.GiftID}>
<td className="gift-select-col"><input type="checkbox" checked={selected.has(gift.GiftID)} onChange={() => toggleSelected(gift.GiftID)} aria-label={t("gifts.bulkSelectOne", { id: gift.GiftID })} /></td> <td className="gift-select-col"><input type="checkbox" checked={selected.has(gift.GiftID)} onChange={() => toggleSelected(gift.GiftID)} aria-label={`Select gift ${gift.GiftID}`} /></td>
<td><LottiePreview giftID={gift.GiftID} revision={gift.Revision} compact /></td> <td><LottiePreview giftID={gift.GiftID} revision={gift.Revision} compact /></td>
<td className="mono">{gift.GiftID} / {gift.Revision}</td> <td className="mono">{gift.GiftID} / {gift.Revision}</td>
<td><strong className="gift-table-title">{gift.Title || `Gift #${gift.GiftID}`}</strong><span className="gift-sort-order">{t("gifts.sortOrder")}: {gift.SortOrder}</span></td> <td><strong className="gift-table-title">{gift.Title || `Gift #${gift.GiftID}`}</strong><span className="gift-sort-order">{"Sort order"}: {gift.SortOrder}</span></td>
<td><strong className="gift-table-price"> {gift.Stars}</strong><span className="gift-convert-price"> {gift.ConvertStars}</span></td> <td><strong className="gift-table-price"> {gift.Stars}</strong><span className="gift-convert-price"> {gift.ConvertStars}</span></td>
<td><Badge>{gift.SourceFormat}</Badge><span className="gift-source-size">{formatBytes(gift.AnimationSize)}</span></td> <td><Badge>{gift.SourceFormat}</Badge><span className="gift-source-size">{formatBytes(gift.AnimationSize)}</span></td>
<td>{gift.ReceivedCount}</td> <td>{gift.ReceivedCount}</td>
<td><Badge tone={gift.Enabled ? "good" : "neutral"}>{gift.Enabled ? t("common.enabled") : t("common.disabled")}</Badge></td> <td><Badge tone={gift.Enabled ? "good" : "neutral"}>{gift.Enabled ? "Enabled" : "Disabled"}</Badge></td>
<td>{formatDate(gift.UpdatedAt)}</td> <td>{formatDate(gift.UpdatedAt)}</td>
<td><div className="gift-table-actions"><button className="btn compact-btn collectible-button" type="button" onClick={() => setCollectibleGift(gift)}><Gem size={13} />{t("collectibles.manage")}</button><button className="btn compact-btn" type="button" onClick={() => startRevision(gift)}>{t("gifts.replace")}</button><ActionButton compact tone="neutral" label={gift.Enabled ? t("gifts.disable") : t("gifts.enable")} path="/api/actions/set-gift-enabled" payload={() => ({ gift_id: gift.GiftID, enabled: !gift.Enabled })} onDone={() => void load()} /></div></td> <td><div className="gift-table-actions"><button className="btn compact-btn collectible-button" type="button" onClick={() => setCollectibleGift(gift)}><Gem size={13} />{"Attribute pool"}</button><button className="btn compact-btn" type="button" onClick={() => startRevision(gift)}>{"New revision"}</button><ActionButton compact tone="neutral" label={gift.Enabled ? "Disable" : "Enable"} path="/api/actions/set-gift-enabled" payload={() => ({ gift_id: gift.GiftID, enabled: !gift.Enabled })} onDone={() => void load()} /></div></td>
</tr> </tr>
))} ))}
{pagedGifts.length === 0 && <EmptyRow colSpan={10} />} {pagedGifts.length === 0 && <EmptyRow colSpan={10} />}
@ -501,45 +506,45 @@ export function GiftsPage() {
</table> </table>
</div> </div>
{pageSize !== "all" && visibleGifts.length > 0 && <div className="gift-pager"> {pageSize !== "all" && visibleGifts.length > 0 && <div className="gift-pager">
<span className="gift-pager-range">{t("gifts.pageRange", { start: pageRangeStart, end: pageRangeEnd, total: visibleGifts.length })}</span> <span className="gift-pager-range">{`Showing ${pageRangeStart}-${pageRangeEnd} of ${visibleGifts.length}`}</span>
<div className="gift-pager-controls"> <div className="gift-pager-controls">
<button className="btn compact-btn" type="button" onClick={() => setPage((p) => Math.max(1, p - 1))} disabled={currentPage <= 1}> <button className="btn compact-btn" type="button" onClick={() => setPage((p) => Math.max(1, p - 1))} disabled={currentPage <= 1}>
<ChevronLeft size={14} /> {t("gifts.pagePrev")} <ChevronLeft size={14} /> {"Previous"}
</button> </button>
<span className="gift-pager-page">{t("gifts.pageOf", { page: currentPage, total: totalPages })}</span> <span className="gift-pager-page">{`Page ${currentPage} of ${totalPages}`}</span>
<button className="btn compact-btn" type="button" onClick={() => setPage((p) => Math.min(totalPages, p + 1))} disabled={currentPage >= totalPages}> <button className="btn compact-btn" type="button" onClick={() => setPage((p) => Math.min(totalPages, p + 1))} disabled={currentPage >= totalPages}>
{t("gifts.pageNext")} <ChevronRight size={14} /> {"Next"} <ChevronRight size={14} />
</button> </button>
</div> </div>
</div>} </div>}
{importOpen && createPortal( {importOpen && createPortal(
<div className="modal-backdrop" role="presentation"> <div className="modal-backdrop" role="presentation">
<section className="modal command-modal gift-import-modal" role="dialog" aria-modal="true" aria-label={giftID !== "0" ? t("gifts.newRevision", { id: giftID }) : t("gifts.importTitle")}> <section className="modal command-modal gift-import-modal" role="dialog" aria-modal="true" aria-label={giftID !== "0" ? `Create revision for gift #${giftID}` : "Import a Star Gift"}>
<div className="modal-head"> <div className="modal-head">
<div><div className="eyebrow">{t("gifts.importEyebrow")}</div><h2>{giftID !== "0" ? t("gifts.newRevision", { id: giftID }) : t("gifts.importTitle")}</h2></div> <div><div className="eyebrow">{"Gift catalog operation"}</div><h2>{giftID !== "0" ? `Create revision for gift #${giftID}` : "Import a Star Gift"}</h2></div>
<button className="icon-btn" type="button" onClick={() => setImportOpen(false)} disabled={busy} aria-label={t("action.close")}><X size={15} /></button> <button className="icon-btn" type="button" onClick={() => setImportOpen(false)} disabled={busy} aria-label={"Close"}><X size={15} /></button>
</div> </div>
<div className="command-body gift-import-modal-body"> <div className="command-body gift-import-modal-body">
<div className="command-steps"> <div className="command-steps">
<div className={`command-step ${step1Done ? "done" : "active"}`}><span>1</span><strong>{t("gifts.stepDetails")}</strong></div> <div className={`command-step ${step1Done ? "done" : "active"}`}><span>1</span><strong>{"File and details"}</strong></div>
<div className={`command-step ${preview ? "done" : step1Done ? "active" : ""}`}><span>2</span><strong>{t("gifts.stepValidate")}</strong></div> <div className={`command-step ${preview ? "done" : step1Done ? "active" : ""}`}><span>2</span><strong>{"Dry-run validation"}</strong></div>
<div className={`command-step ${preview ? "active" : ""}`}><span>3</span><strong>{t("gifts.stepImport")}</strong></div> <div className={`command-step ${preview ? "active" : ""}`}><span>3</span><strong>{"Confirm import"}</strong></div>
</div> </div>
{giftID === "0" && <div className="gift-source-tabs"> {giftID === "0" && <div className="gift-source-tabs">
{SHOW_DEFAULT_GIFTS_TAB && <button className={`btn ${importSource === "default" ? "primary" : ""}`} type="button" onClick={() => { setImportSource("default"); setPreview(null); }}>{t("gifts.defaultSource")}</button>} {SHOW_DEFAULT_GIFTS_TAB && <button className={`btn ${importSource === "default" ? "primary" : ""}`} type="button" onClick={() => { setImportSource("default"); setPreview(null); }}>{"Default gifts"}</button>}
<button className={`btn ${importSource === "official" ? "primary" : ""}`} type="button" onClick={() => { setImportSource("official"); setPreview(null); }}>{t("gifts.officialSource")}</button> <button className={`btn ${importSource === "official" ? "primary" : ""}`} type="button" onClick={() => { setImportSource("official"); setPreview(null); }}>{"Official snapshot"}</button>
<button className={`btn ${importSource === "file" ? "primary" : ""}`} type="button" onClick={() => { setImportSource("file"); setPreview(null); }}>{t("gifts.fileSource")}</button> <button className={`btn ${importSource === "file" ? "primary" : ""}`} type="button" onClick={() => { setImportSource("file"); setPreview(null); }}>{"Upload file"}</button>
</div>} </div>}
{importSource === "default" && giftID === "0" && SHOW_DEFAULT_GIFTS_TAB ? <section className="official-gift-picker"> {importSource === "default" && giftID === "0" && SHOW_DEFAULT_GIFTS_TAB ? <section className="official-gift-picker">
<div className="gift-import-note"><span>{t("gifts.defaultHint")}</span><div className="gift-format-chips"><span>{defaultGifts.length}</span><span>OwpenGram</span></div></div> <div className="gift-import-note"><span>{"Import our built-in original OwpenGram gifts. Complete collectible pools (upgrade + craft) are imported atomically."}</span><div className="gift-format-chips"><span>{defaultGifts.length}</span><span>OwpenGram</span></div></div>
<div className="official-gift-bulk-import"> <div className="official-gift-bulk-import">
<button className="btn" type="button" onClick={() => openBulkImport("default")}> <button className="btn" type="button" onClick={() => openBulkImport("default")}>
<Upload size={14} /> {t("gifts.importAllDefault")} <Upload size={14} /> {"Import all default gifts"}
</button> </button>
</div> </div>
<label className="gift-switch"><input type="checkbox" checked={enabled} onChange={(e) => { setEnabled(e.target.checked); setPreview(null); }} /><span className="gift-switch-track" aria-hidden="true"><span /></span><span>{t("gifts.enableAfterImport")}</span></label> <label className="gift-switch"><input type="checkbox" checked={enabled} onChange={(e) => { setEnabled(e.target.checked); setPreview(null); }} /><span className="gift-switch-track" aria-hidden="true"><span /></span><span>{"Enable after import"}</span></label>
<div className="official-gift-list" role="listbox" aria-label={t("gifts.defaultSelect")}> <div className="official-gift-list" role="listbox" aria-label={"Choose a default gift"}>
{defaultGifts.map((gift) => { {defaultGifts.map((gift) => {
const isSelected = gift.id === selectedDefaultID; const isSelected = gift.id === selectedDefaultID;
return <button key={gift.id} className={`official-gift-option ${isSelected ? "selected" : ""}`} return <button key={gift.id} className={`official-gift-option ${isSelected ? "selected" : ""}`}
@ -549,105 +554,105 @@ export function GiftsPage() {
<span className="mono"> {gift.stars}</span> <span className="mono"> {gift.stars}</span>
</span> </span>
<span className="official-gift-option-meta"> <span className="official-gift-option-meta">
<span>{t("gifts.officialAttributes", { count: defaultGiftAttributeCount(gift) })}</span> <span>{`${defaultGiftAttributeCount(gift)} attributes`}</span>
{gift.limited && <span>{t("gifts.limited", { total: gift.availability })}</span>} {gift.limited && <span>{`Limited · ${gift.availability}`}</span>}
{gift.require_premium && <span>{t("gifts.premium")}</span>} {gift.require_premium && <span>{"Premium only"}</span>}
</span> </span>
<span className="official-gift-capabilities"> <span className="official-gift-capabilities">
<span className={gift.upgradeable ? "yes" : "no"}>{gift.upgradeable ? t("gifts.canUpgrade") : t("gifts.cannotUpgrade")}</span> <span className={gift.upgradeable ? "yes" : "no"}>{gift.upgradeable ? "Can upgrade" : "Cannot upgrade"}</span>
<span className={gift.craftable ? "craft" : "no"}>{gift.craftable ? t("gifts.canCraft") : t("gifts.cannotCraft")}</span> <span className={gift.craftable ? "craft" : "no"}>{gift.craftable ? "Can Craft" : "Cannot Craft"}</span>
</span> </span>
</button>; </button>;
})} })}
{defaultGifts.length === 0 && <div className="official-gift-empty">{t("gifts.defaultEmpty")}</div>} {defaultGifts.length === 0 && <div className="official-gift-empty">{"No default gifts are available."}</div>}
</div> </div>
{selectedDefault && <div className="official-gift-selected"> {selectedDefault && <div className="official-gift-selected">
<DefaultLottiePreview id={selectedDefault.id} /> <DefaultLottiePreview id={selectedDefault.id} />
<div><strong>{selectedDefault.title}</strong><span className="mono"> {selectedDefault.stars} {selectedDefault.convert_stars}</span><small>{selectedDefault.model_count} {t("collectibles.models")} · {selectedDefault.pattern_count} {t("collectibles.patterns")} · {selectedDefault.backdrop_count} {t("collectibles.backdrops")}</small><span className="official-gift-capabilities"><span className={selectedDefault.upgradeable ? "yes" : "no"}>{selectedDefault.upgradeable ? t("gifts.canUpgrade") : t("gifts.cannotUpgrade")}</span><span className={selectedDefault.craftable ? "craft" : "no"}>{selectedDefault.craftable ? t("gifts.canCraft") : t("gifts.cannotCraft")}</span></span></div> <div><strong>{selectedDefault.title}</strong><span className="mono"> {selectedDefault.stars} {selectedDefault.convert_stars}</span><small>{selectedDefault.model_count} {"Models"} · {selectedDefault.pattern_count} {"Patterns"} · {selectedDefault.backdrop_count} {"Backdrops"}</small><span className="official-gift-capabilities"><span className={selectedDefault.upgradeable ? "yes" : "no"}>{selectedDefault.upgradeable ? "Can upgrade" : "Cannot upgrade"}</span><span className={selectedDefault.craftable ? "craft" : "no"}>{selectedDefault.craftable ? "Can Craft" : "Cannot Craft"}</span></span></div>
</div>} </div>}
</section> : importSource === "official" && giftID === "0" ? <section className="official-gift-picker"> </section> : importSource === "official" && giftID === "0" ? <section className="official-gift-picker">
<div className="gift-import-note"><span>{t("gifts.officialHint")}</span><div className="gift-format-chips"><span>{officialGifts.length}</span><span>SHA-256</span></div></div> <div className="gift-import-note"><span>{"Choose a verified gift from data/official-gifts. Complete collectible pools are imported atomically."}</span><div className="gift-format-chips"><span>{officialGifts.length}</span><span>SHA-256</span></div></div>
<div className="official-gift-bulk-import"> <div className="official-gift-bulk-import">
<button className="btn" type="button" onClick={() => openBulkImport("official")}> <button className="btn" type="button" onClick={() => openBulkImport("official")}>
<Upload size={14} /> {t("gifts.importAllOfficial")} <Upload size={14} /> {"Import all official gifts"}
</button> </button>
</div> </div>
<div className="official-gift-tools"> <div className="official-gift-tools">
<label className="searchbox"><Search size={15} /><input value={officialQuery} onChange={(e) => setOfficialQuery(e.target.value)} placeholder={t("gifts.officialSearch")} /></label> <label className="searchbox"><Search size={15} /><input value={officialQuery} onChange={(e) => setOfficialQuery(e.target.value)} placeholder={"Search official gift ID or title"} /></label>
<span>{t("gifts.officialResults", { shown: visibleOfficial.length, total: officialGifts.length })}</span> <span>{`Showing ${visibleOfficial.length} of ${officialGifts.length}`}</span>
</div> </div>
<div className="official-gift-categories" role="group" aria-label={t("gifts.officialCategoryLabel")}> <div className="official-gift-categories" role="group" aria-label={"Official gift capability category"}>
{(["all", "upgrade", "craft", "basic"] as const).map((category) => ( {(["all", "upgrade", "craft", "basic"] as const).map((category) => (
<button key={category} className={officialCategory === category ? "active" : ""} type="button" <button key={category} className={officialCategory === category ? "active" : ""} type="button"
aria-pressed={officialCategory === category} onClick={() => setOfficialCategory(category)}> aria-pressed={officialCategory === category} onClick={() => setOfficialCategory(category)}>
{t(`gifts.officialCategory.${category}`)}<span>{officialCategoryCounts[category]}</span> {officialCategoryLabels[category]}<span>{officialCategoryCounts[category]}</span>
</button> </button>
))} ))}
</div> </div>
<div className="official-gift-list" role="listbox" aria-label={t("gifts.officialSelect")}> <div className="official-gift-list" role="listbox" aria-label={"Choose an official gift"}>
{visibleOfficial.map((gift) => { {visibleOfficial.map((gift) => {
const isSelected = gift.source_gift_id === sourceGiftID; const isSelected = gift.source_gift_id === sourceGiftID;
return <button key={gift.source_gift_id} className={`official-gift-option ${isSelected ? "selected" : ""}`} return <button key={gift.source_gift_id} className={`official-gift-option ${isSelected ? "selected" : ""}`}
type="button" role="option" aria-selected={isSelected} onClick={() => chooseOfficial(gift)}> type="button" role="option" aria-selected={isSelected} onClick={() => chooseOfficial(gift)}>
<span className="official-gift-option-head"> <span className="official-gift-option-head">
<strong>{gift.title || t("gifts.officialUnnamed", { id: gift.source_gift_id })}</strong> <strong>{gift.title || `Unnamed official gift #${gift.source_gift_id}`}</strong>
<span className="mono">#{gift.source_gift_id}</span> <span className="mono">#{gift.source_gift_id}</span>
</span> </span>
<span className="official-gift-option-meta"> <span className="official-gift-option-meta">
<span> {gift.stars}</span> <span> {gift.stars}</span>
<span>{t("gifts.officialAttributes", { count: officialGiftAttributeCount(gift) })}</span> <span>{`${officialGiftAttributeCount(gift)} attributes`}</span>
</span> </span>
<span className="official-gift-capabilities"> <span className="official-gift-capabilities">
<span className={gift.can_upgrade ? "yes" : "no"}>{gift.can_upgrade ? t("gifts.canUpgrade") : t("gifts.cannotUpgrade")}</span> <span className={gift.can_upgrade ? "yes" : "no"}>{gift.can_upgrade ? "Can upgrade" : "Cannot upgrade"}</span>
<span className={gift.can_craft ? "craft" : "no"}>{gift.can_craft ? t("gifts.canCraft") : t("gifts.cannotCraft")}</span> <span className={gift.can_craft ? "craft" : "no"}>{gift.can_craft ? "Can Craft" : "Cannot Craft"}</span>
</span> </span>
</button>; </button>;
})} })}
{visibleOfficial.length === 0 && <div className="official-gift-empty">{t("gifts.officialEmpty")}</div>} {visibleOfficial.length === 0 && <div className="official-gift-empty">{"No official gifts match this category and search."}</div>}
</div> </div>
{selectedOfficial && <div className="official-gift-selected"> {selectedOfficial && <div className="official-gift-selected">
<OfficialLottiePreview sourceGiftID={selectedOfficial.source_gift_id} /> <OfficialLottiePreview sourceGiftID={selectedOfficial.source_gift_id} />
<div><strong>{selectedOfficial.title || t("gifts.officialUnnamed", { id: selectedOfficial.source_gift_id })}</strong><span className="mono">{selectedOfficial.source_gift_id}</span><small>{selectedOfficial.model_count} {t("collectibles.models")} · {selectedOfficial.pattern_count} {t("collectibles.patterns")} · {selectedOfficial.backdrop_count} {t("collectibles.backdrops")}</small><span className="official-gift-capabilities"><span className={selectedOfficial.can_upgrade ? "yes" : "no"}>{selectedOfficial.can_upgrade ? t("gifts.canUpgrade") : t("gifts.cannotUpgrade")}</span><span className={selectedOfficial.can_craft ? "craft" : "no"}>{selectedOfficial.can_craft ? t("gifts.canCraft") : t("gifts.cannotCraft")}</span></span></div> <div><strong>{selectedOfficial.title || `Unnamed official gift #${selectedOfficial.source_gift_id}`}</strong><span className="mono">{selectedOfficial.source_gift_id}</span><small>{selectedOfficial.model_count} {"Models"} · {selectedOfficial.pattern_count} {"Patterns"} · {selectedOfficial.backdrop_count} {"Backdrops"}</small><span className="official-gift-capabilities"><span className={selectedOfficial.can_upgrade ? "yes" : "no"}>{selectedOfficial.can_upgrade ? "Can upgrade" : "Cannot upgrade"}</span><span className={selectedOfficial.can_craft ? "craft" : "no"}>{selectedOfficial.can_craft ? "Can Craft" : "Cannot Craft"}</span></span></div>
</div>} </div>}
{selectedOfficial?.can_upgrade && <> {selectedOfficial?.can_upgrade && <>
<label className="gift-switch"><input type="checkbox" checked={includeCollectible} onChange={(e) => { setIncludeCollectible(e.target.checked); setPreview(null); }} /><span className="gift-switch-track" aria-hidden="true"><span /></span><span>{t("gifts.includeCollectible")}</span></label> <label className="gift-switch"><input type="checkbox" checked={includeCollectible} onChange={(e) => { setIncludeCollectible(e.target.checked); setPreview(null); }} /><span className="gift-switch-track" aria-hidden="true"><span /></span><span>{"Import the complete collectible pool, including crafted models"}</span></label>
{includeCollectible && <div className="gift-fields-grid"> {includeCollectible && <div className="gift-fields-grid">
<label><span>{t("collectibles.upgradeStars")}</span><input type="number" min="1" value={upgradeStars} onChange={(e) => { setUpgradeStars(e.target.value); setPreview(null); }} /></label> <label><span>{"Upgrade price in Stars"}</span><input type="number" min="1" value={upgradeStars} onChange={(e) => { setUpgradeStars(e.target.value); setPreview(null); }} /></label>
<label><span>{t("collectibles.supply")}</span><input type="number" min="1" value={supplyTotal} onChange={(e) => { setSupplyTotal(e.target.value); setPreview(null); }} /></label> <label><span>{"Unique supply"}</span><input type="number" min="1" value={supplyTotal} onChange={(e) => { setSupplyTotal(e.target.value); setPreview(null); }} /></label>
<label><span>{t("collectibles.slug")}</span><input value={slugPrefix} maxLength={48} onChange={(e) => { setSlugPrefix(e.target.value.toLowerCase()); setPreview(null); }} /></label> <label><span>{"Public slug prefix"}</span><input value={slugPrefix} maxLength={48} onChange={(e) => { setSlugPrefix(e.target.value.toLowerCase()); setPreview(null); }} /></label>
</div>} </div>}
</>} </>}
<div className="gift-fields-grid"> <div className="gift-fields-grid">
<label><span>{t("gifts.title")}</span><input value={title} maxLength={128} placeholder={t("gifts.titlePlaceholder")} onChange={(e) => { setTitle(e.target.value); setPreview(null); }} /></label> <label><span>{"Display title"}</span><input value={title} maxLength={128} placeholder={"e.g. Celebration Star"} onChange={(e) => { setTitle(e.target.value); setPreview(null); }} /></label>
<label><span>{t("gifts.stars")}</span><input type="number" min="1" value={stars} onChange={(e) => { setStars(e.target.value); setPreview(null); }} /></label> <label><span>{"Price in Stars"}</span><input type="number" min="1" value={stars} onChange={(e) => { setStars(e.target.value); setPreview(null); }} /></label>
<label><span>{t("gifts.convertStars")}</span><input type="number" min="0" value={convertStars} onChange={(e) => { setConvertStars(e.target.value); setPreview(null); }} /></label> <label><span>{"Conversion Stars"}</span><input type="number" min="0" value={convertStars} onChange={(e) => { setConvertStars(e.target.value); setPreview(null); }} /></label>
<label><span>{t("gifts.sortOrder")}</span><input type="number" value={sortOrder} onChange={(e) => { setSortOrder(e.target.value); setPreview(null); }} /></label> <label><span>{"Sort order"}</span><input type="number" value={sortOrder} onChange={(e) => { setSortOrder(e.target.value); setPreview(null); }} /></label>
</div> </div>
<label className="gift-switch"><input type="checkbox" checked={enabled} onChange={(e) => { setEnabled(e.target.checked); setPreview(null); }} /><span className="gift-switch-track" aria-hidden="true"><span /></span><span>{t("gifts.enableAfterImport")}</span></label> <label className="gift-switch"><input type="checkbox" checked={enabled} onChange={(e) => { setEnabled(e.target.checked); setPreview(null); }} /><span className="gift-switch-track" aria-hidden="true"><span /></span><span>{"Enable after import"}</span></label>
</section> : <> </section> : <>
<div className="gift-import-note"><span>{t("gifts.importHint")}</span><div className="gift-format-chips" aria-label={t("gifts.formats")}><span>TGS</span><span>Lottie JSON</span></div></div> <div className="gift-import-note"><span>{"Upload TGS or plain Lottie JSON. Lottie is normalized and compressed to TGS."}</span><div className="gift-format-chips" aria-label={"Accepted formats"}><span>TGS</span><span>Lottie JSON</span></div></div>
<label className={`gift-file-picker ${file ? "has-file" : ""}`}> <label className={`gift-file-picker ${file ? "has-file" : ""}`}>
<input type="file" accept=".tgs,.json,.lottie,application/json,application/x-tgsticker" onChange={(e) => { setFile(e.target.files?.[0] ?? null); setPreview(null); }} /> <input type="file" accept=".tgs,.json,.lottie,application/json,application/x-tgsticker" onChange={(e) => { setFile(e.target.files?.[0] ?? null); setPreview(null); }} />
<span className="gift-file-icon"><FileJson2 size={22} /></span> <span className="gift-file-icon"><FileJson2 size={22} /></span>
<span className="gift-file-copy"><span className="gift-field-label">{t("gifts.animation")}</span><strong>{file ? file.name : t("gifts.filePrompt")}</strong><small>{file ? formatBytes(file.size) : t("gifts.fileHint")}</small></span> <span className="gift-file-copy"><span className="gift-field-label">{"Animation file"}</span><strong>{file ? file.name : "Drop or choose a TGS / Lottie file"}</strong><small>{file ? formatBytes(file.size) : "TGS, JSON or Lottie · validated before import"}</small></span>
<span className="gift-file-action">{file ? t("gifts.changeFile") : t("gifts.chooseFile")}</span> <span className="gift-file-action">{file ? "Change file" : "Choose file"}</span>
</label> </label>
<div className="gift-fields-grid"> <div className="gift-fields-grid">
<label><span>{t("gifts.title")}</span><input value={title} maxLength={128} placeholder={t("gifts.titlePlaceholder")} onChange={(e) => { setTitle(e.target.value); setPreview(null); }} /></label> <label><span>{"Display title"}</span><input value={title} maxLength={128} placeholder={"e.g. Celebration Star"} onChange={(e) => { setTitle(e.target.value); setPreview(null); }} /></label>
<label><span>{t("gifts.stars")}</span><input type="number" min="1" value={stars} onChange={(e) => { setStars(e.target.value); setPreview(null); }} /></label> <label><span>{"Price in Stars"}</span><input type="number" min="1" value={stars} onChange={(e) => { setStars(e.target.value); setPreview(null); }} /></label>
<label><span>{t("gifts.convertStars")}</span><input type="number" min="0" value={convertStars} onChange={(e) => { setConvertStars(e.target.value); setPreview(null); }} /></label> <label><span>{"Conversion Stars"}</span><input type="number" min="0" value={convertStars} onChange={(e) => { setConvertStars(e.target.value); setPreview(null); }} /></label>
<label><span>{t("gifts.sortOrder")}</span><input type="number" value={sortOrder} onChange={(e) => { setSortOrder(e.target.value); setPreview(null); }} /></label> <label><span>{"Sort order"}</span><input type="number" value={sortOrder} onChange={(e) => { setSortOrder(e.target.value); setPreview(null); }} /></label>
</div> </div>
<label className="gift-switch"><input type="checkbox" checked={enabled} onChange={(e) => { setEnabled(e.target.checked); setPreview(null); }} /><span className="gift-switch-track" aria-hidden="true"><span /></span><span>{t("gifts.enableAfterImport")}</span></label> <label className="gift-switch"><input type="checkbox" checked={enabled} onChange={(e) => { setEnabled(e.target.checked); setPreview(null); }} /><span className="gift-switch-track" aria-hidden="true"><span /></span><span>{"Enable after import"}</span></label>
</>} </>}
<label className="gift-reason-field"><span>{t("gifts.reason")}</span><input value={reason} placeholder={t("gifts.reasonPlaceholder")} onChange={(e) => setReason(e.target.value)} /></label> <label className="gift-reason-field"><span>{"Audit reason"}</span><input value={reason} placeholder={"Briefly describe why this gift is being imported"} onChange={(e) => setReason(e.target.value)} /></label>
{importError && <Alert>{importError}</Alert>} {importError && <Alert>{importError}</Alert>}
{preview && <div className="gift-validation"><div className="gift-validation-head"><CheckCircle2 size={17} /><div><strong>{t("gifts.validationReady")}</strong><span>{t("gifts.validationHint")}</span></div></div><pre>{JSON.stringify(preview.details, null, 2)}</pre></div>} {preview && <div className="gift-validation"><div className="gift-validation-head"><CheckCircle2 size={17} /><div><strong>{"Validation passed"}</strong><span>{"Review the normalized metadata, then confirm the import."}</span></div></div><pre>{JSON.stringify(preview.details, null, 2)}</pre></div>}
</div> </div>
<div className="modal-actions"> <div className="modal-actions">
<button className="btn" type="button" onClick={() => setImportOpen(false)} disabled={busy}>{t("common.close")}</button> <button className="btn" type="button" onClick={() => setImportOpen(false)} disabled={busy}>{"Close"}</button>
<button className="btn" type="button" onClick={validateImport} disabled={busy}>{busy ? <Loader2 className="spin" size={15} /> : <ShieldCheck size={15} />}{t("gifts.validate")}</button> <button className="btn" type="button" onClick={validateImport} disabled={busy}>{busy ? <Loader2 className="spin" size={15} /> : <ShieldCheck size={15} />}{"Dry-run validation"}</button>
<button className="btn primary" type="button" onClick={confirmImport} disabled={busy || !preview}><Upload size={15} />{t("gifts.confirmImport")}</button> <button className="btn primary" type="button" onClick={confirmImport} disabled={busy || !preview}><Upload size={15} />{"Confirm import"}</button>
</div> </div>
</section> </section>
</div>, </div>,
@ -656,29 +661,29 @@ export function GiftsPage() {
{bulkImportOpen && createPortal( {bulkImportOpen && createPortal(
<div className="modal-backdrop" role="presentation"> <div className="modal-backdrop" role="presentation">
<section className="modal command-modal gift-bulk-import-modal" role="dialog" aria-modal="true" <section className="modal command-modal gift-bulk-import-modal" role="dialog" aria-modal="true"
aria-label={bulkImportOpen === "default" ? t("gifts.importAllDefault") : t("gifts.importAllOfficial")}> aria-label={bulkImportOpen === "default" ? "Import all default gifts" : "Import all official gifts"}>
<div className="modal-head"> <div className="modal-head">
<div><div className="eyebrow">{t("gifts.importEyebrow")}</div><h2>{bulkImportOpen === "default" ? t("gifts.importAllDefault") : t("gifts.importAllOfficial")}</h2></div> <div><div className="eyebrow">{"Gift catalog operation"}</div><h2>{bulkImportOpen === "default" ? "Import all default gifts" : "Import all official gifts"}</h2></div>
<button className="icon-btn" type="button" onClick={closeBulkImport} disabled={bulkImportBusy} aria-label={t("action.close")}><X size={15} /></button> <button className="icon-btn" type="button" onClick={closeBulkImport} disabled={bulkImportBusy} aria-label={"Close"}><X size={15} /></button>
</div> </div>
<div className="command-body"> <div className="command-body">
<div className="gift-import-note"><span>{t("gifts.bulkImportCount", { count: bulkImportItems.length })}</span></div> <div className="gift-import-note"><span>{`${bulkImportItems.length} gifts available to import`}</span></div>
<label className="gift-switch"><input type="checkbox" checked={bulkImportEnabled} disabled={bulkImportBusy} onChange={(e) => setBulkImportEnabled(e.target.checked)} /><span className="gift-switch-track" aria-hidden="true"><span /></span><span>{t("gifts.enableAfterImport")}</span></label> <label className="gift-switch"><input type="checkbox" checked={bulkImportEnabled} disabled={bulkImportBusy} onChange={(e) => setBulkImportEnabled(e.target.checked)} /><span className="gift-switch-track" aria-hidden="true"><span /></span><span>{"Enable after import"}</span></label>
<label className="gift-reason-field"><span>{t("gifts.reason")}</span><input value={bulkImportReason} placeholder={t("gifts.reasonPlaceholder")} disabled={bulkImportBusy} onChange={(e) => setBulkImportReason(e.target.value)} /></label> <label className="gift-reason-field"><span>{"Audit reason"}</span><input value={bulkImportReason} placeholder={"Briefly describe why this gift is being imported"} disabled={bulkImportBusy} onChange={(e) => setBulkImportReason(e.target.value)} /></label>
{bulkImportBusy && <div className="gift-bulk-import-progress"> {bulkImportBusy && <div className="gift-bulk-import-progress">
<div className="gift-bulk-import-progress-bar"><div style={{ width: `${bulkImportProgress.total ? Math.round((bulkImportProgress.done / bulkImportProgress.total) * 100) : 0}%` }} /></div> <div className="gift-bulk-import-progress-bar"><div style={{ width: `${bulkImportProgress.total ? Math.round((bulkImportProgress.done / bulkImportProgress.total) * 100) : 0}%` }} /></div>
<span>{t("gifts.importingProgress", { done: bulkImportProgress.done, total: bulkImportProgress.total })}</span> <span>{`Importing ${bulkImportProgress.done} of ${bulkImportProgress.total}`}</span>
</div>} </div>}
{bulkImportError && <Alert>{bulkImportError}</Alert>} {bulkImportError && <Alert>{bulkImportError}</Alert>}
{bulkImportResult && <div className="gift-validation"> {bulkImportResult && <div className="gift-validation">
<div className="gift-validation-head"><CheckCircle2 size={17} /><div><strong>{t("gifts.bulkImportDone")}</strong><span>{t("gifts.bulkImportSummary", { imported: bulkImportResult.imported, skipped: bulkImportResult.skipped, failed: bulkImportResult.failed })}</span></div></div> <div className="gift-validation-head"><CheckCircle2 size={17} /><div><strong>{"Import complete"}</strong><span>{`Imported ${bulkImportResult.imported}, skipped ${bulkImportResult.skipped}, failed ${bulkImportResult.failed}`}</span></div></div>
{bulkImportResult.errors.length > 0 && <pre>{bulkImportResult.errors.join("\n")}</pre>} {bulkImportResult.errors.length > 0 && <pre>{bulkImportResult.errors.join("\n")}</pre>}
</div>} </div>}
</div> </div>
<div className="modal-actions"> <div className="modal-actions">
<button className="btn" type="button" onClick={closeBulkImport} disabled={bulkImportBusy}>{t("common.close")}</button> <button className="btn" type="button" onClick={closeBulkImport} disabled={bulkImportBusy}>{"Close"}</button>
<button className="btn primary" type="button" onClick={runBulkImport} disabled={bulkImportBusy || bulkImportItems.length === 0}> <button className="btn primary" type="button" onClick={runBulkImport} disabled={bulkImportBusy || bulkImportItems.length === 0}>
{bulkImportBusy ? <Loader2 className="spin" size={15} /> : <Upload size={15} />} {t("gifts.startBulkImport")} {bulkImportBusy ? <Loader2 className="spin" size={15} /> : <Upload size={15} />} {"Start import"}
</button> </button>
</div> </div>
</section> </section>

View file

@ -3,7 +3,6 @@ import { useEffect, useMemo, useState } from "react";
import { api, errorMessage } from "../api"; import { api, errorMessage } from "../api";
import { ChannelPicker, UserPicker } from "../components/EntityPicker"; import { ChannelPicker, UserPicker } from "../components/EntityPicker";
import { Alert, JsonBlock } from "../components/ui"; import { Alert, JsonBlock } from "../components/ui";
import { useI18n } from "../i18n";
import type { AccountRow, ChannelRow, CommandResult, StarGiftCollectibleAttributeRow, StarGiftCollectiblePreview, StarGiftRow } from "../types"; import type { AccountRow, ChannelRow, CommandResult, StarGiftCollectibleAttributeRow, StarGiftCollectiblePreview, StarGiftRow } from "../types";
const SYSTEM_SENDER = "777000"; const SYSTEM_SENDER = "777000";
@ -16,7 +15,6 @@ function attrLabel(attr: StarGiftCollectibleAttributeRow): string {
} }
export function GiveGiftForm({ gift, onDone }: { gift: StarGiftRow; onDone?: () => void }) { export function GiveGiftForm({ gift, onDone }: { gift: StarGiftRow; onDone?: () => void }) {
const { t } = useI18n();
const [kind, setKind] = useState<RecipientKind>("user"); const [kind, setKind] = useState<RecipientKind>("user");
const [user, setUser] = useState<AccountRow | null>(null); const [user, setUser] = useState<AccountRow | null>(null);
const [channel, setChannel] = useState<ChannelRow | null>(null); const [channel, setChannel] = useState<ChannelRow | null>(null);
@ -82,11 +80,11 @@ export function GiveGiftForm({ gift, onDone }: { gift: StarGiftRow; onDone?: ()
async function run(confirm: boolean) { async function run(confirm: boolean) {
if (recipientID <= 0) { if (recipientID <= 0) {
setError(t("giveGift.recipientRequired")); setError("Select a recipient first");
return; return;
} }
if (!reason.trim()) { if (!reason.trim()) {
setError(t("action.reasonRequired")); setError("Please enter an operation reason");
return; return;
} }
setBusy(true); setBusy(true);
@ -114,34 +112,34 @@ export function GiveGiftForm({ gift, onDone }: { gift: StarGiftRow; onDone?: ()
</div> </div>
</div> </div>
<div className="give-gift-tabs" role="group" aria-label={t("giveGift.recipientKind")}> <div className="give-gift-tabs" role="group" aria-label={"Recipient type"}>
<button type="button" className={`btn ${kind === "user" ? "primary" : ""}`} onClick={() => { setKind("user"); setResult(null); }}> <button type="button" className={`btn ${kind === "user" ? "primary" : ""}`} onClick={() => { setKind("user"); setResult(null); }}>
<User size={15} /> {t("giveGift.recipientUser")} <User size={15} /> {"User"}
</button> </button>
<button type="button" className={`btn ${kind === "channel" ? "primary" : ""}`} onClick={() => { setKind("channel"); setUpgrade(false); setResult(null); }}> <button type="button" className={`btn ${kind === "channel" ? "primary" : ""}`} onClick={() => { setKind("channel"); setUpgrade(false); setResult(null); }}>
<Users size={15} /> {t("giveGift.recipientChannel")} <Users size={15} /> {"Channel"}
</button> </button>
</div> </div>
{kind === "user" {kind === "user"
? <UserPicker label={t("giveGift.pickUser")} value={user} onChange={(row) => { setUser(row); setResult(null); }} /> ? <UserPicker label={"Recipient user"} value={user} onChange={(row) => { setUser(row); setResult(null); }} />
: <ChannelPicker label={t("giveGift.pickChannel")} value={channel} onChange={(row) => { setChannel(row); setResult(null); }} />} : <ChannelPicker label={"Recipient channel"} value={channel} onChange={(row) => { setChannel(row); setResult(null); }} />}
<label className="form-field"> <label className="form-field">
<span>{t("giveGift.sender")}</span> <span>{"Sender account ID"}</span>
<input value={SYSTEM_SENDER} disabled readOnly /> <input value={SYSTEM_SENDER} disabled readOnly />
<small className="field-hint">{t("giveGift.senderHint")}</small> <small className="field-hint">{"Gifts are always sent from the system account 777000 (Telesrv)."}</small>
</label> </label>
<label className="form-field"> <label className="form-field">
<span>{t("giveGift.message")}</span> <span>{"Attached message (optional)"}</span>
<textarea value={message} rows={2} maxLength={128} onChange={(event) => { setMessage(event.target.value); setResult(null); }} placeholder={t("giveGift.messagePlaceholder")} /> <textarea value={message} rows={2} maxLength={128} onChange={(event) => { setMessage(event.target.value); setResult(null); }} placeholder={"Shown with the gift"} />
</label> </label>
<label className="gift-switch"> <label className="gift-switch">
<input type="checkbox" checked={hideName} onChange={(event) => { setHideName(event.target.checked); setResult(null); }} /> <input type="checkbox" checked={hideName} onChange={(event) => { setHideName(event.target.checked); setResult(null); }} />
<span className="gift-switch-track" aria-hidden="true"><span /></span> <span className="gift-switch-track" aria-hidden="true"><span /></span>
<span>{t("giveGift.hideName")}</span> <span>{"Hide sender name from recipient"}</span>
</label> </label>
{kind === "user" && ( {kind === "user" && (
@ -149,30 +147,30 @@ export function GiveGiftForm({ gift, onDone }: { gift: StarGiftRow; onDone?: ()
<label className="gift-switch"> <label className="gift-switch">
<input type="checkbox" checked={upgrade} onChange={(event) => { setUpgrade(event.target.checked); if (!event.target.checked) { setModelID("0"); setPatternID("0"); setBackdropID("0"); } setResult(null); }} /> <input type="checkbox" checked={upgrade} onChange={(event) => { setUpgrade(event.target.checked); if (!event.target.checked) { setModelID("0"); setPatternID("0"); setBackdropID("0"); } setResult(null); }} />
<span className="gift-switch-track" aria-hidden="true"><span /></span> <span className="gift-switch-track" aria-hidden="true"><span /></span>
<span>{t("giveGift.upgrade")}</span> <span>{"Deliver as upgraded collectible"}</span>
</label> </label>
{upgrade && <p className="give-gift-upgrade-note">{t("giveGift.upgradeNote")}</p>} {upgrade && <p className="give-gift-upgrade-note">{"The gift is minted as a unique collectible. Pick specific attributes below, or leave them on Random to draw from the published pool. The collectible number is assigned automatically. Requires a published collectible upgrade with remaining supply."}</p>}
{upgrade && previewError && <Alert>{previewError}</Alert>} {upgrade && previewError && <Alert>{previewError}</Alert>}
{upgrade && preview && ( {upgrade && preview && (
<div className="gift-fields-grid give-gift-attrs"> <div className="gift-fields-grid give-gift-attrs">
<label> <label>
<span>{t("giveGift.model")}</span> <span>{"Model"}</span>
<select value={modelID} onChange={(event) => { setModelID(event.target.value); setResult(null); }}> <select value={modelID} onChange={(event) => { setModelID(event.target.value); setResult(null); }}>
<option value="0">{t("giveGift.random")}</option> <option value="0">{"Random"}</option>
{(preview.models ?? []).map((attr) => <option key={attr.id} value={attr.id}>{attrLabel(attr)}</option>)} {(preview.models ?? []).map((attr) => <option key={attr.id} value={attr.id}>{attrLabel(attr)}</option>)}
</select> </select>
</label> </label>
<label> <label>
<span>{t("giveGift.pattern")}</span> <span>{"Pattern"}</span>
<select value={patternID} onChange={(event) => { setPatternID(event.target.value); setResult(null); }}> <select value={patternID} onChange={(event) => { setPatternID(event.target.value); setResult(null); }}>
<option value="0">{t("giveGift.random")}</option> <option value="0">{"Random"}</option>
{(preview.patterns ?? []).map((attr) => <option key={attr.id} value={attr.id}>{attrLabel(attr)}</option>)} {(preview.patterns ?? []).map((attr) => <option key={attr.id} value={attr.id}>{attrLabel(attr)}</option>)}
</select> </select>
</label> </label>
<label> <label>
<span>{t("giveGift.backdrop")}</span> <span>{"Backdrop"}</span>
<select value={backdropID} onChange={(event) => { setBackdropID(event.target.value); setResult(null); }}> <select value={backdropID} onChange={(event) => { setBackdropID(event.target.value); setResult(null); }}>
<option value="0">{t("giveGift.random")}</option> <option value="0">{"Random"}</option>
{(preview.backdrops ?? []).map((attr) => <option key={attr.id} value={attr.id}>{attrLabel(attr)}</option>)} {(preview.backdrops ?? []).map((attr) => <option key={attr.id} value={attr.id}>{attrLabel(attr)}</option>)}
</select> </select>
</label> </label>
@ -182,12 +180,12 @@ export function GiveGiftForm({ gift, onDone }: { gift: StarGiftRow; onDone?: ()
)} )}
<label className="form-field"> <label className="form-field">
<span>{t("action.reason")}</span> <span>{"Operation reason"}</span>
<textarea value={reason} rows={2} onChange={(event) => setReason(event.target.value)} placeholder={t("action.reasonPlaceholder")} /> <textarea value={reason} rows={2} onChange={(event) => setReason(event.target.value)} placeholder={"Describe why this operation is being performed"} />
</label> </label>
<div className="command-preview"> <div className="command-preview">
<div className="preview-head">{t("action.requestPreview")}</div> <div className="preview-head">{"Request preview"}</div>
<JsonBlock value={JSON.stringify(previewPayload, null, 2)} /> <JsonBlock value={JSON.stringify(previewPayload, null, 2)} />
</div> </div>
@ -196,11 +194,11 @@ export function GiveGiftForm({ gift, onDone }: { gift: StarGiftRow; onDone?: ()
<div className="result-box"> <div className="result-box">
<div className="result-title"> <div className="result-title">
{result.error ? <CircleAlert size={16} /> : <CheckCircle2 size={16} />} {result.error ? <CircleAlert size={16} /> : <CheckCircle2 size={16} />}
<strong>{result.message || result.error || t("action.result")}</strong> <strong>{result.message || result.error || "Action result"}</strong>
</div> </div>
<div className="result-line"><span>{t("action.commandID")}</span><strong>{result.command_id}</strong></div> <div className="result-line"><span>{"Command ID"}</span><strong>{result.command_id}</strong></div>
<div className="result-line"><span>{t("action.status")}</span><strong>{result.status}</strong></div> <div className="result-line"><span>{"Status"}</span><strong>{result.status}</strong></div>
<div className="result-line"><span>{t("action.dryRun")}</span><strong>{result.dry_run ? t("common.yes") : t("common.no")}</strong></div> <div className="result-line"><span>{"Dry-run"}</span><strong>{result.dry_run ? "Yes" : "No"}</strong></div>
{result.details && <JsonBlock value={JSON.stringify(result.details, null, 2)} />} {result.details && <JsonBlock value={JSON.stringify(result.details, null, 2)} />}
</div> </div>
)} )}
@ -208,11 +206,11 @@ export function GiveGiftForm({ gift, onDone }: { gift: StarGiftRow; onDone?: ()
<div className="give-gift-form-actions"> <div className="give-gift-form-actions">
<button className="btn icon-text" type="button" onClick={() => run(false)} disabled={busy}> <button className="btn icon-text" type="button" onClick={() => run(false)} disabled={busy}>
{busy ? <Loader2 size={15} className="spin" /> : <Play size={15} />} {busy ? <Loader2 size={15} className="spin" /> : <Play size={15} />}
{result ? t("action.runAgain") : t("action.runDry")} {result ? "Run dry-run again" : "Run dry-run first"}
</button> </button>
<button className="btn primary icon-text" type="button" onClick={() => run(true)} disabled={busy || !canConfirm}> <button className="btn primary icon-text" type="button" onClick={() => run(true)} disabled={busy || !canConfirm}>
<Gift size={15} /> <Gift size={15} />
{t("giveGift.confirm")} {"Give gift"}
</button> </button>
</div> </div>
</div> </div>

View file

@ -3,12 +3,10 @@ import { useEffect, useMemo, useState } from "react";
import { api, errorMessage } from "../api"; import { api, errorMessage } from "../api";
import { StaticLottie } from "../components/StaticLottie"; import { StaticLottie } from "../components/StaticLottie";
import { Alert, Badge, PageFrame } from "../components/ui"; import { Alert, Badge, PageFrame } from "../components/ui";
import { useI18n } from "../i18n";
import type { StarGiftRow } from "../types"; import type { StarGiftRow } from "../types";
import { GiveGiftForm } from "./GiveGiftForm"; import { GiveGiftForm } from "./GiveGiftForm";
export function GiveGiftsPage() { export function GiveGiftsPage() {
const { t } = useI18n();
const [gifts, setGifts] = useState<StarGiftRow[]>([]); const [gifts, setGifts] = useState<StarGiftRow[]>([]);
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [selected, setSelected] = useState<StarGiftRow | null>(null); const [selected, setSelected] = useState<StarGiftRow | null>(null);
@ -40,18 +38,18 @@ export function GiveGiftsPage() {
}, [gifts, query]); }, [gifts, query]);
return ( return (
<PageFrame title={t("giveGifts.pageTitle")} eyebrow={t("giveGifts.eyebrow")} actions={ <PageFrame title={"Give Gifts"} eyebrow={"Grant catalog gifts to any user or channel"} actions={
<button className="btn" type="button" onClick={() => load()} disabled={busy}><RefreshCw size={15} /> {t("common.refresh")}</button> <button className="btn" type="button" onClick={() => load()} disabled={busy}><RefreshCw size={15} /> {"Refresh"}</button>
}> }>
{error && <Alert>{error}</Alert>} {error && <Alert>{error}</Alert>}
<p className="give-gift-upgrade-note">{t("giveGifts.hint")}</p> <p className="give-gift-upgrade-note">{"Pick a gift to grant. Delivery is free of charge and sent from the system account 777000 (Telesrv) by default."}</p>
<div className="give-gift-layout"> <div className="give-gift-layout">
<section className="give-gift-picker"> <section className="give-gift-picker">
<div className="give-gift-picker-head"> <div className="give-gift-picker-head">
<label className="searchbox"><Search size={15} /><input value={query} onChange={(event) => setQuery(event.target.value)} placeholder={t("giveGifts.searchPlaceholder")} /></label> <label className="searchbox"><Search size={15} /><input value={query} onChange={(event) => setQuery(event.target.value)} placeholder={"Search by title or gift ID"} /></label>
<span className="gift-list-summary">{t("gifts.listSummary", { shown: visible.length, total: gifts.length })}</span> <span className="gift-list-summary">{`Showing ${visible.length} of ${gifts.length}`}</span>
</div> </div>
<div className="give-gift-picker-list" role="listbox" aria-label={t("giveGifts.pickGift")}> <div className="give-gift-picker-list" role="listbox" aria-label={"Select a gift"}>
{visible.map((gift) => { {visible.map((gift) => {
const active = selected?.GiftID === gift.GiftID; const active = selected?.GiftID === gift.GiftID;
return ( return (
@ -64,18 +62,18 @@ export function GiveGiftsPage() {
<span className="mono">#{gift.GiftID}</span> <span className="mono">#{gift.GiftID}</span>
</span> </span>
<span className="give-gift-option-price"> <span className="give-gift-option-price">
{gift.Enabled ? <Badge> {gift.Stars}</Badge> : <Badge tone="neutral">{t("common.disabled")}</Badge>} {gift.Enabled ? <Badge> {gift.Stars}</Badge> : <Badge tone="neutral">{"Disabled"}</Badge>}
</span> </span>
</button> </button>
); );
})} })}
{visible.length === 0 && !busy && <div className="official-gift-empty">{t("common.noResults")}</div>} {visible.length === 0 && !busy && <div className="official-gift-empty">{"No results"}</div>}
</div> </div>
</section> </section>
<section className="give-gift-panel"> <section className="give-gift-panel">
{selected {selected
? <GiveGiftForm key={selected.GiftID} gift={selected} onDone={() => void load()} /> ? <GiveGiftForm key={selected.GiftID} gift={selected} onDone={() => void load()} />
: <div className="give-gift-empty-panel"><Gift size={26} /><p>{t("giveGifts.selectPrompt")}</p></div>} : <div className="give-gift-empty-panel"><Gift size={26} /><p>{"Select a gift from the list to start."}</p></div>}
</section> </section>
</div> </div>
</PageFrame> </PageFrame>

View file

@ -2,13 +2,11 @@ import { ArrowLeft } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { api, errorMessage } from "../api"; import { api, errorMessage } from "../api";
import { Alert, Badge, EmptyRow, JsonBlock, LoadingSurface, PageFrame, SectionHead, Summary } from "../components/ui"; import { Alert, Badge, EmptyRow, JsonBlock, LoadingSurface, PageFrame, SectionHead, Summary } from "../components/ui";
import { useI18n } from "../i18n";
import { formatUnix } from "../lib/format"; import { formatUnix } from "../lib/format";
import type { Navigate } from "../routing"; import type { Navigate } from "../routing";
import type { GroupMessageDetail } from "../types"; import type { GroupMessageDetail } from "../types";
export function GroupMessageDetailPage({ channelID, msgID, navigate }: { channelID: number; msgID: number; navigate: Navigate }) { export function GroupMessageDetailPage({ channelID, msgID, navigate }: { channelID: number; msgID: number; navigate: Navigate }) {
const { t } = useI18n();
const [detail, setDetail] = useState<GroupMessageDetail | null>(null); const [detail, setDetail] = useState<GroupMessageDetail | null>(null);
const [error, setError] = useState(""); const [error, setError] = useState("");
@ -29,48 +27,48 @@ export function GroupMessageDetailPage({ channelID, msgID, navigate }: { channel
return <Alert>{error}</Alert>; return <Alert>{error}</Alert>;
} }
if (!detail) { if (!detail) {
return <LoadingSurface label={t("common.loading")} />; return <LoadingSurface label={"Loading"} />;
} }
const msg = detail.Message; const msg = detail.Message;
return ( return (
<PageFrame <PageFrame
title={t("messages.groupDetailTitle", { id: msg.ID })} title={`Group Message #${msg.ID}`}
eyebrow={t("messages.detailEyebrow")} eyebrow={"Message Detail"}
actions={<button className="btn icon-text" onClick={() => navigate("/messages/groups")}><ArrowLeft size={15} /> {t("messages.backGroup")}</button>} actions={<button className="btn icon-text" onClick={() => navigate("/messages/groups")}><ArrowLeft size={15} /> {"Back to group messages"}</button>}
> >
<div className="stacked-sections"> <div className="stacked-sections">
<section className="entity-head"> <section className="entity-head">
<div> <div>
<div className="entity-title">{t("messages.channelGroupTitle", { id: msg.ChannelID })}</div> <div className="entity-title">{`Channel / Group ${msg.ChannelID}`}</div>
<div className="entity-subtitle">{t("messages.senderSubtitle", { sender: msg.SenderUserID, date: formatUnix(msg.Date) })}</div> <div className="entity-subtitle">{`Sender ${msg.SenderUserID} · ${formatUnix(msg.Date)}`}</div>
</div> </div>
<div className="entity-badges"> <div className="entity-badges">
{msg.Deleted ? <Badge tone="danger">{t("common.deleted")}</Badge> : <Badge>{t("common.survived")}</Badge>} {msg.Deleted ? <Badge tone="danger">{"Deleted"}</Badge> : <Badge>{"Live"}</Badge>}
{msg.Pinned && <Badge tone="warn">{t("messages.pinned")}</Badge>} {msg.Pinned && <Badge tone="warn">{"Pinned"}</Badge>}
{msg.Post && <Badge>{t("messages.channelPost")}</Badge>} {msg.Post && <Badge>{"Channel post"}</Badge>}
<Badge>pts {msg.PTS}</Badge> <Badge>pts {msg.PTS}</Badge>
</div> </div>
</section> </section>
<div className="summary-grid"> <div className="summary-grid">
<Summary label={t("common.messageId")} value={String(msg.ID)} mono /> <Summary label={"Message ID"} value={String(msg.ID)} mono />
<Summary label={t("messages.channelGroup")} value={String(msg.ChannelID)} mono /> <Summary label={"Channel / Group"} value={String(msg.ChannelID)} mono />
<Summary label="From Peer" value={`${msg.FromPeerType}:${msg.FromPeerID}`} mono /> <Summary label="From Peer" value={`${msg.FromPeerType}:${msg.FromPeerID}`} mono />
<Summary label={t("common.views")} value={String(msg.ViewsCount)} /> <Summary label={"Views"} value={String(msg.ViewsCount)} />
</div> </div>
<section className="section-block"> <section className="section-block">
<SectionHead title={t("messages.channelMessageRow")} text={t("messages.channelMessagesSnapshot")} /> <SectionHead title={"Channel Message Row"} text={"channel_messages read-only snapshot"} />
<JsonBlock value={detail.MessageJSON} /> <JsonBlock value={detail.MessageJSON} />
</section> </section>
<section className="section-block"> <section className="section-block">
<SectionHead title={t("messages.channelRow")} text={t("messages.channelSnapshot")} /> <SectionHead title={"Channel Row"} text={"channels read-only snapshot"} />
<JsonBlock value={detail.ChannelJSON} /> <JsonBlock value={detail.ChannelJSON} />
</section> </section>
<section className="section-block"> <section className="section-block">
<SectionHead title={t("messages.channelUpdateEvents")} text={t("messages.channelEventsSource")} /> <SectionHead title={"Channel Update Events"} text={"durable channel_update_events"} />
<div className="table-wrap"> <div className="table-wrap">
<table className="data-table"> <table className="data-table">
<thead><tr><th>PTS</th><th>{t("common.count")}</th><th>{t("common.type")}</th><th>{t("common.messageId")}</th><th>{t("common.sender")}</th><th>{t("common.time")}</th></tr></thead> <thead><tr><th>PTS</th><th>{"Count"}</th><th>{"Type"}</th><th>{"Message ID"}</th><th>{"Sender"}</th><th>{"Time"}</th></tr></thead>
<tbody> <tbody>
{detail.UpdateEvents.map((row) => ( {detail.UpdateEvents.map((row) => (
<tr key={`${row.PTS}-${row.Type}-${row.MessageID}`}> <tr key={`${row.PTS}-${row.Type}-${row.MessageID}`}>
@ -88,12 +86,12 @@ export function GroupMessageDetailPage({ channelID, msgID, navigate }: { channel
</div> </div>
</section> </section>
<section className="section-block"> <section className="section-block">
<SectionHead title={t("messages.eventJson")} /> <SectionHead title={"Event JSON"} />
<div className="raw-grid"> <div className="raw-grid">
{detail.UpdateEvents.map((row) => ( {detail.UpdateEvents.map((row) => (
<JsonBlock key={`${row.PTS}-${row.Type}-json`} value={row.JSON} /> <JsonBlock key={`${row.PTS}-${row.Type}-json`} value={row.JSON} />
))} ))}
{detail.UpdateEvents.length === 0 && <div className="empty-panel">{t("common.noResults")}</div>} {detail.UpdateEvents.length === 0 && <div className="empty-panel">{"No results"}</div>}
</div> </div>
</section> </section>
</div> </div>

View file

@ -3,13 +3,11 @@ import { useState } from "react";
import { api, errorMessage } from "../api"; import { api, errorMessage } from "../api";
import { ChannelPicker } from "../components/EntityPicker"; import { ChannelPicker } from "../components/EntityPicker";
import { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel } from "../components/ui"; import { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel } from "../components/ui";
import { useI18n } from "../i18n";
import { channelKind, formatUnix } from "../lib/format"; import { channelKind, formatUnix } from "../lib/format";
import type { Navigate } from "../routing"; import type { Navigate } from "../routing";
import type { ChannelRow, GroupMessageListResponse } from "../types"; import type { ChannelRow, GroupMessageListResponse } from "../types";
export function GroupMessagesPage({ navigate }: { navigate: Navigate }) { export function GroupMessagesPage({ navigate }: { navigate: Navigate }) {
const { t } = useI18n();
const [channel, setChannel] = useState<ChannelRow | null>(null); const [channel, setChannel] = useState<ChannelRow | null>(null);
const [beforeDate, setBeforeDate] = useState(""); const [beforeDate, setBeforeDate] = useState("");
const [beforeID, setBeforeID] = useState(""); const [beforeID, setBeforeID] = useState("");
@ -20,7 +18,7 @@ export function GroupMessagesPage({ navigate }: { navigate: Navigate }) {
async function load(next = false) { async function load(next = false) {
setError(""); setError("");
if (!channel) { if (!channel) {
setError(t("messages.selectChannel")); setError("Search and select a supergroup or channel first");
return; return;
} }
const params = new URLSearchParams({ const params = new URLSearchParams({
@ -54,38 +52,38 @@ export function GroupMessagesPage({ navigate }: { navigate: Navigate }) {
const rows = data?.rows ?? []; const rows = data?.rows ?? [];
return ( return (
<PageFrame title={t("messages.groupTitle")} eyebrow={t("messages.groupEyebrow")}> <PageFrame title={"Group Messages"} eyebrow={"Supergroup / channel messages"}>
{error && <Alert>{error}</Alert>} {error && <Alert>{error}</Alert>}
<QueryPanel> <QueryPanel>
<div className="message-selector-grid single"> <div className="message-selector-grid single">
<ChannelPicker label={t("messages.channelGroup")} value={channel} onChange={changeChannel} /> <ChannelPicker label={"Channel / Group"} value={channel} onChange={changeChannel} />
</div> </div>
<form className="toolbar message-query" onSubmit={(event) => { event.preventDefault(); void load(false); }}> <form className="toolbar message-query" onSubmit={(event) => { event.preventDefault(); void load(false); }}>
<input value={beforeDate} onChange={(event) => setBeforeDate(event.target.value)} placeholder={t("messages.beforeDatePlaceholder")} /> <input value={beforeDate} onChange={(event) => setBeforeDate(event.target.value)} placeholder={"before_date cursor"} />
<input value={beforeID} onChange={(event) => setBeforeID(event.target.value)} placeholder={t("messages.beforeIDPlaceholder")} /> <input value={beforeID} onChange={(event) => setBeforeID(event.target.value)} placeholder={"before_msg_id cursor"} />
<input className="small-input" value={limit} onChange={(event) => setLimit(event.target.value)} placeholder={t("messages.limitPlaceholder")} /> <input className="small-input" value={limit} onChange={(event) => setLimit(event.target.value)} placeholder={"limit <= 100"} />
<button className="btn primary icon-text" type="submit"><Search size={15} /> {t("messages.searchMessages")}</button> <button className="btn primary icon-text" type="submit"><Search size={15} /> {"Search messages"}</button>
{rows.length ? <button className="btn icon-text" type="button" onClick={() => load(true)}><ChevronRight size={15} /> {t("messages.nextPage")}</button> : null} {rows.length ? <button className="btn icon-text" type="button" onClick={() => load(true)}><ChevronRight size={15} /> {"Next page"}</button> : null}
</form> </form>
</QueryPanel> </QueryPanel>
<div className="metric-row"> <div className="metric-row">
<Metric label={t("messages.currentPage")} value={String(rows.length)} /> <Metric label={"Messages on page"} value={String(rows.length)} />
<Metric label={t("messages.mediaCount")} value={String(rows.filter((row) => row.Media && row.Media !== "{}").length)} /> <Metric label={"With media"} value={String(rows.filter((row) => row.Media && row.Media !== "{}").length)} />
<Metric label={t("messages.channelPosts")} value={String(rows.filter((row) => row.Post).length)} /> <Metric label={"Channel posts"} value={String(rows.filter((row) => row.Post).length)} />
<Metric label={t("messages.channelGroup")} value={channel ? `${channel.Title || channelKind(channel, t)} (${channel.ID})` : "-"} /> <Metric label={"Channel / Group"} value={channel ? `${channel.Title || channelKind(channel)} (${channel.ID})` : "-"} />
</div> </div>
<div className="table-wrap"> <div className="table-wrap">
<table className="data-table"> <table className="data-table">
<thead> <thead>
<tr> <tr>
<th>{t("common.messageId")}</th> <th>{"Message ID"}</th>
<th>{t("common.time")}</th> <th>{"Time"}</th>
<th>{t("common.sender")}</th> <th>{"Sender"}</th>
<th>From Peer</th> <th>From Peer</th>
<th>PTS</th> <th>PTS</th>
<th>{t("common.views")}</th> <th>{"Views"}</th>
<th>{t("common.status")}</th> <th>{"Status"}</th>
<th>{t("messages.body")}</th> <th>{"Body"}</th>
<th></th> <th></th>
</tr> </tr>
</thead> </thead>
@ -99,7 +97,7 @@ export function GroupMessagesPage({ navigate }: { navigate: Navigate }) {
<td>{row.PTS}</td> <td>{row.PTS}</td>
<td>{row.ViewsCount}</td> <td>{row.ViewsCount}</td>
<td> <td>
{row.Deleted ? <Badge tone="danger">{t("common.deleted")}</Badge> : row.Pinned ? <Badge tone="warn">{t("messages.pinned")}</Badge> : <Badge>{t("common.survived")}</Badge>} {row.Deleted ? <Badge tone="danger">{"Deleted"}</Badge> : row.Pinned ? <Badge tone="warn">{"Pinned"}</Badge> : <Badge>{"Live"}</Badge>}
</td> </td>
<td className="truncate">{row.Body}</td> <td className="truncate">{row.Body}</td>
<td> <td>
@ -107,7 +105,7 @@ export function GroupMessagesPage({ navigate }: { navigate: Navigate }) {
className="row-link" className="row-link"
onClick={() => navigate(`/messages/groups/detail?channel_id=${row.ChannelID}&msg_id=${row.ID}`)} onClick={() => navigate(`/messages/groups/detail?channel_id=${row.ChannelID}&msg_id=${row.ID}`)}
> >
{t("common.detail")} <ChevronRight size={14} /> {"Details"} <ChevronRight size={14} />
</button> </button>
</td> </td>
</tr> </tr>

View file

@ -2,11 +2,10 @@ import type { FormEvent } from "react";
import { useState } from "react"; import { useState } from "react";
import { api, errorMessage } from "../api"; import { api, errorMessage } from "../api";
import { Alert } from "../components/ui"; import { Alert } from "../components/ui";
import { useI18n } from "../i18n";
import { ThemeSwitch } from "../theme"; 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 [secret, setSecret] = useState("");
const [error, setError] = useState(""); const [error, setError] = useState("");
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
@ -16,8 +15,10 @@ export function LoginPage({ onLogin }: { onLogin: (actor: string) => void }) {
setBusy(true); setBusy(true);
setError(""); setError("");
try { 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); const result = await api.login(secret);
onLogin(result.actor); onLogin({ actor: result.actor, permissions: result.permissions ?? [] });
} catch (err) { } catch (err) {
setError(errorMessage(err)); setError(errorMessage(err));
} finally { } finally {
@ -38,22 +39,22 @@ export function LoginPage({ onLogin }: { onLogin: (actor: string) => void }) {
<span className="brand-mark"><img src="/logo.png" alt="OwpenGram" /></span> <span className="brand-mark"><img src="/logo.png" alt="OwpenGram" /></span>
<span> <span>
<strong>OwpenGram</strong> <strong>OwpenGram</strong>
<small>{t("app.adminConsole")}</small> <small>{"Admin Console"}</small>
</span> </span>
</div> </div>
<div className="login-head-actions"> <div className="login-head-actions">
<ThemeSwitch /> <ThemeSwitch />
<span className="login-chip">{t("app.localAccess")}</span> <span className="login-chip">{"Local access"}</span>
</div> </div>
</div> </div>
<div className="login-copy"> <div className="login-copy">
<h1>{t("login.heading")}</h1> <h1>{"Operations Admin"}</h1>
<p>{t("login.body")}</p> <p>{"Enter credentials to open the console."}</p>
</div> </div>
{error && <Alert>{error}</Alert>} {error && <Alert>{error}</Alert>}
<form className="form-stack" onSubmit={submit}> <form className="form-stack" onSubmit={submit}>
<label> <label>
<span>{t("login.secret")}</span> <span>{"Admin password or token"}</span>
<input <input
autoFocus autoFocus
type="password" type="password"
@ -63,7 +64,7 @@ export function LoginPage({ onLogin }: { onLogin: (actor: string) => void }) {
/> />
</label> </label>
<button className="btn primary full" type="submit" disabled={busy}> <button className="btn primary full" type="submit" disabled={busy}>
{busy ? t("login.submitting") : t("login.submit")} {busy ? "Logging in" : "Log in"}
</button> </button>
</form> </form>
</section> </section>

View file

@ -3,13 +3,11 @@ import { useEffect, useState } from "react";
import { api, errorMessage } from "../api"; import { api, errorMessage } from "../api";
import { ActionButton } from "../components/ActionButton"; import { ActionButton } from "../components/ActionButton";
import { Alert, Badge, EmptyRow, JsonBlock, LoadingSurface, PageFrame, SectionHead, SplitLayout, Summary } from "../components/ui"; import { Alert, Badge, EmptyRow, JsonBlock, LoadingSurface, PageFrame, SectionHead, SplitLayout, Summary } from "../components/ui";
import { useI18n } from "../i18n";
import { formatDate, formatUnix } from "../lib/format"; import { formatDate, formatUnix } from "../lib/format";
import type { Navigate } from "../routing"; import type { Navigate } from "../routing";
import type { MessageDetail } from "../types"; import type { MessageDetail } from "../types";
export function MessageDetailPage({ ownerUserID, msgID, navigate }: { ownerUserID: number; msgID: number; navigate: Navigate }) { export function MessageDetailPage({ ownerUserID, msgID, navigate }: { ownerUserID: number; msgID: number; navigate: Navigate }) {
const { t } = useI18n();
const [detail, setDetail] = useState<MessageDetail | null>(null); const [detail, setDetail] = useState<MessageDetail | null>(null);
const [error, setError] = useState(""); const [error, setError] = useState("");
@ -30,55 +28,55 @@ export function MessageDetailPage({ ownerUserID, msgID, navigate }: { ownerUserI
return <Alert>{error}</Alert>; return <Alert>{error}</Alert>;
} }
if (!detail) { if (!detail) {
return <LoadingSurface label={t("common.loading")} />; return <LoadingSurface label={"Loading"} />;
} }
const msg = detail.Message; const msg = detail.Message;
return ( return (
<PageFrame <PageFrame
title={t("messages.privateDetailTitle", { id: msg.BoxID })} title={`Message #${msg.BoxID}`}
eyebrow={t("messages.detailEyebrow")} eyebrow={"Message Detail"}
actions={<button className="btn icon-text" onClick={() => navigate("/messages/private")}><ArrowLeft size={15} /> {t("messages.backPrivate")}</button>} actions={<button className="btn icon-text" onClick={() => navigate("/messages/private")}><ArrowLeft size={15} /> {"Back to private messages"}</button>}
> >
<SplitLayout <SplitLayout
main={ main={
<div className="stacked-sections"> <div className="stacked-sections">
<section className="entity-head"> <section className="entity-head">
<div> <div>
<div className="entity-title">{t("messages.ownerPeerTitle", { owner: msg.OwnerUserID, peer: msg.PeerID })}</div> <div className="entity-title">{`Owner ${msg.OwnerUserID} · Peer ${msg.PeerID}`}</div>
<div className="entity-subtitle">{t("messages.senderSubtitle", { sender: msg.FromUserID, date: formatUnix(msg.Date) })}</div> <div className="entity-subtitle">{`Sender ${msg.FromUserID} · ${formatUnix(msg.Date)}`}</div>
</div> </div>
<div className="entity-badges"> <div className="entity-badges">
{msg.Deleted ? <Badge tone="danger">{t("common.deleted")}</Badge> : <Badge>{t("common.survived")}</Badge>} {msg.Deleted ? <Badge tone="danger">{"Deleted"}</Badge> : <Badge>{"Live"}</Badge>}
<Badge>pts {msg.PTS}</Badge> <Badge>pts {msg.PTS}</Badge>
<Badge>{msg.Outgoing ? t("messages.outgoing") : t("messages.incoming")}</Badge> <Badge>{msg.Outgoing ? "Outgoing" : "Incoming"}</Badge>
</div> </div>
</section> </section>
<div className="summary-grid"> <div className="summary-grid">
<Summary label={t("messages.boxID")} value={String(msg.BoxID)} mono /> <Summary label={"Message box ID"} value={String(msg.BoxID)} mono />
<Summary label={t("messages.privateMessageID")} value={String(msg.PrivateMessageID)} mono /> <Summary label={"Private message ID"} value={String(msg.PrivateMessageID)} mono />
<Summary label={t("messages.messageSender")} value={String(msg.MessageSenderID)} mono /> <Summary label={"Message sender"} value={String(msg.MessageSenderID)} mono />
<Summary label={t("common.time")} value={formatUnix(msg.Date)} /> <Summary label={"Time"} value={formatUnix(msg.Date)} />
</div> </div>
<section className="section-block"> <section className="section-block">
<SectionHead title={t("messages.messageBox")} text={t("messages.messageBoxesSnapshot")} /> <SectionHead title={"Message Box"} text={"message_boxes read-only snapshot"} />
<JsonBlock value={detail.MessageJSON} /> <JsonBlock value={detail.MessageJSON} />
</section> </section>
<div className="raw-grid"> <div className="raw-grid">
<section className="section-block"> <section className="section-block">
<SectionHead title={t("messages.dialogRow")} text={t("messages.dialogSnapshot")} /> <SectionHead title={"Dialog Row"} text={"dialogs read-only snapshot"} />
<JsonBlock value={detail.DialogJSON} /> <JsonBlock value={detail.DialogJSON} />
</section> </section>
<section className="section-block"> <section className="section-block">
<SectionHead title={t("messages.privateRow")} text={t("messages.privateSnapshot")} /> <SectionHead title={"Private Message Row"} text={"private_messages read-only snapshot"} />
<JsonBlock value={detail.PrivateJSON} /> <JsonBlock value={detail.PrivateJSON} />
</section> </section>
</div> </div>
<section className="section-block"> <section className="section-block">
<SectionHead title={t("messages.userUpdateEvents")} text={t("messages.userEventsSource")} /> <SectionHead title={"Update Events"} text={"durable user_update_events"} />
<div className="table-wrap"> <div className="table-wrap">
<table className="data-table"> <table className="data-table">
<thead><tr><th>PTS</th><th>{t("common.count")}</th><th>{t("common.type")}</th><th>{t("common.time")}</th></tr></thead> <thead><tr><th>PTS</th><th>{"Count"}</th><th>{"Type"}</th><th>{"Time"}</th></tr></thead>
<tbody> <tbody>
{detail.UpdateEvents.map((row) => <tr key={`${row.PTS}-${row.Type}`}><td>{row.PTS}</td><td>{row.PTSCount}</td><td>{row.Type}</td><td>{formatUnix(row.Date)}</td></tr>)} {detail.UpdateEvents.map((row) => <tr key={`${row.PTS}-${row.Type}`}><td>{row.PTS}</td><td>{row.PTSCount}</td><td>{row.Type}</td><td>{formatUnix(row.Date)}</td></tr>)}
{detail.UpdateEvents.length === 0 && <EmptyRow colSpan={4} />} {detail.UpdateEvents.length === 0 && <EmptyRow colSpan={4} />}
@ -87,10 +85,10 @@ export function MessageDetailPage({ ownerUserID, msgID, navigate }: { ownerUserI
</div> </div>
</section> </section>
<section className="section-block"> <section className="section-block">
<SectionHead title={t("messages.dispatchOutbox")} text={t("messages.outboxSource")} /> <SectionHead title={"Dispatch Queue"} text={"online/offline dispatch_outbox"} />
<div className="table-wrap"> <div className="table-wrap">
<table className="data-table"> <table className="data-table">
<thead><tr><th>ID</th><th>{t("account.userID")}</th><th>PTS</th><th>{t("common.type")}</th><th>{t("common.status")}</th><th>{t("messages.attempts")}</th><th>{t("common.updatedAt")}</th></tr></thead> <thead><tr><th>ID</th><th>{"User ID"}</th><th>PTS</th><th>{"Type"}</th><th>{"Status"}</th><th>{"Attempts"}</th><th>{"Updated"}</th></tr></thead>
<tbody> <tbody>
{detail.Outbox.map((row) => <tr key={row.ID}><td>{row.ID}</td><td>{row.TargetUserID}</td><td>{row.PTS}</td><td>{row.EventType}</td><td>{row.Status}</td><td>{row.Attempts}</td><td>{formatDate(row.UpdatedAt)}</td></tr>)} {detail.Outbox.map((row) => <tr key={row.ID}><td>{row.ID}</td><td>{row.TargetUserID}</td><td>{row.PTS}</td><td>{row.EventType}</td><td>{row.Status}</td><td>{row.Attempts}</td><td>{formatDate(row.UpdatedAt)}</td></tr>)}
{detail.Outbox.length === 0 && <EmptyRow colSpan={7} />} {detail.Outbox.length === 0 && <EmptyRow colSpan={7} />}
@ -102,9 +100,9 @@ export function MessageDetailPage({ ownerUserID, msgID, navigate }: { ownerUserI
} }
side={ side={
<section className="action-dock"> <section className="action-dock">
<div className="dock-title">{t("common.operations")}</div> <div className="dock-title">{"Operations"}</div>
<ActionButton <ActionButton
label={t("messages.deleteThis")} label={"Delete this message"}
icon={<Trash2 size={15} />} icon={<Trash2 size={15} />}
path="/api/actions/delete-messages" path="/api/actions/delete-messages"
payload={() => ({ owner_user_id: msg.OwnerUserID, peer_id: msg.PeerID, ids: [msg.BoxID], revoke: true })} payload={() => ({ owner_user_id: msg.OwnerUserID, peer_id: msg.PeerID, ids: [msg.BoxID], revoke: true })}

View file

@ -4,13 +4,11 @@ import { api, errorMessage } from "../api";
import { ActionButton } from "../components/ActionButton"; import { ActionButton } from "../components/ActionButton";
import { UserPicker } from "../components/EntityPicker"; import { UserPicker } from "../components/EntityPicker";
import { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel } from "../components/ui"; import { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel } from "../components/ui";
import { useI18n } from "../i18n";
import { displayName, formatUnix, parseIDs, toInt } from "../lib/format"; import { displayName, formatUnix, parseIDs, toInt } from "../lib/format";
import type { Navigate } from "../routing"; import type { Navigate } from "../routing";
import type { AccountRow, MessageListResponse } from "../types"; import type { AccountRow, MessageListResponse } from "../types";
export function MessagesPage({ navigate }: { navigate: Navigate }) { export function MessagesPage({ navigate }: { navigate: Navigate }) {
const { t } = useI18n();
const [owner, setOwner] = useState<AccountRow | null>(null); const [owner, setOwner] = useState<AccountRow | null>(null);
const [peer, setPeer] = useState<AccountRow | null>(null); const [peer, setPeer] = useState<AccountRow | null>(null);
const [beforeDate, setBeforeDate] = useState(""); const [beforeDate, setBeforeDate] = useState("");
@ -27,7 +25,7 @@ export function MessagesPage({ navigate }: { navigate: Navigate }) {
async function load(next = false) { async function load(next = false) {
setError(""); setError("");
if (!owner || !peer) { if (!owner || !peer) {
setError(t("messages.selectPrivatePeers")); setError("Search and select the owner user and peer user first");
return; return;
} }
const params = new URLSearchParams({ const params = new URLSearchParams({
@ -67,46 +65,46 @@ export function MessagesPage({ navigate }: { navigate: Navigate }) {
} }
return ( return (
<PageFrame title={t("messages.privateTitle")} eyebrow={t("messages.privateEyebrow")}> <PageFrame title={"Private Messages"} eyebrow={"Private message boxes"}>
{error && <Alert>{error}</Alert>} {error && <Alert>{error}</Alert>}
<QueryPanel> <QueryPanel>
<div className="message-selector-grid"> <div className="message-selector-grid">
<UserPicker label={t("messages.ownerUser")} value={owner} onChange={changeOwner} /> <UserPicker label={"Owner user"} value={owner} onChange={changeOwner} />
<UserPicker label={t("messages.peerUser")} value={peer} onChange={changePeer} /> <UserPicker label={"Peer user"} value={peer} onChange={changePeer} />
</div> </div>
<form className="toolbar message-query" onSubmit={(event) => { event.preventDefault(); void load(false); }}> <form className="toolbar message-query" onSubmit={(event) => { event.preventDefault(); void load(false); }}>
<input value={beforeDate} onChange={(event) => setBeforeDate(event.target.value)} placeholder={t("messages.beforeDatePlaceholder")} /> <input value={beforeDate} onChange={(event) => setBeforeDate(event.target.value)} placeholder={"before_date cursor"} />
<input value={beforeID} onChange={(event) => setBeforeID(event.target.value)} placeholder={t("messages.beforeIDPlaceholder")} /> <input value={beforeID} onChange={(event) => setBeforeID(event.target.value)} placeholder={"before_msg_id cursor"} />
<input className="small-input" value={limit} onChange={(event) => setLimit(event.target.value)} placeholder={t("messages.limitPlaceholder")} /> <input className="small-input" value={limit} onChange={(event) => setLimit(event.target.value)} placeholder={"limit <= 100"} />
<button className="btn primary icon-text" type="submit"><Search size={15} /> {t("messages.searchMessages")}</button> <button className="btn primary icon-text" type="submit"><Search size={15} /> {"Search messages"}</button>
{data?.rows.length ? <button className="btn icon-text" type="button" onClick={() => load(true)}><ChevronRight size={15} /> {t("messages.nextPage")}</button> : null} {data?.rows.length ? <button className="btn icon-text" type="button" onClick={() => load(true)}><ChevronRight size={15} /> {"Next page"}</button> : null}
</form> </form>
</QueryPanel> </QueryPanel>
<div className="metric-row"> <div className="metric-row">
<Metric label={t("messages.currentPage")} value={String(data?.rows.length ?? 0)} /> <Metric label={"Messages on page"} value={String(data?.rows.length ?? 0)} />
<Metric label={t("messages.deleted")} value={String((data?.rows ?? []).filter((row) => row.Deleted).length)} tone="danger" /> <Metric label={"Deleted"} value={String((data?.rows ?? []).filter((row) => row.Deleted).length)} tone="danger" />
<Metric label={t("messages.outgoing")} value={String((data?.rows ?? []).filter((row) => row.Outgoing).length)} /> <Metric label={"Outgoing"} value={String((data?.rows ?? []).filter((row) => row.Outgoing).length)} />
<Metric label={t("messages.ownerPeer")} value={owner && peer ? `${displayName(owner)} / ${displayName(peer)}` : "-"} /> <Metric label={"Owner / Peer"} value={owner && peer ? `${displayName(owner)} / ${displayName(peer)}` : "-"} />
</div> </div>
<div className="operation-row"> <div className="operation-row">
<div className="operation-box"> <div className="operation-box">
<div className="operation-title"><Trash2 size={15} /> {t("messages.deleteSelected")}</div> <div className="operation-title"><Trash2 size={15} /> {"Delete selected messages"}</div>
<input value={ids} onChange={(event) => setIDs(event.target.value)} placeholder={t("messages.idsPlaceholder")} /> <input value={ids} onChange={(event) => setIDs(event.target.value)} placeholder={"Message IDs, comma separated"} />
<label className="checkline"><input type="checkbox" checked={revoke} onChange={(event) => setRevoke(event.target.checked)} /> {t("messages.revoke")}</label> <label className="checkline"><input type="checkbox" checked={revoke} onChange={(event) => setRevoke(event.target.checked)} /> {"Revoke for both sides"}</label>
<ActionButton path="/api/actions/delete-messages" label={t("messages.previewDelete")} payload={() => ({ <ActionButton path="/api/actions/delete-messages" label={"Dry-run delete"} payload={() => ({
owner_user_id: owner?.ID ?? 0, owner_user_id: owner?.ID ?? 0,
peer_id: peer?.ID ?? 0, peer_id: peer?.ID ?? 0,
ids: parseIDs(ids, t("messages.msgIDsInvalid")), ids: parseIDs(ids, "Message IDs are invalid"),
revoke revoke
})} /> })} />
</div> </div>
<div className="operation-box"> <div className="operation-box">
<div className="operation-title"><History size={15} /> {t("messages.clearHistory")}</div> <div className="operation-title"><History size={15} /> {"Clear private history"}</div>
<input value={maxID} onChange={(event) => setMaxID(event.target.value)} placeholder={t("messages.maxIDPlaceholder")} /> <input value={maxID} onChange={(event) => setMaxID(event.target.value)} placeholder={"max_id cutoff"} />
<input value={maxBatches} onChange={(event) => setMaxBatches(event.target.value)} placeholder={t("messages.maxBatchesPlaceholder")} /> <input value={maxBatches} onChange={(event) => setMaxBatches(event.target.value)} placeholder={"max_batches"} />
<label className="checkline"><input type="checkbox" checked={revoke} onChange={(event) => setRevoke(event.target.checked)} /> {t("messages.revoke")}</label> <label className="checkline"><input type="checkbox" checked={revoke} onChange={(event) => setRevoke(event.target.checked)} /> {"Revoke for both sides"}</label>
<label className="checkline"><input type="checkbox" checked={justClear} onChange={(event) => setJustClear(event.target.checked)} /> {t("messages.justClear")}</label> <label className="checkline"><input type="checkbox" checked={justClear} onChange={(event) => setJustClear(event.target.checked)} /> {"Clear only this side"}</label>
<ActionButton path="/api/actions/delete-history" label={t("messages.previewClearHistory")} payload={() => ({ <ActionButton path="/api/actions/delete-history" label={"Dry-run clear history"} payload={() => ({
owner_user_id: owner?.ID ?? 0, owner_user_id: owner?.ID ?? 0,
peer_id: peer?.ID ?? 0, peer_id: peer?.ID ?? 0,
max_id: toInt(maxID), max_id: toInt(maxID),
@ -120,13 +118,13 @@ export function MessagesPage({ navigate }: { navigate: Navigate }) {
<table className="data-table"> <table className="data-table">
<thead> <thead>
<tr> <tr>
<th>{t("common.messageId")}</th> <th>{"Message ID"}</th>
<th>{t("common.time")}</th> <th>{"Time"}</th>
<th>{t("common.sender")}</th> <th>{"Sender"}</th>
<th>{t("messages.direction")}</th> <th>{"Direction"}</th>
<th>PTS</th> <th>PTS</th>
<th>{t("common.status")}</th> <th>{"Status"}</th>
<th>{t("messages.body")}</th> <th>{"Body"}</th>
<th></th> <th></th>
</tr> </tr>
</thead> </thead>
@ -136,16 +134,16 @@ export function MessagesPage({ navigate }: { navigate: Navigate }) {
<td className="mono">{row.BoxID}</td> <td className="mono">{row.BoxID}</td>
<td>{formatUnix(row.Date)}</td> <td>{formatUnix(row.Date)}</td>
<td className="mono">{row.FromUserID}</td> <td className="mono">{row.FromUserID}</td>
<td>{row.Outgoing ? t("messages.outgoing") : t("messages.incoming")}</td> <td>{row.Outgoing ? "Outgoing" : "Incoming"}</td>
<td>{row.PTS}</td> <td>{row.PTS}</td>
<td>{row.Deleted ? <Badge tone="danger">{t("common.deleted")}</Badge> : <Badge>{t("common.survived")}</Badge>}</td> <td>{row.Deleted ? <Badge tone="danger">{"Deleted"}</Badge> : <Badge>{"Live"}</Badge>}</td>
<td className="truncate">{row.Body}</td> <td className="truncate">{row.Body}</td>
<td> <td>
<button <button
className="row-link" className="row-link"
onClick={() => navigate(`/messages/private/detail?owner_user_id=${row.OwnerUserID}&msg_id=${row.BoxID}`)} onClick={() => navigate(`/messages/private/detail?owner_user_id=${row.OwnerUserID}&msg_id=${row.BoxID}`)}
> >
{t("common.detail")} <ChevronRight size={14} /> {"Details"} <ChevronRight size={14} />
</button> </button>
</td> </td>
</tr> </tr>

View file

@ -0,0 +1,402 @@
import { ArrowLeft, CheckCircle2, RefreshCw, ShieldCheck } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { api, errorMessage } from "../api";
import { Alert, JsonBlock, LoadingSurface, PageFrame, SectionHead, SplitLayout, Summary } from "../components/ui";
import { formatDate } from "../lib/format";
import type { Navigate } from "../routing";
import type { ModerationCaseDetail, ModerationReport } from "../types";
import {
CaseSeverity,
CaseStatus,
moderationEnumLabel,
moderationTargetLabel
} from "./ModerationCasesPage";
type DecisionPreset = "no_violation" | "scam" | "fake" | "freeze" | "scam_freeze" | "fake_freeze" | "delete_messages" | "delete_account";
export function ModerationCaseDetailPage({ id, navigate }: { id: number; navigate: Navigate }) {
const [detail, setDetail] = useState<ModerationCaseDetail | null>(null);
const [report, setReport] = useState<ModerationReport | null>(null);
const [reason, setReason] = useState("");
const [preset, setPreset] = useState<DecisionPreset>("no_violation");
const [messageIDs, setMessageIDs] = useState("");
const [ownerUserID, setOwnerUserID] = useState("");
const [revokeMessages, setRevokeMessages] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
function selectReport(next: ModerationReport | null) {
setReport(next);
if (!next) return;
const ids = next.Items
.filter((item) => item.Kind === "message")
.map((item) => Number(item.ItemID))
.filter((value) => Number.isSafeInteger(value) && value > 0);
setMessageIDs(ids.join(", "));
setOwnerUserID(String(next.ReporterUserID));
}
async function load() {
setError("");
try {
const next = await api.moderationCase(id);
setDetail(next);
const reportID = next.ReportIDs[0];
selectReport(reportID ? await api.moderationReport(reportID) : null);
} catch (err) {
setError(errorMessage(err));
}
}
useEffect(() => {
void load();
}, [id]);
const selectedActions = useMemo(
() => actionsForPreset(
preset,
detail?.Case.Target.Type,
parseMessageIDs(messageIDs),
Number(ownerUserID),
revokeMessages
),
[preset, detail?.Case.Target.Type, messageIDs, ownerUserID, revokeMessages]
);
const appealRemedy = useMemo(
() => detail ? requiredAppealRemedy(detail) : { actions: [], label: "None", blocked: false },
[detail]
);
async function claim() {
if (!detail) return;
setBusy(true);
setError("");
try {
await api.claimModerationCase(id, detail.Case.Version);
await load();
} catch (err) {
setError(errorMessage(err));
} finally {
setBusy(false);
}
}
async function decide() {
if (!detail || !reason.trim()) {
setError("A review reason is required.");
return;
}
if (preset === "delete_messages" && selectedActions.length === 0) {
setError(detail.Case.Target.Type === "user"
? "Private-message deletion requires valid evidence message IDs and the reporter's owner_user_id."
: "Channel-message deletion requires at least one valid evidence message ID.");
return;
}
if (!window.confirm(`Submit the “${decisionPresetLabel(preset)}” decision? The action will run through the durable action queue.`)) return;
setBusy(true);
setError("");
try {
const result = await api.decideModerationCase(id, {
expected_version: detail.Case.Version,
reason: reason.trim(),
kind: preset === "no_violation" ? "no_violation" : "violation",
actions: selectedActions
});
setDetail(result.case);
setReason("");
} catch (err) {
setError(errorMessage(err));
} finally {
setBusy(false);
}
}
async function reviewAppeal(appealID: number, granted: boolean) {
if (!detail || !reason.trim()) {
setError("An appeal review reason is required.");
return;
}
if (!window.confirm(granted ? "Grant this appeal?" : "Deny this appeal?")) return;
setBusy(true);
try {
const result = await api.reviewModerationAppeal(id, appealID, {
expected_version: detail.Case.Version,
reason: reason.trim(),
granted,
actions: granted ? appealRemedy.actions : []
});
setDetail(result.case);
setReason("");
} catch (err) {
setError(errorMessage(err));
} finally {
setBusy(false);
}
}
if (error && !detail) return <Alert>{error}</Alert>;
if (!detail) return <LoadingSurface label={"Loading moderation case…"} />;
const item = detail.Case;
const canClaim = item.Status === "open" || item.Status === "in_review" || item.Status === "appeal_review";
const canDecide = (item.Status === "in_review" || item.Status === "action_failed") && Boolean(item.AssignedTo);
const canSubmitDecision = canDecide && (item.Status !== "action_failed" || preset !== "no_violation");
const pendingAppeal = detail.Appeals.find((appeal) => appeal.Status === "pending");
return (
<PageFrame
title={`Review case #${item.ID}`}
eyebrow={"Moderation / Case detail"}
actions={
<>
<button className="btn icon-text" onClick={() => navigate("/moderation")}>
<ArrowLeft size={15} /> {"Back to queue"}
</button>
<button className="btn icon-text" onClick={load}>
<RefreshCw size={15} /> {"Refresh"}
</button>
</>
}
>
{error && <Alert>{error}</Alert>}
<SplitLayout
main={
<div className="stacked-sections">
<section className="entity-head">
<div>
<div className="entity-title">{moderationTargetLabel(item.Target.Type, item.Target.ID)}</div>
<div className="entity-subtitle">
{`Version ${item.Version} · Updated ${formatDate(item.UpdatedAt)}`}
</div>
</div>
<div className="entity-badges">
<CaseStatus status={item.Status} />
<CaseSeverity value={item.Severity} />
</div>
</section>
<div className="summary-grid">
<Summary label={"Target"} value={moderationTargetLabel(item.Target.Type, item.Target.ID)} mono />
<Summary
label={"Reports"}
value={`${item.ReportCount} reports from ${item.DistinctReporterCount} reporters`}
/>
<Summary label={"Reviewer"} value={item.AssignedTo || "-"} />
<Summary
label={"First / latest report"}
value={`${formatDate(item.FirstReportAt)} / ${formatDate(item.LastReportAt)}`}
/>
</div>
<section className="section-block">
<SectionHead title={"Report evidence"} text={"Shows up to the latest 100 reports; snapshots are frozen when reports are admitted."} />
<div className="toolbar">
{detail.ReportIDs.map((reportID) => (
<button className="btn" key={reportID} onClick={async () => selectReport(await api.moderationReport(reportID))}>
#{reportID}
</button>
))}
</div>
{report && (
<>
<div className="summary-grid">
<Summary
label={"Source / Reason"}
value={`${moderationEnumLabel("source", report.Source)} / ${moderationEnumLabel("reason", report.Reason)}`}
/>
<Summary label={"Reporter"} value={String(report.ReporterUserID)} mono />
<Summary label={"Option"} value={report.Option} mono />
<Summary label={"Time"} value={formatDate(report.CreatedAt)} />
</div>
{report.Comment && <p className="about-text">{report.Comment}</p>}
<JsonBlock value={JSON.stringify(report, null, 2)} />
</>
)}
</section>
<section className="section-block">
<SectionHead title={"Decision and action audit"} text={"Actions run idempotently through a lease worker; failures retain their error and attempt count."} />
<JsonBlock value={JSON.stringify({ decisions: detail.Decisions, actions: detail.Actions }, null, 2)} />
</section>
{detail.Appeals.length > 0 && (
<section className="section-block">
<SectionHead title={"Appeals"} />
<JsonBlock value={JSON.stringify(detail.Appeals, null, 2)} />
</section>
)}
</div>
}
side={
<section className="action-dock">
<div className="dock-title">{"Case actions"}</div>
{canClaim && (
<button className="btn primary icon-text" disabled={busy} onClick={claim}>
<ShieldCheck size={15} /> {item.AssignedTo ? "Renew claim" : "Claim case"}
</button>
)}
<label className="field">
<span>{"Review reason"}</span>
<textarea value={reason} onChange={(event) => setReason(event.target.value)} rows={5} />
</label>
<label className="field">
<span>{"Decision template"}</span>
<select value={preset} onChange={(event) => setPreset(event.target.value as DecisionPreset)}>
<option value="no_violation">{"No violation (dismiss report)"}</option>
<option value="scam">{"Mark as SCAM"}</option>
<option value="fake">{"Mark as FAKE"}</option>
<option value="freeze">{"Freeze account"}</option>
<option value="scam_freeze">{"SCAM + freeze"}</option>
<option value="fake_freeze">{"FAKE + freeze"}</option>
<option value="delete_messages">{"Delete messages covered by evidence"}</option>
<option value="delete_account">{"Delete account"}</option>
</select>
</label>
{preset === "delete_messages" && (
<>
<label className="field">
<span>{"Evidence message IDs (comma-separated)"}</span>
<input value={messageIDs} onChange={(event) => setMessageIDs(event.target.value)} placeholder="101, 102" />
</label>
{item.Target.Type === "user" && (
<>
<label className="field">
<span>{"Private-chat owner_user_id"}</span>
<input value={ownerUserID} onChange={(event) => setOwnerUserID(event.target.value)} inputMode="numeric" />
</label>
<label className="field checkbox-field">
<input type="checkbox" checked={revokeMessages} onChange={(event) => setRevokeMessages(event.target.checked)} />
<span>{"Revoke for both sides"}</span>
</label>
</>
)}
<Alert>{"The server will verify again that every message ID exists in this case's immutable report evidence."}</Alert>
</>
)}
{item.Status === "action_failed" && preset === "no_violation" && (
<Alert>{"The action was partially executed and cannot be changed directly to no violation. Select a new action to retry while retaining the previous failure audit."}</Alert>
)}
{canDecide && (
<button className="btn danger icon-text" disabled={busy || !canSubmitDecision} onClick={decide}>
<CheckCircle2 size={15} /> {item.Status === "action_failed"
? "Retry action"
: "Submit decision"}
</button>
)}
{pendingAppeal && item.AssignedTo && (
<>
<div className="dock-title">{`Appeal review #${pendingAppeal.ID}`}</div>
<Summary label={"Automatic remedy after approval"} value={appealRemedy.label} />
{appealRemedy.blocked && (
<Alert>{"The case contains a completed irreversible deletion. It cannot be marked as approved and restored; deny it or escalate for manual handling."}</Alert>
)}
<button className="btn" disabled={busy} onClick={() => reviewAppeal(pendingAppeal.ID, false)}>
{"Deny appeal"}
</button>
<button className="btn primary" disabled={busy || appealRemedy.blocked} onClick={() => reviewAppeal(pendingAppeal.ID, true)}>
{"Grant appeal"}
</button>
</>
)}
</section>
}
/>
</PageFrame>
);
}
function actionsForPreset(
preset: DecisionPreset,
targetType: string | undefined,
messageIDs: number[],
ownerUserID: number,
revoke: boolean
): Array<{ kind: string; payload: Record<string, unknown> }> {
switch (preset) {
case "scam":
return [{ kind: "mark_scam", payload: {} }];
case "fake":
return [{ kind: "mark_fake", payload: {} }];
case "freeze":
return [{ kind: "freeze_account", payload: {} }];
case "scam_freeze":
return [{ kind: "mark_scam", payload: {} }, { kind: "freeze_account", payload: {} }];
case "fake_freeze":
return [{ kind: "mark_fake", payload: {} }, { kind: "freeze_account", payload: {} }];
case "delete_messages":
if (messageIDs.length === 0) return [];
if (targetType === "channel") {
return [{ kind: "delete_channel_message", payload: { ids: messageIDs } }];
}
if (targetType === "user" && Number.isSafeInteger(ownerUserID) && ownerUserID > 0) {
return [{ kind: "delete_private_message", payload: { owner_user_id: ownerUserID, ids: messageIDs, revoke } }];
}
return [];
case "delete_account":
return [{ kind: "delete_account", payload: {} }];
default:
return [];
}
}
function parseMessageIDs(raw: string): number[] {
const values = raw
.split(/[,\s]+/)
.filter(Boolean)
.map(Number);
if (values.length === 0 || values.some((value) => !Number.isSafeInteger(value) || value <= 0)) return [];
return [...new Set(values)];
}
function requiredAppealRemedy(detail: ModerationCaseDetail): {
actions: Array<{ kind: string; payload: Record<string, unknown> }>;
label: string;
blocked: boolean;
} {
let flagsActive = false;
let freezeActive = false;
let irreversible = false;
for (const action of [...detail.Actions].sort((left, right) => left.ID - right.ID)) {
if (action.Status !== "succeeded") continue;
switch (action.Kind) {
case "mark_scam":
case "mark_fake":
flagsActive = true;
break;
case "clear_peer_flags":
flagsActive = false;
break;
case "freeze_account":
freezeActive = true;
break;
case "unfreeze_account":
freezeActive = false;
break;
case "delete_private_message":
case "delete_channel_message":
case "delete_account":
irreversible = true;
break;
}
}
const actions: Array<{ kind: string; payload: Record<string, unknown> }> = [];
const labels: string[] = [];
if (flagsActive) {
actions.push({ kind: "clear_peer_flags", payload: {} });
labels.push("Clear SCAM / FAKE");
}
if (freezeActive) {
actions.push({ kind: "unfreeze_account", payload: {} });
labels.push("Unfreeze account");
}
return { actions, label: labels.join(" + ") || "No recovery action needed", blocked: irreversible };
}
function decisionPresetLabel(preset: DecisionPreset): string {
const labels: Record<DecisionPreset, string> = {
no_violation: "No violation (dismiss report)",
scam: "Mark as SCAM",
fake: "Mark as FAKE",
freeze: "Freeze account",
scam_freeze: "SCAM + freeze",
fake_freeze: "FAKE + freeze",
delete_messages: "Delete messages covered by evidence",
delete_account: "Delete account"
};
return labels[preset];
}

View file

@ -0,0 +1,209 @@
import { ChevronRight, RefreshCw, ShieldAlert } from "lucide-react";
import { useEffect, useState } from "react";
import { api, errorMessage } from "../api";
import { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel } from "../components/ui";
import { formatDate } from "../lib/format";
import type { Navigate } from "../routing";
import type { ModerationCaseRow } from "../types";
const defaultStatuses = "open,in_review,action_pending,action_failed,appeal_review";
const allStatuses = "open,in_review,action_pending,action_failed,resolved,dismissed,appeal_review";
const statusFilterOptions = [
{ value: defaultStatuses, label: "Active queue" },
{ value: allStatuses, label: "All statuses" },
{ value: "open", label: "Open" },
{ value: "in_review", label: "In review" },
{ value: "action_pending", label: "Action pending" },
{ value: "action_failed", label: "Action failed" },
{ value: "appeal_review", label: "Appeal review" },
{ value: "resolved", label: "Resolved" },
{ value: "dismissed", label: "Dismissed" }
];
export function ModerationCasesPage({ navigate }: { navigate: Navigate }) {
const [statuses, setStatuses] = useState(defaultStatuses);
const [assignedTo, setAssignedTo] = useState("");
const [rows, setRows] = useState<ModerationCaseRow[]>([]);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
async function load() {
setBusy(true);
setError("");
try {
const params = new URLSearchParams({ statuses, limit: "100" });
if (assignedTo.trim()) params.set("assigned_to", assignedTo.trim());
setRows((await api.moderationCases(params)).cases);
} catch (err) {
setError(errorMessage(err));
} finally {
setBusy(false);
}
}
useEffect(() => {
void load();
}, []);
const pendingActions = rows.filter((row) => row.Status === "action_pending" || row.Status === "action_failed").length;
const critical = rows.filter((row) => row.Severity === 4).length;
return (
<PageFrame
title={"Reports and Moderation"}
eyebrow={"Moderation / Cases"}
actions={
<button className="btn icon-text" type="button" onClick={load} disabled={busy}>
<RefreshCw size={15} className={busy ? "spin" : ""} /> {"Refresh"}
</button>
}
>
{error && <Alert>{error}</Alert>}
<div className="metric-row">
<Metric label={"Current queue"} value={String(rows.length)} />
<Metric label={"Critical cases"} value={String(critical)} tone={critical ? "danger" : "neutral"} />
<Metric label={"Pending / failed actions"} value={String(pendingActions)} tone={pendingActions ? "warn" : "good"} />
</div>
<QueryPanel>
<form className="toolbar" onSubmit={(event) => { event.preventDefault(); void load(); }}>
<label className="field-inline">
<span>{"Status"}</span>
<select
aria-label={"Case status filter"}
value={statuses}
onChange={(event) => setStatuses(event.target.value)}
>
{statusFilterOptions.map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
</label>
<label className="field-inline">
<span>{"Reviewer"}</span>
<input
value={assignedTo}
onChange={(event) => setAssignedTo(event.target.value)}
placeholder={"Leave blank for all"}
/>
</label>
<button className="btn primary icon-text" type="submit" disabled={busy}>
<ShieldAlert size={15} /> {"Search"}
</button>
</form>
</QueryPanel>
<div className="table-wrap">
<table className="data-table">
<thead>
<tr>
<th>{"Case"}</th>
<th>{"Target"}</th>
<th>{"Status"}</th>
<th>{"Severity"}</th>
<th>{"Reports / Reporters"}</th>
<th>{"Reviewer"}</th>
<th>{"Latest report"}</th>
<th></th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.ID}>
<td className="mono">#{row.ID}</td>
<td className="mono">{moderationTargetLabel(row.Target.Type, row.Target.ID)}</td>
<td><CaseStatus status={row.Status} /></td>
<td><CaseSeverity value={row.Severity} /></td>
<td>{row.ReportCount} / {row.DistinctReporterCount}</td>
<td>{row.AssignedTo || "-"}</td>
<td>{formatDate(row.LastReportAt)}</td>
<td>
<button className="row-link" onClick={() => navigate(`/moderation/${row.ID}`)}>
{"Review"} <ChevronRight size={14} />
</button>
</td>
</tr>
))}
{rows.length === 0 && <EmptyRow colSpan={8} />}
</tbody>
</table>
</div>
</PageFrame>
);
}
export function CaseStatus({ status }: { status: string }) {
const tone = status === "resolved" || status === "dismissed"
? "good"
: status === "action_failed"
? "danger"
: status === "action_pending"
? "warn"
: "neutral";
return <Badge tone={tone}>{moderationEnumLabel("status", status)}</Badge>;
}
const severityLabels: Record<string, string> = {
low: "Low",
medium: "Medium",
high: "High",
critical: "Critical"
};
export function CaseSeverity({ value }: { value: number }) {
const keys = ["", "low", "medium", "high", "critical"];
const key = keys[value];
return (
<Badge tone={value >= 4 ? "danger" : value >= 3 ? "warn" : "neutral"}>
{key ? severityLabels[key] : value}
</Badge>
);
}
const moderationLabels: Record<string, Record<string, string>> = {
status: {
open: "Open",
in_review: "In review",
action_pending: "Action pending",
action_failed: "Action failed",
appeal_review: "Appeal review",
resolved: "Resolved",
dismissed: "Dismissed"
},
targetType: {
channel: "Channel",
chat: "Group",
user: "Account"
},
source: {
account_peer: "Account / peer",
antispam_false_positive: "Anti-spam false positive",
channel_spam: "Channel spam",
encrypted_spam: "Encrypted-chat spam",
ephemeral: "Ephemeral media",
messages: "Messages",
messages_spam: "Message spam",
profile_photo: "Profile photo",
reaction: "Reaction",
sponsored: "Sponsored message",
story: "Story"
},
reason: {
child_abuse: "Child abuse",
copyright: "Copyright",
fake: "Fake",
geo_irrelevant: "Location-irrelevant",
illegal_drugs: "Illegal drugs",
other: "Other",
personal_details: "Personal details",
pornography: "Pornography",
spam: "Spam",
violence: "Violence"
}
};
export function moderationEnumLabel(group: string, value: string): string {
return moderationLabels[group]?.[value] ?? value;
}
export function moderationTargetLabel(type: string, id: number): string {
return `${moderationEnumLabel("targetType", type)} #${id}`;
}

View file

@ -1,6 +1,10 @@
import { type Navigate, type RouteState } from "../routing"; import { type Navigate, type RouteState } from "../routing";
import { AccountDetailPage } from "./AccountDetailPage"; import { AccountDetailPage } from "./AccountDetailPage";
import { AccountRatingDetailPage } from "./AccountRatingDetailPage";
import { AccountRatingsPage } from "./AccountRatingsPage";
import { AccountsPage } from "./AccountsPage"; import { AccountsPage } from "./AccountsPage";
import { CollectibleUsernameDetailPage } from "./CollectibleUsernameDetailPage";
import { CollectibleUsernamesPage } from "./CollectibleUsernamesPage";
import { ChannelDetailPage } from "./ChannelDetailPage"; import { ChannelDetailPage } from "./ChannelDetailPage";
import { ChannelsPage } from "./ChannelsPage"; import { ChannelsPage } from "./ChannelsPage";
import { BotDetailPage } from "./BotDetailPage"; import { BotDetailPage } from "./BotDetailPage";
@ -13,11 +17,73 @@ import { MessagesPage } from "./MessagesPage";
import { GiftsPage } from "./GiftsPage"; import { GiftsPage } from "./GiftsPage";
import { StickerSetsPage } from "./StickerSetsPage"; import { StickerSetsPage } from "./StickerSetsPage";
import { GiveGiftsPage } from "./GiveGiftsPage"; 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 }) { export function Routes({ route, navigate }: { route: RouteState; navigate: Navigate }) {
const accountID = route.path.match(/^\/accounts\/(\d+)$/)?.[1]; const accountID = route.path.match(/^\/accounts\/(\d+)$/)?.[1];
const channelID = route.path.match(/^\/channels\/(\d+)$/)?.[1]; const channelID = route.path.match(/^\/channels\/(\d+)$/)?.[1];
const botID = route.path.match(/^\/bots\/(\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) { if (accountID) {
return <AccountDetailPage id={Number(accountID)} navigate={navigate} />; return <AccountDetailPage id={Number(accountID)} navigate={navigate} />;
} }
@ -27,6 +93,9 @@ export function Routes({ route, navigate }: { route: RouteState; navigate: Navig
if (botID) { if (botID) {
return <BotDetailPage id={Number(botID)} navigate={navigate} />; return <BotDetailPage id={Number(botID)} navigate={navigate} />;
} }
if (moderationCaseID) {
return <ModerationCaseDetailPage id={Number(moderationCaseID)} navigate={navigate} />;
}
if (route.path === "/accounts") { if (route.path === "/accounts") {
return <AccountsPage navigate={navigate} />; return <AccountsPage navigate={navigate} />;
} }
@ -36,6 +105,9 @@ export function Routes({ route, navigate }: { route: RouteState; navigate: Navig
if (route.path === "/bots") { if (route.path === "/bots") {
return <BotsPage navigate={navigate} />; return <BotsPage navigate={navigate} />;
} }
if (route.path === "/moderation") {
return <ModerationCasesPage navigate={navigate} />;
}
if (route.path === "/emoji") { if (route.path === "/emoji") {
return <StickerSetsPage kind="emoji" />; return <StickerSetsPage kind="emoji" />;
} }

View file

@ -5,7 +5,6 @@ import { api, errorMessage } from "../api";
import { ActionButton } from "../components/ActionButton"; import { ActionButton } from "../components/ActionButton";
import { StickerDocumentPreview } from "../components/StickerDocumentPreview"; import { StickerDocumentPreview } from "../components/StickerDocumentPreview";
import { Alert } from "../components/ui"; import { Alert } from "../components/ui";
import { useI18n } from "../i18n";
import type { StickerSetRow } from "../types"; import type { StickerSetRow } from "../types";
// Cells per modal page. Each page fully replaces the previous one (rather // Cells per modal page. Each page fully replaces the previous one (rather
@ -15,7 +14,6 @@ import type { StickerSetRow } from "../types";
const PAGE_SIZE = 24; const PAGE_SIZE = 24;
export function StickerSetPreviewModal({ set, onClose }: { set: StickerSetRow; onClose: () => void }) { export function StickerSetPreviewModal({ set, onClose }: { set: StickerSetRow; onClose: () => void }) {
const { t } = useI18n();
const noun = set.Kind === "emoji" ? "emoji" : "sticker"; const noun = set.Kind === "emoji" ? "emoji" : "sticker";
const [documentIDs, setDocumentIDs] = useState<string[] | null>(null); const [documentIDs, setDocumentIDs] = useState<string[] | null>(null);
const [error, setError] = useState(""); const [error, setError] = useState("");
@ -52,19 +50,19 @@ export function StickerSetPreviewModal({ set, onClose }: { set: StickerSetRow; o
<section className="modal command-modal sticker-preview-modal" role="dialog" aria-modal="true" aria-label={set.Title || `#${set.ID}`}> <section className="modal command-modal sticker-preview-modal" role="dialog" aria-modal="true" aria-label={set.Title || `#${set.ID}`}>
<div className="modal-head"> <div className="modal-head">
<div> <div>
<div className="eyebrow">{t("stickers.previewEyebrow")}</div> <div className="eyebrow">{"Set contents"}</div>
<h2>{set.Title || `#${set.ID}`}</h2> <h2>{set.Title || `#${set.ID}`}</h2>
</div> </div>
<button className="icon-btn" type="button" onClick={onClose} aria-label={t("action.close")}><X size={15} /></button> <button className="icon-btn" type="button" onClick={onClose} aria-label={"Close"}><X size={15} /></button>
</div> </div>
<div className="command-body"> <div className="command-body">
<AddStickerForm setID={set.ID} noun={noun} onAdded={load} /> <AddStickerForm setID={set.ID} noun={noun} onAdded={load} />
{error && <Alert>{error}</Alert>} {error && <Alert>{error}</Alert>}
{!error && documentIDs === null && ( {!error && documentIDs === null && (
<div className="loading-line"><Loader2 className="spin" size={18} /> {t("common.loading")}</div> <div className="loading-line"><Loader2 className="spin" size={18} /> {"Loading"}</div>
)} )}
{documentIDs !== null && total === 0 && !error && ( {documentIDs !== null && total === 0 && !error && (
<div className="empty-panel">{t("stickers.previewEmpty")}</div> <div className="empty-panel">{"This set has no documents."}</div>
)} )}
{pageItems.length > 0 && ( {pageItems.length > 0 && (
<div className="sticker-doc-grid" key={currentPage}> <div className="sticker-doc-grid" key={currentPage}>
@ -74,7 +72,7 @@ export function StickerSetPreviewModal({ set, onClose }: { set: StickerSetRow; o
<ActionButton <ActionButton
compact compact
tone="danger" tone="danger"
label={t("stickers.removeSticker", { noun })} label={"Remove"}
icon={<Trash2 size={12} />} icon={<Trash2 size={12} />}
path="/api/actions/remove-sticker-from-set" path="/api/actions/remove-sticker-from-set"
payload={() => ({ set_id: set.ID, document_id: documentID })} payload={() => ({ set_id: set.ID, document_id: documentID })}
@ -86,14 +84,14 @@ export function StickerSetPreviewModal({ set, onClose }: { set: StickerSetRow; o
)} )}
{total > PAGE_SIZE && ( {total > PAGE_SIZE && (
<div className="gift-pager"> <div className="gift-pager">
<span className="gift-pager-range">{t("gifts.pageRange", { start: rangeStart, end: rangeEnd, total })}</span> <span className="gift-pager-range">{`Showing ${rangeStart}-${rangeEnd} of ${total}`}</span>
<div className="gift-pager-controls"> <div className="gift-pager-controls">
<button className="btn compact-btn" type="button" onClick={() => setPage((p) => Math.max(1, p - 1))} disabled={currentPage <= 1}> <button className="btn compact-btn" type="button" onClick={() => setPage((p) => Math.max(1, p - 1))} disabled={currentPage <= 1}>
<ChevronLeft size={14} /> {t("gifts.pagePrev")} <ChevronLeft size={14} /> {"Previous"}
</button> </button>
<span className="gift-pager-page">{t("gifts.pageOf", { page: currentPage, total: totalPages })}</span> <span className="gift-pager-page">{`Page ${currentPage} of ${totalPages}`}</span>
<button className="btn compact-btn" type="button" onClick={() => setPage((p) => Math.min(totalPages, p + 1))} disabled={currentPage >= totalPages}> <button className="btn compact-btn" type="button" onClick={() => setPage((p) => Math.min(totalPages, p + 1))} disabled={currentPage >= totalPages}>
{t("gifts.pageNext")} <ChevronRight size={14} /> {"Next"} <ChevronRight size={14} />
</button> </button>
</div> </div>
</div> </div>
@ -110,7 +108,6 @@ export function StickerSetPreviewModal({ set, onClose }: { set: StickerSetRow; o
// step): unlike destructive actions, materializing one sticker document is // step): unlike destructive actions, materializing one sticker document is
// low-risk and reversible via the per-cell Remove button. // low-risk and reversible via the per-cell Remove button.
function AddStickerForm({ setID, noun, onAdded }: { setID: string; noun: string; onAdded: () => void }) { function AddStickerForm({ setID, noun, onAdded }: { setID: string; noun: string; onAdded: () => void }) {
const { t } = useI18n();
const [file, setFile] = useState<File | null>(null); const [file, setFile] = useState<File | null>(null);
const [emoji, setEmoji] = useState(""); const [emoji, setEmoji] = useState("");
const [reason, setReason] = useState(""); const [reason, setReason] = useState("");
@ -119,15 +116,15 @@ function AddStickerForm({ setID, noun, onAdded }: { setID: string; noun: string;
async function submit() { async function submit() {
if (!file) { if (!file) {
setError(t("stickers.fileRequired", { noun })); setError(`Choose a ${noun} file first`);
return; return;
} }
if (!emoji.trim()) { if (!emoji.trim()) {
setError(t("stickers.emojiRequired")); setError("An emoji is required.");
return; return;
} }
if (!reason.trim()) { if (!reason.trim()) {
setError(t("action.reasonRequired")); setError("Please enter an operation reason");
return; return;
} }
setBusy(true); setBusy(true);
@ -152,12 +149,12 @@ function AddStickerForm({ setID, noun, onAdded }: { setID: string; noun: string;
<div className="sticker-add-form"> <div className="sticker-add-form">
<label className={`gift-file-picker compact ${file ? "has-file" : ""}`}> <label className={`gift-file-picker compact ${file ? "has-file" : ""}`}>
<input type="file" accept=".tgs,.json,.webp,application/json,application/x-tgsticker,image/webp" onChange={(event) => setFile(event.target.files?.[0] ?? null)} /> <input type="file" accept=".tgs,.json,.webp,application/json,application/x-tgsticker,image/webp" onChange={(event) => setFile(event.target.files?.[0] ?? null)} />
<span className="gift-file-copy"><strong>{file ? file.name : t("stickers.filePrompt")}</strong></span> <span className="gift-file-copy"><strong>{file ? file.name : "Choose a TGS, Lottie JSON, or WebP file"}</strong></span>
</label> </label>
<input className="small-input" value={emoji} onChange={(event) => setEmoji(event.target.value)} placeholder={t("stickers.emojiPlaceholder")} /> <input className="small-input" value={emoji} onChange={(event) => setEmoji(event.target.value)} placeholder={"e.g. 😀"} />
<input className="small-input" value={reason} onChange={(event) => setReason(event.target.value)} placeholder={t("action.reasonPlaceholder")} /> <input className="small-input" value={reason} onChange={(event) => setReason(event.target.value)} placeholder={"Describe why this operation is being performed"} />
<button className="btn primary compact-btn" type="button" onClick={submit} disabled={busy}> <button className="btn primary compact-btn" type="button" onClick={submit} disabled={busy}>
{busy ? <Loader2 className="spin" size={14} /> : <Plus size={14} />} {t("stickers.addSticker", { noun })} {busy ? <Loader2 className="spin" size={14} /> : <Plus size={14} />} {`Add ${noun}`}
</button> </button>
{error && <span className="sticker-add-form-error">{error}</span>} {error && <span className="sticker-add-form-error">{error}</span>}
</div> </div>

View file

@ -4,7 +4,6 @@ import { api, errorMessage } from "../api";
import { ActionButton } from "../components/ActionButton"; import { ActionButton } from "../components/ActionButton";
import { StickerDocumentPreview } from "../components/StickerDocumentPreview"; import { StickerDocumentPreview } from "../components/StickerDocumentPreview";
import { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel } from "../components/ui"; import { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel } from "../components/ui";
import { useI18n } from "../i18n";
import type { StickerSetRow } from "../types"; import type { StickerSetRow } from "../types";
import { CreateStickerSetModal } from "./CreateStickerSetModal"; import { CreateStickerSetModal } from "./CreateStickerSetModal";
import { StickerSetPreviewModal } from "./StickerSetPreviewModal"; import { StickerSetPreviewModal } from "./StickerSetPreviewModal";
@ -16,7 +15,6 @@ type StickerPageSize = 10 | 20 | 50 | 100 | "all";
// filtered out server-side and never reach this page; they aren't meant to be // filtered out server-side and never reach this page; they aren't meant to be
// hand-edited. // hand-edited.
export function StickerSetsPage({ kind }: { kind: "stickers" | "emoji" }) { export function StickerSetsPage({ kind }: { kind: "stickers" | "emoji" }) {
const { t } = useI18n();
const [sets, setSets] = useState<StickerSetRow[]>([]); const [sets, setSets] = useState<StickerSetRow[]>([]);
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
@ -28,8 +26,10 @@ export function StickerSetsPage({ kind }: { kind: "stickers" | "emoji" }) {
const [previewSet, setPreviewSet] = useState<StickerSetRow | null>(null); const [previewSet, setPreviewSet] = useState<StickerSetRow | null>(null);
const [createOpen, setCreateOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false);
const pageTitleKey = kind === "emoji" ? "stickers.emojiPageTitle" : "stickers.pageTitle"; const pageTitle = kind === "emoji" ? "Emoji" : "Stickers";
const eyebrowKey = kind === "emoji" ? "stickers.emojiEyebrow" : "stickers.eyebrow"; const eyebrow = kind === "emoji"
? "Custom-emoji packs — system packs aren't shown here, they're not hand-edited"
: "Sticker packs — system packs (dice, animated emoji, gifts) aren't shown here, they're not hand-edited";
const noun = kind === "emoji" ? "emoji" : "sticker"; const noun = kind === "emoji" ? "emoji" : "sticker";
async function load() { async function load() {
@ -76,33 +76,33 @@ export function StickerSetsPage({ kind }: { kind: "stickers" | "emoji" }) {
return ( return (
<PageFrame <PageFrame
title={t(pageTitleKey)} title={pageTitle}
eyebrow={t(eyebrowKey)} eyebrow={eyebrow}
actions={ actions={
<> <>
<button className="btn" type="button" onClick={() => load()} disabled={busy}> <button className="btn" type="button" onClick={() => load()} disabled={busy}>
<RefreshCw size={15} /> {t("common.refresh")} <RefreshCw size={15} /> {"Refresh"}
</button> </button>
<button className="btn primary" type="button" onClick={() => setCreateOpen(true)}> <button className="btn primary" type="button" onClick={() => setCreateOpen(true)}>
<Plus size={15} /> {t("stickers.create", { noun })} <Plus size={15} /> {`Create ${noun} pack`}
</button> </button>
</> </>
} }
> >
{error && <Alert>{error}</Alert>} {error && <Alert>{error}</Alert>}
<div className="metric-row"> <div className="metric-row">
<Metric label={t("stickers.total")} value={String(counts.total)} /> <Metric label={"Total sets"} value={String(counts.total)} />
<Metric label={t("stickers.official")} value={String(counts.official)} tone="good" /> <Metric label={"Official"} value={String(counts.official)} tone="good" />
<Metric label={t("stickers.archived")} value={String(counts.archived)} tone={counts.archived > 0 ? "warn" : "neutral"} /> <Metric label={"Archived"} value={String(counts.archived)} tone={counts.archived > 0 ? "warn" : "neutral"} />
</div> </div>
<QueryPanel> <QueryPanel>
<div className="toolbar"> <div className="toolbar">
<label className="searchbox"> <label className="searchbox">
<Search size={15} /> <Search size={15} />
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder={t("stickers.searchPlaceholder")} /> <input value={query} onChange={(event) => setQuery(event.target.value)} placeholder={"Search set ID, short name or title"} />
</label> </label>
<label className="gift-page-size"> <label className="gift-page-size">
<span>{t("gifts.perPage")}</span> <span>{"Per page"}</span>
<select <select
value={String(pageSize)} value={String(pageSize)}
onChange={(event) => setPageSize(event.target.value === "all" ? "all" : (Number(event.target.value) as StickerPageSize))} onChange={(event) => setPageSize(event.target.value === "all" ? "all" : (Number(event.target.value) as StickerPageSize))}
@ -111,25 +111,25 @@ export function StickerSetsPage({ kind }: { kind: "stickers" | "emoji" }) {
<option value="20">20</option> <option value="20">20</option>
<option value="50">50</option> <option value="50">50</option>
<option value="100">100</option> <option value="100">100</option>
<option value="all">{t("gifts.perPageAll")}</option> <option value="all">{"All"}</option>
</select> </select>
</label> </label>
<span className="gift-list-summary">{t("stickers.listSummary", { shown: visible.length, total: sets.length })}</span> <span className="gift-list-summary">{`Showing ${visible.length} of ${sets.length}`}</span>
</div> </div>
</QueryPanel> </QueryPanel>
<div className="table-wrap gift-table-wrap"> <div className="table-wrap gift-table-wrap">
<table className="data-table"> <table className="data-table">
<thead> <thead>
<tr> <tr>
<th>{t("stickers.logo")}</th> <th>{"Logo"}</th>
<th>{t("stickers.id")}</th> <th>{"ID"}</th>
<th>{t("stickers.shortName")}</th> <th>{"Short name"}</th>
<th>{t("stickers.title")}</th> <th>{"Title"}</th>
<th>{t("stickers.count")}</th> <th>{"Documents"}</th>
<th>{t("stickers.official")}</th> <th>{"Official"}</th>
<th>{t("common.status")}</th> <th>{"Status"}</th>
<th>{t("stickers.sortOrder")}</th> <th>{"Sort order"}</th>
<th>{t("common.actions")}</th> <th>{"Actions"}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -143,7 +143,7 @@ export function StickerSetsPage({ kind }: { kind: "stickers" | "emoji" }) {
)} )}
</td> </td>
<td className="mono">{set.ID}</td> <td className="mono">{set.ID}</td>
<td className="mono">{set.ShortName || <span className="muted-cell">{t("common.none")}</span>}</td> <td className="mono">{set.ShortName || <span className="muted-cell">{"None"}</span>}</td>
<td> <td>
<div className="sort-order-editor"> <div className="sort-order-editor">
<input <input
@ -154,7 +154,7 @@ export function StickerSetsPage({ kind }: { kind: "stickers" | "emoji" }) {
<ActionButton <ActionButton
compact compact
tone="neutral" tone="neutral"
label={t("stickers.saveTitle")} label={"Save"}
path="/api/actions/rename-sticker-set" path="/api/actions/rename-sticker-set"
payload={() => ({ set_id: set.ID, title: (titleDrafts[set.ID] ?? set.Title).trim() })} payload={() => ({ set_id: set.ID, title: (titleDrafts[set.ID] ?? set.Title).trim() })}
onDone={() => void load()} onDone={() => void load()}
@ -162,8 +162,8 @@ export function StickerSetsPage({ kind }: { kind: "stickers" | "emoji" }) {
</div> </div>
</td> </td>
<td>{set.Count}</td> <td>{set.Count}</td>
<td>{set.Official ? <Badge tone="good">{t("common.yes")}</Badge> : <Badge>{t("common.no")}</Badge>}</td> <td>{set.Official ? <Badge tone="good">{"Yes"}</Badge> : <Badge>{"No"}</Badge>}</td>
<td>{set.Archived ? <Badge tone="danger">{t("stickers.archived")}</Badge> : <Badge tone="good">{t("common.enabled")}</Badge>}</td> <td>{set.Archived ? <Badge tone="danger">{"Archived"}</Badge> : <Badge tone="good">{"Enabled"}</Badge>}</td>
<td> <td>
<div className="sort-order-editor"> <div className="sort-order-editor">
<input <input
@ -175,7 +175,7 @@ export function StickerSetsPage({ kind }: { kind: "stickers" | "emoji" }) {
<ActionButton <ActionButton
compact compact
tone="neutral" tone="neutral"
label={t("stickers.saveOrder")} label={"Save"}
path="/api/actions/set-sticker-set-sort-order" path="/api/actions/set-sticker-set-sort-order"
payload={() => ({ set_id: set.ID, sort_order: Number(orderDrafts[set.ID] ?? set.SortOrder) })} payload={() => ({ set_id: set.ID, sort_order: Number(orderDrafts[set.ID] ?? set.SortOrder) })}
onDone={() => void load()} onDone={() => void load()}
@ -185,12 +185,12 @@ export function StickerSetsPage({ kind }: { kind: "stickers" | "emoji" }) {
<td> <td>
<div className="gift-table-actions"> <div className="gift-table-actions">
<button className="btn compact-btn" type="button" onClick={() => setPreviewSet(set)}> <button className="btn compact-btn" type="button" onClick={() => setPreviewSet(set)}>
<Eye size={13} /> {t("stickers.view")} <Eye size={13} /> {"View"}
</button> </button>
<ActionButton <ActionButton
compact compact
tone="neutral" tone="neutral"
label={set.Archived ? t("stickers.unarchive") : t("stickers.archive")} label={set.Archived ? "Unarchive" : "Archive"}
path="/api/actions/set-sticker-set-archived" path="/api/actions/set-sticker-set-archived"
payload={() => ({ set_id: set.ID, archived: !set.Archived })} payload={() => ({ set_id: set.ID, archived: !set.Archived })}
onDone={() => void load()} onDone={() => void load()}
@ -198,7 +198,7 @@ export function StickerSetsPage({ kind }: { kind: "stickers" | "emoji" }) {
<ActionButton <ActionButton
compact compact
tone="danger" tone="danger"
label={t("stickers.delete")} label={"Delete"}
path="/api/actions/delete-sticker-set" path="/api/actions/delete-sticker-set"
payload={() => ({ set_id: set.ID })} payload={() => ({ set_id: set.ID })}
onDone={() => void load()} onDone={() => void load()}
@ -213,14 +213,14 @@ export function StickerSetsPage({ kind }: { kind: "stickers" | "emoji" }) {
</div> </div>
{pageSize !== "all" && visible.length > 0 && ( {pageSize !== "all" && visible.length > 0 && (
<div className="gift-pager"> <div className="gift-pager">
<span className="gift-pager-range">{t("gifts.pageRange", { start: rangeStart, end: rangeEnd, total: visible.length })}</span> <span className="gift-pager-range">{`Showing ${rangeStart}-${rangeEnd} of ${visible.length}`}</span>
<div className="gift-pager-controls"> <div className="gift-pager-controls">
<button className="btn compact-btn" type="button" onClick={() => setPage((p) => Math.max(1, p - 1))} disabled={currentPage <= 1}> <button className="btn compact-btn" type="button" onClick={() => setPage((p) => Math.max(1, p - 1))} disabled={currentPage <= 1}>
<ChevronLeft size={14} /> {t("gifts.pagePrev")} <ChevronLeft size={14} /> {"Previous"}
</button> </button>
<span className="gift-pager-page">{t("gifts.pageOf", { page: currentPage, total: totalPages })}</span> <span className="gift-pager-page">{`Page ${currentPage} of ${totalPages}`}</span>
<button className="btn compact-btn" type="button" onClick={() => setPage((p) => Math.min(totalPages, p + 1))} disabled={currentPage >= totalPages}> <button className="btn compact-btn" type="button" onClick={() => setPage((p) => Math.min(totalPages, p + 1))} disabled={currentPage >= totalPages}>
{t("gifts.pageNext")} <ChevronRight size={14} /> {"Next"} <ChevronRight size={14} />
</button> </button>
</div> </div>
</div> </div>

View file

@ -0,0 +1,426 @@
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 { 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,
verificationStatusLabels,
verificationTargetTypeLabels
} from "./VerificationPage";
const verificationEventKindLabels: Record<VerificationEventKind, string> = {
created: "Created",
updated: "Updated",
submitted: "Submitted",
claimed: "Claimed",
approved: "Approved",
rejected: "Rejected",
cancelled: "Cancelled",
revoked: "Badge revoked",
notified: "Applicant notified"
};
export function VerificationDetailPage({ id, navigate }: { id: string; navigate: Navigate }) {
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 "Another admin has already changed this application. The data has been reloaded — check the status before deciding again.";
}
return undefined;
}
if (error && !detail) {
return <Alert>{error}</Alert>;
}
if (!detail) {
return <LoadingSurface label={"Loading the application…"} />;
}
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={`Application #${app.ID}`}
eyebrow={"Verification / Review"}
actions={
<>
<button className="btn icon-text" type="button" onClick={() => navigate("/verification")}>
<ArrowLeft size={15} /> {"Back to list"}
</button>
<button className="btn icon-text" type="button" onClick={refresh} disabled={busy}>
<RefreshCw size={15} className={busy ? "spin" : ""} /> {"Refresh"}
</button>
</>
}
>
{error && <Alert>{error}</Alert>}
{conflict && <Alert>{"Another admin has already changed this application. The data has been reloaded — check the status before deciding again."}</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} · {verificationTargetTypeLabels[app.TargetType]}:{app.TargetID} · v{app.Version}
</div>
</div>
<div className="entity-badges">
<VerificationStatusBadge status={app.Status} />
{verified && <Badge tone="good"><BadgeCheck size={12} /> {"Badge already on"}</Badge>}
<Badge tone={controls ? "good" : "danger"}>
{controls ? "Control confirmed" : "No control over the target"}
</Badge>
</div>
</section>
<section className="section-block">
<SectionHead
title={"Target"}
text={"The peer the badge would be attached to, as it exists right now."}
action={
<button className="btn icon-text" type="button" onClick={() => navigate(targetHref(app))}>
<ExternalLink size={15} /> {"Open target"}
</button>
}
/>
<div className="summary-grid">
<Summary label={"Type"} value={verificationTargetTypeLabels[app.TargetType]} />
<Summary label={"Username"} value={displayUsername(app.TargetUsername) || "-"} />
<Summary label={"Title"} value={app.TargetTitle || "-"} />
<Summary label={"Peer ID"} value={app.TargetID} mono />
</div>
</section>
<section className="section-block">
<SectionHead
title={"Applicant"}
text={"Who filed the application and whether they still hold rights on the target."}
action={
<button className="btn icon-text" type="button" onClick={() => navigate(`/accounts/${app.ApplicantUserID}`)}>
<User size={15} /> {"Open account"}
</button>
}
/>
<div className="summary-grid">
<Summary label={"Username"} value={displayUsername(app.ApplicantUsername) || "-"} />
<Summary label={"Name"} value={app.ApplicantName || "-"} />
<Summary label={"User ID"} value={app.ApplicantUserID} mono />
<Summary label={"Submitted"} value={formatDate(app.SubmittedAt) || "-"} />
</div>
{controls
? <p className="bot-create-note">{"The applicant controls the target right now — checked against the live records, not against the submission snapshot."}</p>
: <Alert>{"The applicant no longer controls the target. Approving would hand the badge to someone who does not hold the peer — normally a reason to reject."}</Alert>}
</section>
<section className="section-block">
<SectionHead title={"Application"} text={"Everything the applicant submitted, rendered as plain text."} />
<div className="stacked-sections">
<div className="summary-grid">
<Summary label={"Category"} value={app.Category || "-"} />
<Summary label={"Correlation ID"} value={app.CorrelationID || "-"} mono />
<Summary label={"Created"} value={formatDate(app.CreatedAt) || "-"} />
<Summary label={"Updated"} value={formatDate(app.UpdatedAt) || "-"} />
</div>
<FieldBlock label={"Description"}>
{app.Description
? <p className="about-text">{app.Description}</p>
: <p className="bot-create-note">{"Not provided"}</p>}
</FieldBlock>
<FieldBlock label={"Official website"}>
{app.OfficialWebsite
? <div className="about-text"><SafeLink value={app.OfficialWebsite} /></div>
: <p className="bot-create-note">{"Not provided"}</p>}
</FieldBlock>
<FieldBlock label={"Social links"}>
<LinkList values={app.SocialLinks} />
</FieldBlock>
<FieldBlock label={"Press coverage"}>
<LinkList values={app.PressLinks} />
</FieldBlock>
<FieldBlock label={"Applicant comment"}>
{app.AdditionalNote
? <p className="about-text">{app.AdditionalNote}</p>
: <p className="bot-create-note">{"Not provided"}</p>}
</FieldBlock>
<p className="bot-create-note">{"Only http:// and https:// links are clickable and open in a new tab; anything else is shown as text."}</p>
</div>
</section>
<section className="section-block">
<SectionHead title={"Decision"} text={"What was decided, by whom, and with which wording."} />
<div className="stacked-sections">
<div className="summary-grid">
<Summary label={"Reviewer"} value={app.ReviewerAdminID || "-"} />
<Summary label={"Decided"} value={formatDate(app.ReviewedAt) || "-"} />
<Summary label={"Status"} value={verificationStatusLabels[app.Status]} />
<Summary label={"Version (optimistic lock)"} value={app.Version} mono />
</div>
<FieldBlock label={"Decision reason"}>
{app.DecisionReason
? <p className="about-text">{app.DecisionReason}</p>
: <p className="bot-create-note">{"No decision yet"}</p>}
</FieldBlock>
{/* The internal note is the reviewer handover text and is labelled
as admin-only wherever it appears. */}
<FieldBlock label={`${"Internal note"} · ${"admins only"}`}>
{app.InternalNote
? <p className="about-text">{app.InternalNote}</p>
: <p className="bot-create-note">{"Not provided"}</p>}
</FieldBlock>
</div>
</section>
<section className="section-block">
<SectionHead title={"History"} text={"Immutable trail of every status transition, with actor and reason."} />
<div className="table-wrap">
<table className="data-table">
<thead>
<tr>
<th>{"Event"}</th>
<th>{"From → to"}</th>
<th>{"Actor"}</th>
<th>{"Reason"}</th>
<th>{"Internal note"}</th>
<th>{"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">{"Review actions"}</div>
{!canClaim && !canDecide && !canRevoke && (
<p className="bot-create-note">{"This status has no available actions."}</p>
)}
{canClaim && (
<>
<div className="action-stack">
<ActionButton
label={"Take into review"}
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">{"Assigns the application to you and moves it to in review, so two reviewers never work on the same one."}</p>
</>
)}
{/* One optional note field feeds every decision on this page,
including a revoke. */}
{(canDecide || canRevoke) && (
<>
<label className="duration-field">
<span>{"Internal note"}</span>
<textarea
value={note}
onChange={(event) => setNote(event.target.value)}
rows={3}
placeholder={"Handover note for other reviewers"}
/>
</label>
<p className="bot-create-note">{"Optional. Stored with the decision and visible to admins only — never sent to the applicant."}</p>
</>
)}
{canDecide && (
<>
{!controls && <Alert>{"The applicant no longer controls the target. Approving would hand the badge to someone who does not hold the peer — normally a reason to reject."}</Alert>}
{verified && <p className="bot-create-note">{"The target already carries the badge; approving only records the decision."}</p>}
<div className="action-stack">
<ActionButton
label={"Approve"}
icon={<CheckCircle2 size={15} />}
tone="neutral"
path={`/api/verification/applications/${app.ID}/approve`}
payload={decisionPayload}
onDone={afterDecision}
onError={handleActionError}
/>
<ActionButton
label={"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">{"Grants the official badge to the target and closes the application."}</p>
<p className="bot-create-note">{"The reason is mandatory: it is the wording the applicant is told, so write what exactly was missing."}</p>
</>
)}
{canRevoke && (
<>
<div className="dock-title"><ShieldOff size={14} /> {"Danger zone"}</div>
<div className="danger-zone">
<ActionButton
label={"Revoke verification"}
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">{"Clears the badge from the target. The approved application stays in history."}</p>
{!verified && <p className="bot-create-note">{"The target carries no badge right now — there is nothing to revoke."}</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 links = (values ?? []).filter((item) => item.trim() !== "");
if (links.length === 0) {
return <p className="bot-create-note">{"Not provided"}</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 tone = kind === "approved"
? "good"
: kind === "rejected" || kind === "revoked" || kind === "cancelled"
? "danger"
: kind === "submitted" || kind === "claimed"
? "warn"
: "neutral";
return <Badge tone={tone}>{verificationEventKindLabels[kind]}</Badge>;
}

View file

@ -0,0 +1,245 @@
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 { 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 const verificationStatusLabels: Record<VerificationStatus, string> = {
draft: "Draft",
submitted: "Submitted",
in_review: "In review",
approved: "Approved",
rejected: "Rejected",
cancelled: "Cancelled"
};
export const verificationTargetTypeLabels: Record<VerificationTargetType, string> = {
bot: "Bot",
channel: "Channel",
supergroup: "Supergroup",
user: "User"
};
export function VerificationPage({ navigate }: { navigate: Navigate }) {
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={"Verification queue"}
eyebrow={"Verification / Queue"}
actions={
<button className="btn icon-text" type="button" onClick={refresh} disabled={busy}>
<RefreshCw size={15} className={busy ? "spin" : ""} /> {"Refresh"}
</button>
}
>
{error && <Alert>{error}</Alert>}
<div className="metric-row">
{statuses.map((item) => (
<Metric
key={item}
label={verificationStatusLabels[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={"Application id, peer id, username or title"} />
</label>
<label className="field-inline">
<span>{"Status"}</span>
<select value={status} onChange={(event) => setStatus(event.target.value as StatusFilter)}>
<option value="all">{"All statuses"}</option>
{statuses.map((item) => (
<option key={item} value={item}>{verificationStatusLabels[item]}</option>
))}
</select>
</label>
<label className="field-inline">
<span>{"Target type"}</span>
<select value={targetType} onChange={(event) => setTargetType(event.target.value as TargetFilter)}>
<option value="all">{"All types"}</option>
{targetTypes.map((item) => (
<option key={item} value={item}>{verificationTargetTypeLabels[item]}</option>
))}
</select>
</label>
<label className="field-inline">
<span>{"Reviewer"}</span>
<input value={reviewer} onChange={(event) => setReviewer(event.target.value)} placeholder={"Any reviewer"} />
</label>
<label className="field-inline">
<span>{"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} />} {"Search"}
</button>
</form>
</QueryPanel>
<div className="table-wrap">
<table className="data-table">
<thead>
<tr>
<th>{"ID"}</th>
<th>{"Target"}</th>
<th>{"Applicant"}</th>
<th>{"Category"}</th>
<th>{"Status"}</th>
<th>{"Submitted"}</th>
<th>{"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">
{verificationTargetTypeLabels[row.TargetType]} · {row.TargetID}
</div>
{row.TargetVerified && (
<Badge tone="good"><BadgeCheck size={12} /> {"Badge already on"}</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} /> {"Details"} <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} />} {"Load more"}
</button>
</div>
)}
</PageFrame>
);
}
export function VerificationStatusBadge({ status }: { status: VerificationStatus }) {
return <Badge tone={statusTone(status)}>{verificationStatusLabels[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,72 @@
import { ShieldOff } from "lucide-react";
import { createContext, useContext, useMemo, type ReactNode } from "react";
import { Alert, PageFrame } from "./components/ui";
// 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 }) {
return (
<PageFrame title={"Not enough rights"} eyebrow={"Console / Access"}>
<Alert>{`This session was not granted the ${permission} permission, so the section stays closed.`}</Alert>
<section className="section-block">
<div className="entity-head">
<div>
<div className="entity-title"><ShieldOff size={16} /> {"Section unavailable"}</div>
<div className="entity-subtitle">{"Ask an operator to add the permission to TELESRV_ADMIN_UI_PERMISSIONS and sign in again."}</div>
</div>
</div>
</section>
</PageFrame>
);
}

View file

@ -1,5 +1,3 @@
import type { TFunction } from "./i18n";
export type Navigate = (href: string) => void; export type Navigate = (href: string) => void;
export type RouteState = { export type RouteState = {
@ -16,28 +14,40 @@ export function currentRoute(): RouteState {
}; };
} }
export function routeTitle(pathname: string, t: TFunction): string { export function routeTitle(pathname: string): string {
if (pathname.startsWith("/accounts")) return t("route.accounts"); // Third-party verification is tested before the official section and before
if (pathname.startsWith("/channels")) return t("route.channels"); // "/bots": three different prefixes that all read as "verification of a bot".
if (pathname.startsWith("/bots")) return t("route.bots"); if (pathname.startsWith("/bot-verification")) return "Third-party verification";
if (pathname.startsWith("/emoji")) return t("route.emoji"); if (pathname.startsWith("/verification")) return "Official Verification";
if (pathname.startsWith("/messages")) return t("route.messages"); if (pathname.startsWith("/collectible-usernames")) return "Collectible Usernames";
if (pathname.startsWith("/give-gifts")) return t("route.giveGifts"); if (pathname.startsWith("/account-ratings")) return "Account Rating";
if (pathname.startsWith("/gifts")) return t("route.gifts"); if (pathname.startsWith("/accounts")) return "Accounts";
if (pathname.startsWith("/stickers")) return t("route.stickers"); if (pathname.startsWith("/channels")) return "Supergroups and Channels";
if (pathname.startsWith("/emoji")) return t("route.emoji"); if (pathname.startsWith("/bots")) return "Bots";
return t("route.dashboard"); if (pathname.startsWith("/moderation")) return "Reports and Moderation";
if (pathname.startsWith("/emoji")) return "Emoji";
if (pathname.startsWith("/messages")) return "Message Audit";
if (pathname.startsWith("/give-gifts")) return "Give Gifts";
if (pathname.startsWith("/gifts")) return "Star Gifts";
if (pathname.startsWith("/stickers")) return "Stickers";
if (pathname.startsWith("/emoji")) return "Emoji";
return "Operations Console";
} }
export function routeSubtitle(pathname: string, t: TFunction): string { export function routeSubtitle(pathname: string): string {
if (pathname.startsWith("/accounts")) return t("route.accountsSubtitle"); if (pathname.startsWith("/bot-verification")) return "Console / Third-party verification";
if (pathname.startsWith("/channels")) return t("route.channelsSubtitle"); if (pathname.startsWith("/verification")) return "Console / Verification";
if (pathname.startsWith("/bots")) return t("route.botsSubtitle"); if (pathname.startsWith("/collectible-usernames")) return "Console / Collectible usernames";
if (pathname.startsWith("/emoji")) return t("route.emojiSubtitle"); if (pathname.startsWith("/account-ratings")) return "Console / Account rating";
if (pathname.startsWith("/messages")) return t("route.messagesSubtitle"); if (pathname.startsWith("/accounts")) return "Console / Accounts";
if (pathname.startsWith("/give-gifts")) return t("route.giveGiftsSubtitle"); if (pathname.startsWith("/channels")) return "Console / Channels";
if (pathname.startsWith("/gifts")) return t("route.giftsSubtitle"); if (pathname.startsWith("/bots")) return "Console / Bots";
if (pathname.startsWith("/stickers")) return t("route.stickersSubtitle"); if (pathname.startsWith("/moderation")) return "Console / Moderation";
if (pathname.startsWith("/emoji")) return t("route.emojiSubtitle"); if (pathname.startsWith("/emoji")) return "Console / Emoji";
return t("route.dashboardSubtitle"); if (pathname.startsWith("/messages")) return "Console / Messages";
if (pathname.startsWith("/give-gifts")) return "Console / Give Gifts";
if (pathname.startsWith("/gifts")) return "Console / Star Gifts";
if (pathname.startsWith("/stickers")) return "Console / Stickers";
if (pathname.startsWith("/emoji")) return "Console / Emoji";
return "Console / Overview";
} }

View file

@ -185,6 +185,7 @@ body {
button, button,
input, input,
select,
textarea { textarea {
font: inherit; font: inherit;
} }

View file

@ -325,6 +325,7 @@
} }
input, input,
select,
textarea { textarea {
color: var(--text); color: var(--text);
background: var(--input-bg); background: var(--input-bg);
@ -339,12 +340,33 @@ textarea::placeholder {
color: var(--muted-2); color: var(--muted-2);
} }
input { input,
select {
width: 190px; width: 190px;
height: 34px; height: 34px;
padding: 0 10px; padding: 0 10px;
} }
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 { textarea {
width: 100%; width: 100%;
padding: 9px 10px; padding: 9px 10px;
@ -352,6 +374,7 @@ textarea {
} }
input:focus, input:focus,
select:focus,
textarea:focus { textarea:focus {
border-color: var(--brand); border-color: var(--brand);
box-shadow: 0 0 0 3px var(--focus); box-shadow: 0 0 0 3px var(--focus);
@ -627,3 +650,43 @@ textarea:focus {
align-items: stretch; 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; font-weight: 800;
} }
.duration-field input { .duration-field input,
.duration-field select {
width: 100%; width: 100%;
} }
@ -126,6 +127,16 @@
border-top: 1px solid var(--line); 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 { .authorization-block {
display: grid; display: grid;
gap: 10px; gap: 10px;
@ -693,7 +704,8 @@
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
} }
.attr-block .duration-field input { .attr-block .duration-field input,
.duration-field select {
width: 100%; width: 100%;
} }
@ -793,3 +805,113 @@
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; 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,6 +1,5 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from "react"; import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from "react";
import { Moon, Sun } from "lucide-react"; import { Moon, Sun } from "lucide-react";
import { useI18n } from "./i18n";
export type Theme = "light" | "dark"; export type Theme = "light" | "dark";
@ -70,9 +69,8 @@ export function useTheme(): ThemeContextValue {
export function ThemeSwitch() { export function ThemeSwitch() {
const { theme, toggleTheme } = useTheme(); const { theme, toggleTheme } = useTheme();
const { t } = useI18n();
const nextIsDark = theme === "light"; const nextIsDark = theme === "light";
const label = t(nextIsDark ? "theme.switchToDark" : "theme.switchToLight"); const label = nextIsDark ? "Switch to dark theme" : "Switch to light theme";
return ( return (
<button <button
className="theme-toggle" className="theme-toggle"

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 = { export type AccountRow = {
ID: number; ID: number;
Phone: string; Phone: string;
Username: string; Username: string;
FirstName: string; FirstName: string;
LastName: 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; CreatedAt: string;
UpdatedAt: string; UpdatedAt: string;
Frozen: boolean; Frozen: boolean;
@ -263,6 +273,85 @@ export type OfficialStarGiftRow = {
export type OfficialStarGiftListResponse = { gifts: OfficialStarGiftRow[] }; export type OfficialStarGiftListResponse = { gifts: OfficialStarGiftRow[] };
export type ModerationPeer = {
Type: "user" | "channel";
ID: number;
};
export type ModerationCaseRow = {
ID: number;
Target: ModerationPeer;
Status: string;
Severity: number;
AssignedTo: string;
Version: number;
ReportCount: number;
DistinctReporterCount: number;
FirstReportAt: string;
LastReportAt: string;
CreatedAt: string;
UpdatedAt: string;
};
export type ModerationDecision = {
ID: number;
CaseID: number;
AppealID: number;
Kind: string;
Actor: string;
Reason: string;
CommandID: string;
CreatedAt: string;
};
export type ModerationAction = {
ID: number;
CaseID: number;
DecisionID: number;
Kind: string;
Payload: Record<string, unknown>;
Status: string;
Attempts: number;
LastError: string;
CommandID: string;
CreatedAt: string;
UpdatedAt: string;
};
export type ModerationAppeal = {
ID: number;
CaseID: number;
AppellantUserID: number;
Text: string;
Status: string;
PreviousCaseStatus: string;
Reviewer: string;
ReviewReason: string;
CreatedAt: string;
ReviewedAt: string;
};
export type ModerationCaseDetail = {
Case: ModerationCaseRow;
ReportIDs: number[];
Decisions: ModerationDecision[];
Actions: ModerationAction[];
Appeals: ModerationAppeal[];
};
export type ModerationReport = {
ID: number;
ReporterUserID: number;
Source: string;
Target: ModerationPeer;
Reason: string;
Option: string;
Comment: string;
Items: Array<Record<string, unknown>>;
MediaHolds: Array<Record<string, unknown>>;
CreatedAt: string;
};
export type StarGiftCollectibleAttributeRow = { export type StarGiftCollectibleAttributeRow = {
id: string; id: string;
kind: "model" | "pattern" | "backdrop"; kind: "model" | "pattern" | "backdrop";
@ -294,6 +383,347 @@ export type StarGiftCollectiblePreview = {
backdrops?: StarGiftCollectibleAttributeRow[]; 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 = { export type MessageDetail = {
Message: MessageRow; Message: MessageRow;
MessageJSON: string; MessageJSON: string;

226
cmd/telesrv-load/main.go Normal file
View file

@ -0,0 +1,226 @@
// Command telesrv-load provisions and drives real encrypted MTProto sessions.
// It is intentionally separate from the server process so a load generator can
// run on the M2 host without sharing server memory, database connections or
// internal handler shortcuts.
package main
import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"os"
"os/signal"
"path/filepath"
"strings"
"syscall"
"time"
"telesrv/internal/loadharness"
)
func main() {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
if err := run(ctx, os.Args[1:]); err != nil {
fmt.Fprintln(os.Stderr, "telesrv-load:", err)
os.Exit(1)
}
}
func run(ctx context.Context, args []string) error {
if len(args) == 0 {
return usageError()
}
switch args[0] {
case "keygen":
return runKeygen(args[1:])
case "provision":
return runProvision(ctx, args[1:])
case "run":
return runLoad(ctx, args[1:])
case "summarize":
return runSummarize(args[1:])
case "help", "-h", "--help":
fmt.Fprintln(os.Stdout, usageText)
return nil
default:
return usageError()
}
}
func runKeygen(args []string) error {
flags := flag.NewFlagSet("keygen", flag.ContinueOnError)
path := flags.String("out", filepath.FromSlash("data/loadtest/session.key"), "owner-only session encryption key file")
if err := flags.Parse(args); err != nil {
return err
}
if flags.NArg() != 0 {
return errors.New("keygen accepts no positional arguments")
}
if err := loadharness.GenerateSessionKey(*path); err != nil {
return err
}
fmt.Fprintf(os.Stdout, "session encryption key written to %s\n", *path)
return nil
}
func runProvision(ctx context.Context, args []string) error {
flags := flag.NewFlagSet("provision", flag.ContinueOnError)
manifest := flags.String("manifest", filepath.FromSlash("data/loadtest/manifest.json"), "output manifest")
sessionKey := flags.String("session-key", filepath.FromSlash("data/loadtest/session.key"), "session encryption key")
server := flags.String("server", "127.0.0.1:2398", "MTProto server address")
dc := flags.Int("dc", 2, "wire DC label")
rsaKey := flags.String("rsa-key", filepath.FromSlash("data/server_rsa.pem"), "server RSA private/public PEM")
apiID := flags.Int("api-id", 1, "test application ID")
apiHash := flags.String("api-hash", "hash", "test application hash")
accounts := flags.Int("accounts", 450, "unique accounts")
extraDevices := flags.Int("extra-devices", 50, "accounts receiving a second independent session")
concurrency := flags.Int("concurrency", 8, "parallel provisioning workers (max 64)")
phonePrefix := flags.String("phone-prefix", "+155500", "E.164 prefix followed by a six-digit account index")
firstName := flags.String("first-name-prefix", "Load", "generated first-name prefix")
obfuscated := flags.Bool("obfuscated", true, "use TDesktop-like Obfuscated2 + abridged transport")
pfs := flags.Bool("pfs", true, "bind temporary auth keys using PFS")
tempKeyTTL := flags.Int("temp-key-ttl", 86400, "temporary auth-key lifetime in seconds")
if err := flags.Parse(args); err != nil {
return err
}
if flags.NArg() != 0 {
return errors.New("provision accepts no positional arguments")
}
code := os.Getenv("TELESRV_LOAD_LOGIN_CODE")
if code == "" {
return errors.New("TELESRV_LOAD_LOGIN_CODE must contain the test environment login code")
}
cfg := loadharness.ProvisionConfig{
ManifestPath: *manifest, SessionKeyPath: *sessionKey, RSAKeyPath: *rsaKey,
Endpoint: loadharness.Endpoint{
Address: *server, DC: *dc, APIID: *apiID, APIHash: *apiHash, RSAKeyPath: *rsaKey,
Obfuscated: *obfuscated, PFS: *pfs, TempKeyTTL: *tempKeyTTL,
},
Accounts: *accounts, ExtraDevices: *extraDevices, Concurrency: *concurrency,
PhonePrefix: *phonePrefix, Code: code, FirstNamePrefix: *firstName,
}
result, err := loadharness.Provision(ctx, cfg, func(event loadharness.ProvisionEvent) {
status := "ok"
if event.Resumed {
status = "resumed"
}
if event.Err != nil {
status = "error"
}
fmt.Fprintf(os.Stdout, "provision %d/%d session=%d account=%d device=%d status=%s\n",
event.Completed, event.Total, event.Session.Index, event.Session.AccountIndex, event.Session.DeviceIndex, status)
})
if err != nil {
return err
}
fmt.Fprintf(os.Stdout, "provisioned %d real MTProto sessions into %s\n", len(result.Sessions), *manifest)
return nil
}
func runLoad(ctx context.Context, args []string) error {
flags := flag.NewFlagSet("run", flag.ContinueOnError)
manifest := flags.String("manifest", filepath.FromSlash("data/loadtest/manifest.json"), "provisioned manifest")
sessionKey := flags.String("session-key", filepath.FromSlash("data/loadtest/session.key"), "session encryption key")
rsaOverride := flags.String("rsa-key", "", "optional RSA public/private PEM override")
report := flags.String("report", filepath.FromSlash("data/loadtest/report.json"), "final JSON report")
events := flags.String("events", filepath.FromSlash("data/loadtest/events.ndjson"), "periodic NDJSON evidence")
fileFixture := flags.String("file-fixture", "", "reusable fixture JSON; empty stores beside manifest")
serverMetrics := flags.String("server-metrics", "http://127.0.0.1:6060/metrics", "server metrics URL; empty disables")
sessions := flags.Int("sessions", 0, "limit selected sessions; 0 uses all")
duration := flags.Duration("duration", 30*time.Minute, "sustained load duration")
recovery := flags.Duration("recovery", 7*time.Minute, "post-disconnect reclamation observation")
ramp := flags.Duration("ramp", 2*time.Minute, "connection ramp duration")
rpcInterval := flags.Duration("rpc-interval", 5*time.Second, "per-session background RPC interval")
messageInterval := flags.Duration("message-interval", 30*time.Second, "per-primary-session message interval; negative disables")
fileInterval := flags.Duration("file-interval", time.Minute, "per-session upload.getFile interval")
fileSize := flags.Int("file-size", 4<<20, "generated shared download fixture bytes; 0 disables")
fileChunk := flags.Int("file-chunk", 1<<20, "upload.getFile bytes per request (max 1MiB)")
setupTimeout := flags.Duration("setup-timeout", 90*time.Second, "maximum first-time file fixture setup duration")
operationTimeout := flags.Duration("operation-timeout", 30*time.Second, "maximum duration of one workload RPC")
sampleInterval := flags.Duration("sample-interval", 10*time.Second, "evidence and server scrape interval")
offlineFraction := flags.Float64("offline-fraction", 0.20, "fraction disconnected during offline window; 0 disables")
offlineAt := flags.Duration("offline-at", 10*time.Minute, "offline window start from run start")
offlineFor := flags.Duration("offline-for", 2*time.Minute, "offline window duration")
readyRatio := flags.Float64("min-ready-ratio", 0.98, "minimum peak ready ratio")
expectRestart := flags.Bool("expect-server-restart", false, "allow classified connection loss but require all selected sessions to reconnect")
if err := flags.Parse(args); err != nil {
return err
}
if flags.NArg() != 0 {
return errors.New("run accepts no positional arguments")
}
result, err := loadharness.Run(ctx, loadharness.RunConfig{
ManifestPath: *manifest, SessionKeyPath: *sessionKey, RSAKeyOverride: *rsaOverride,
ReportPath: *report, EventsPath: *events, FileFixturePath: *fileFixture, ServerMetricsURL: *serverMetrics,
SessionLimit: *sessions, Duration: *duration, RecoveryDuration: *recovery, RampDuration: *ramp,
RPCInterval: *rpcInterval, MessageInterval: *messageInterval, SampleInterval: *sampleInterval,
FileInterval: *fileInterval, FileSizeBytes: *fileSize, FileChunkBytes: *fileChunk, SetupTimeout: *setupTimeout,
OperationTimeout: *operationTimeout,
OfflineFraction: *offlineFraction, OfflineAt: *offlineAt, OfflineFor: *offlineFor,
MinimumReadyRatio: *readyRatio,
ExpectServerRestart: *expectRestart,
})
if err != nil {
return err
}
printSummary(result)
if !result.Pass {
return fmt.Errorf("load acceptance failed; see %s", *report)
}
return nil
}
func runSummarize(args []string) error {
flags := flag.NewFlagSet("summarize", flag.ContinueOnError)
path := flags.String("report", filepath.FromSlash("data/loadtest/report.json"), "JSON report")
if err := flags.Parse(args); err != nil {
return err
}
data, err := os.ReadFile(*path)
if err != nil {
return err
}
var report loadharness.RunReport
decoder := json.NewDecoder(strings.NewReader(string(data)))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&report); err != nil {
return err
}
printSummary(&report)
if !report.Pass {
return errors.New("report did not pass")
}
return nil
}
func printSummary(report *loadharness.RunReport) {
fmt.Fprintf(os.Stdout, "pass=%v sessions=%d peak_ready=%d reconnects=%d disconnects=%d flood_waits=%d fatal_errors=%d\n",
report.Pass, report.ExpectedSessions, report.PeakReadySessions, report.Reconnects, report.Disconnects,
totalFloodWaits(report), report.WorkerFatalErrors)
for _, failure := range report.Failures {
fmt.Fprintln(os.Stdout, "failure:", failure)
}
}
func totalFloodWaits(report *loadharness.RunReport) uint64 {
var total uint64
for _, operation := range report.Operations {
total += operation.FloodWaits
}
return total
}
func usageError() error {
return errors.New("expected one of: keygen, provision, run, summarize, help")
}
const usageText = `telesrv-load commands:
keygen generate an owner-only AES-256 session key
provision create accounts and encrypted sessions through real MTProto auth
run execute sustained real-client load, offline recovery and reclamation
summarize print the acceptance summary from a JSON report
Use "telesrv-load <command> -h" for command flags.`

View file

@ -27,9 +27,12 @@ import (
"telesrv/internal/app/account" "telesrv/internal/app/account"
aiapp "telesrv/internal/app/ai" aiapp "telesrv/internal/app/ai"
"telesrv/internal/app/auth" "telesrv/internal/app/auth"
authdiagnosticsapp "telesrv/internal/app/authdiagnostics"
botsapp "telesrv/internal/app/bots" botsapp "telesrv/internal/app/bots"
botverificationapp "telesrv/internal/app/botverification"
channelapp "telesrv/internal/app/channels" channelapp "telesrv/internal/app/channels"
chatlistsapp "telesrv/internal/app/chatlists" chatlistsapp "telesrv/internal/app/chatlists"
clienttelemetryapp "telesrv/internal/app/clienttelemetry"
communitiesapp "telesrv/internal/app/communities" communitiesapp "telesrv/internal/app/communities"
"telesrv/internal/app/contacts" "telesrv/internal/app/contacts"
"telesrv/internal/app/dialogs" "telesrv/internal/app/dialogs"
@ -41,10 +44,12 @@ import (
"telesrv/internal/app/livestream" "telesrv/internal/app/livestream"
"telesrv/internal/app/maintenance" "telesrv/internal/app/maintenance"
messageapp "telesrv/internal/app/messages" messageapp "telesrv/internal/app/messages"
moderationapp "telesrv/internal/app/moderation"
passkeyapp "telesrv/internal/app/passkey" passkeyapp "telesrv/internal/app/passkey"
phoneapp "telesrv/internal/app/phone" phoneapp "telesrv/internal/app/phone"
pollsapp "telesrv/internal/app/polls" pollsapp "telesrv/internal/app/polls"
privacyapp "telesrv/internal/app/privacy" privacyapp "telesrv/internal/app/privacy"
ratingapp "telesrv/internal/app/rating"
secretchatapp "telesrv/internal/app/secretchat" secretchatapp "telesrv/internal/app/secretchat"
"telesrv/internal/app/stargifts" "telesrv/internal/app/stargifts"
"telesrv/internal/app/stars" "telesrv/internal/app/stars"
@ -53,12 +58,15 @@ import (
themesapp "telesrv/internal/app/themes" themesapp "telesrv/internal/app/themes"
translationapp "telesrv/internal/app/translation" translationapp "telesrv/internal/app/translation"
"telesrv/internal/app/updates" "telesrv/internal/app/updates"
usernamesapp "telesrv/internal/app/usernames"
"telesrv/internal/app/userprojection" "telesrv/internal/app/userprojection"
"telesrv/internal/app/users" "telesrv/internal/app/users"
verificationapp "telesrv/internal/app/verification"
"telesrv/internal/botapi" "telesrv/internal/botapi"
"telesrv/internal/config" "telesrv/internal/config"
"telesrv/internal/domain" "telesrv/internal/domain"
"telesrv/internal/mtprotoedge" "telesrv/internal/mtprotoedge"
obsmetrics "telesrv/internal/observability/metrics"
"telesrv/internal/officialgifts" "telesrv/internal/officialgifts"
"telesrv/internal/otpdelivery" "telesrv/internal/otpdelivery"
otpsmtp "telesrv/internal/otpdelivery/smtp" otpsmtp "telesrv/internal/otpdelivery/smtp"
@ -230,7 +238,7 @@ func newTranslationOptions(cfg config.Config, limiter translationapp.RateLimiter
// - /debug/pprof/allocs 累计分配(带宽/序列化热点常与之相关) // - /debug/pprof/allocs 累计分配(带宽/序列化热点常与之相关)
// //
// mutex/block 采样在低流量测试环境开销可忽略;高流量生产如担心扰动,置空 DebugAddr 关闭整端点。 // mutex/block 采样在低流量测试环境开销可忽略;高流量生产如担心扰动,置空 DebugAddr 关闭整端点。
func startDebugServer(ctx context.Context, addr string, logger *zap.Logger) { func startDebugServer(ctx context.Context, addr string, metricsHandler http.Handler, logger *zap.Logger) {
if addr == "" { if addr == "" {
return return
} }
@ -243,6 +251,9 @@ func startDebugServer(ctx context.Context, addr string, logger *zap.Logger) {
mux.HandleFunc("/debug/pprof/profile", pprof.Profile) mux.HandleFunc("/debug/pprof/profile", pprof.Profile)
mux.HandleFunc("/debug/pprof/symbol", pprof.Symbol) mux.HandleFunc("/debug/pprof/symbol", pprof.Symbol)
mux.HandleFunc("/debug/pprof/trace", pprof.Trace) mux.HandleFunc("/debug/pprof/trace", pprof.Trace)
if metricsHandler != nil {
mux.Handle("/metrics", metricsHandler)
}
srv := &http.Server{Addr: addr, Handler: mux} srv := &http.Server{Addr: addr, Handler: mux}
go func() { go func() {
@ -260,6 +271,55 @@ func startDebugServer(ctx context.Context, addr string, logger *zap.Logger) {
}() }()
} }
func goRuntimeGaugeSamples() []obsmetrics.GaugeSample {
var mem runtime.MemStats
runtime.ReadMemStats(&mem)
return []obsmetrics.GaugeSample{
{Name: "telesrv_go_goroutines", Value: float64(runtime.NumGoroutine())},
{Name: "telesrv_go_heap_alloc_bytes", Value: float64(mem.HeapAlloc)},
{Name: "telesrv_go_heap_inuse_bytes", Value: float64(mem.HeapInuse)},
{Name: "telesrv_go_heap_objects", Value: float64(mem.HeapObjects)},
{Name: "telesrv_go_stack_inuse_bytes", Value: float64(mem.StackInuse)},
{Name: "telesrv_go_sys_bytes", Value: float64(mem.Sys)},
{Name: "telesrv_go_gc_cycles", Value: float64(mem.NumGC)},
{Name: "telesrv_go_gc_pause_seconds", Value: time.Duration(mem.PauseTotalNs).Seconds()},
}
}
func mtprotoRuntimeGaugeSamples(snapshot mtprotoedge.RuntimeSnapshot) []obsmetrics.GaugeSample {
return []obsmetrics.GaugeSample{
{Name: "telesrv_mtproto_raw_connections", Value: float64(snapshot.RawConnections)},
{Name: "telesrv_mtproto_raw_connection_limit", Value: float64(snapshot.RawConnectionLimit)},
{Name: "telesrv_mtproto_handshakes_active", Value: float64(snapshot.Handshakes)},
{Name: "telesrv_mtproto_handshake_limit", Value: float64(snapshot.HandshakeLimit)},
{Name: "telesrv_mtproto_sessions", Labels: []obsmetrics.Label{{Name: "state", Value: "active"}}, Value: float64(snapshot.ActiveSessions)},
{Name: "telesrv_mtproto_sessions", Labels: []obsmetrics.Label{{Name: "state", Value: "provisional"}}, Value: float64(snapshot.ProvisionalSessions)},
{Name: "telesrv_mtproto_logical_sessions", Labels: []obsmetrics.Label{{Name: "state", Value: "retained"}}, Value: float64(snapshot.LogicalSessions)},
{Name: "telesrv_mtproto_logical_sessions", Labels: []obsmetrics.Label{{Name: "state", Value: "offline"}}, Value: float64(snapshot.OfflineLogicalSessions)},
{Name: "telesrv_mtproto_logical_outbox_frames", Value: float64(snapshot.LogicalOutboxFrames)},
{Name: "telesrv_mtproto_logical_outbox_bytes", Value: float64(snapshot.LogicalOutboxBytes)},
{Name: "telesrv_mtproto_pending_push_bytes", Value: float64(snapshot.PendingPushBytes)},
{Name: "telesrv_mtproto_inbound_rpc_tasks", Value: float64(snapshot.InboundRPCTasks)},
{Name: "telesrv_mtproto_inbound_rpc_bytes", Value: float64(snapshot.InboundRPCBytes)},
{Name: "telesrv_mtproto_inbound_rpc_ready_connections", Value: float64(snapshot.InboundRPCReadyConnections)},
{Name: "telesrv_mtproto_inbound_rpc_task_limit", Value: float64(snapshot.InboundRPCMaxTasks)},
{Name: "telesrv_mtproto_inbound_rpc_byte_limit", Value: float64(snapshot.InboundRPCMaxBytes)},
{Name: "telesrv_mtproto_inbound_frame_bytes", Value: float64(snapshot.InboundFrameBytes)},
{Name: "telesrv_mtproto_inbound_frame_byte_limit", Value: float64(snapshot.InboundFrameMaxBytes)},
{Name: "telesrv_mtproto_outbound_tracked_bytes", Labels: []obsmetrics.Label{{Name: "kind", Value: "body"}}, Value: float64(snapshot.OutboundTrackedBytes)},
{Name: "telesrv_mtproto_outbound_tracked_bytes", Labels: []obsmetrics.Label{{Name: "kind", Value: "control"}}, Value: float64(snapshot.OutboundControlBytes)},
{Name: "telesrv_mtproto_outbound_tracked_byte_limit", Labels: []obsmetrics.Label{{Name: "kind", Value: "body"}}, Value: float64(snapshot.OutboundTrackedMaxBytes)},
{Name: "telesrv_mtproto_outbound_tracked_byte_limit", Labels: []obsmetrics.Label{{Name: "kind", Value: "control"}}, Value: float64(snapshot.OutboundControlMaxBytes)},
{Name: "telesrv_mtproto_outbound_write_bytes", Value: float64(snapshot.OutboundWriteBytes)},
{Name: "telesrv_mtproto_outbound_write_byte_limit", Value: float64(snapshot.OutboundWriteMaxBytes)},
{Name: "telesrv_mtproto_rpc_execution_owners", Value: float64(snapshot.RPCExecutionOwners)},
{Name: "telesrv_mtproto_rpc_execution_reserved_entries", Value: float64(snapshot.RPCExecutionReservedEntries)},
{Name: "telesrv_mtproto_rpc_execution_receipts", Value: float64(snapshot.RPCExecutionReceipts)},
{Name: "telesrv_mtproto_rpc_execution_receipt_budget_bytes", Value: float64(snapshot.RPCExecutionReceiptBudgetBytes)},
{Name: "telesrv_mtproto_rpc_execution_subscribers", Value: float64(snapshot.RPCExecutionSubscribers)},
}
}
// externalMediaOption 按配置启用外链媒体抓取;禁用时返回 nilNewService 跳过 nil option // externalMediaOption 按配置启用外链媒体抓取;禁用时返回 nilNewService 跳过 nil option
// liveStreamDep 把可能为 nil 的 *livestream.Service 转成 rpc.LiveStreamsService // liveStreamDep 把可能为 nil 的 *livestream.Service 转成 rpc.LiveStreamsService
// 避免 typed-nil interfacenil 具体指针装进接口后 != nil 的坑)。 // 避免 typed-nil interfacenil 具体指针装进接口后 != nil 的坑)。
@ -270,6 +330,172 @@ func liveStreamDep(s *livestream.Service) rpc.LiveStreamsService {
return s 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 { func externalMediaOption(cfg config.Config) filesapp.Option {
if !cfg.ExternalMediaEnable { if !cfg.ExternalMediaEnable {
return nil return nil
@ -312,6 +538,7 @@ func run(logger *zap.Logger) error {
logger.Info("telesrv starting", logger.Info("telesrv starting",
zap.String("listen", cfg.ListenAddr), zap.String("listen", cfg.ListenAddr),
zap.Int("dc", cfg.DC), zap.Int("dc", cfg.DC),
zap.String("default_country_code", cfg.DefaultCountryCode),
zap.String("advertise", net.JoinHostPort(cfg.AdvertiseIP, portStr)), zap.String("advertise", net.JoinHostPort(cfg.AdvertiseIP, portStr)),
zap.Int("tl_layer", tg.Layer), zap.Int("tl_layer", tg.Layer),
zap.String("git_commit", buildMeta.Commit), zap.String("git_commit", buildMeta.Commit),
@ -325,10 +552,12 @@ func run(logger *zap.Logger) error {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop() defer stop()
metricRegistry := obsmetrics.New()
metricRegistry.AddGaugeProvider(goRuntimeGaugeSamples)
// pprof 调试端点telesrv 是宿主进程(不在 docker 内docker stats 看不到它CPU/内存/ // pprof 调试端点telesrv 是宿主进程(不在 docker 内docker stats 看不到它CPU/内存/
// goroutine/锁竞争的定位全靠此端点。早于重负载初始化启动,连 seed/预热阶段也可剖析。 // goroutine/锁竞争的定位全靠此端点。早于重负载初始化启动,连 seed/预热阶段也可剖析。
startDebugServer(ctx, cfg.DebugAddr, logger) startDebugServer(ctx, cfg.DebugAddr, metricRegistry, logger)
// 持久化依赖:先迁移 schema再建立连接。auth key 与业务事实落 PostgreSQL // 持久化依赖:先迁移 schema再建立连接。auth key 与业务事实落 PostgreSQL
// Redis 只承载可重建的短 TTL 状态、缓存、计数器和限流。 // Redis 只承载可重建的短 TTL 状态、缓存、计数器和限流。
@ -350,6 +579,20 @@ func run(logger *zap.Logger) error {
return fmt.Errorf("connect postgres: %w", err) return fmt.Errorf("connect postgres: %w", err)
} }
defer pool.Close() defer pool.Close()
metricRegistry.AddGaugeProvider(func() []obsmetrics.GaugeSample {
stat := pool.Stat()
return []obsmetrics.GaugeSample{
{Name: "telesrv_postgres_pool_connections", Labels: []obsmetrics.Label{{Name: "state", Value: "total"}}, Value: float64(stat.TotalConns())},
{Name: "telesrv_postgres_pool_connections", Labels: []obsmetrics.Label{{Name: "state", Value: "acquired"}}, Value: float64(stat.AcquiredConns())},
{Name: "telesrv_postgres_pool_connections", Labels: []obsmetrics.Label{{Name: "state", Value: "idle"}}, Value: float64(stat.IdleConns())},
{Name: "telesrv_postgres_pool_connections", Labels: []obsmetrics.Label{{Name: "state", Value: "constructing"}}, Value: float64(stat.ConstructingConns())},
{Name: "telesrv_postgres_pool_max_connections", Value: float64(stat.MaxConns())},
{Name: "telesrv_postgres_pool_acquire_count", Value: float64(stat.AcquireCount())},
{Name: "telesrv_postgres_pool_acquire_wait_seconds", Value: stat.AcquireDuration().Seconds()},
{Name: "telesrv_postgres_pool_empty_acquire_count", Value: float64(stat.EmptyAcquireCount())},
{Name: "telesrv_postgres_pool_canceled_acquire_count", Value: float64(stat.CanceledAcquireCount())},
}
})
var telegramLoginService *telegramloginapp.Service var telegramLoginService *telegramloginapp.Service
var telegramLoginIDTokens *telegramloginapp.IDTokenIssuer var telegramLoginIDTokens *telegramloginapp.IDTokenIssuer
@ -390,6 +633,19 @@ func run(logger *zap.Logger) error {
return fmt.Errorf("connect redis: %w", err) return fmt.Errorf("connect redis: %w", err)
} }
defer func() { _ = rdb.Close() }() defer func() { _ = rdb.Close() }()
metricRegistry.AddGaugeProvider(func() []obsmetrics.GaugeSample {
stat := rdb.PoolStats()
return []obsmetrics.GaugeSample{
{Name: "telesrv_redis_pool_connections", Labels: []obsmetrics.Label{{Name: "state", Value: "total"}}, Value: float64(stat.TotalConns)},
{Name: "telesrv_redis_pool_connections", Labels: []obsmetrics.Label{{Name: "state", Value: "idle"}}, Value: float64(stat.IdleConns)},
{Name: "telesrv_redis_pool_pending_requests", Value: float64(stat.PendingRequests)},
{Name: "telesrv_redis_pool_hits", Value: float64(stat.Hits)},
{Name: "telesrv_redis_pool_misses", Value: float64(stat.Misses)},
{Name: "telesrv_redis_pool_timeouts", Value: float64(stat.Timeouts)},
{Name: "telesrv_redis_pool_wait_count", Value: float64(stat.WaitCount)},
{Name: "telesrv_redis_pool_wait_seconds", Value: time.Duration(stat.WaitDurationNs).Seconds()},
}
})
logger.Info("persistence dependencies ready", zap.String("redis", cfg.RedisAddr)) logger.Info("persistence dependencies ready", zap.String("redis", cfg.RedisAddr))
if cfg.TelegramLoginEnabled { if cfg.TelegramLoginEnabled {
telegramLoginHTTPHandler, err = telegramloginhttp.NewHandler(telegramloginhttp.Config{ telegramLoginHTTPHandler, err = telegramloginhttp.NewHandler(telegramloginhttp.Config{
@ -420,6 +676,9 @@ func run(logger *zap.Logger) error {
botCallbackStore := redisstore.NewBotCallbackRegistryStore(rdb) botCallbackStore := redisstore.NewBotCallbackRegistryStore(rdb)
ephemeralStore := redisstore.NewEphemeralMessageStore(rdb) ephemeralStore := redisstore.NewEphemeralMessageStore(rdb)
ephemeralReportStore := postgres.NewEphemeralReportStore(pool) ephemeralReportStore := postgres.NewEphemeralReportStore(pool)
moderationReportStore := postgres.NewModerationReportStore(pool)
authDeliveryReportStore := postgres.NewAuthDeliveryReportStore(pool)
clientTelemetryStore := postgres.NewClientTelemetryStore(pool)
boxIDAllocator := redisstore.NewBoxIDAllocator(rdb, postgres.NewMessageBoxCounterSource(pool)) boxIDAllocator := redisstore.NewBoxIDAllocator(rdb, postgres.NewMessageBoxCounterSource(pool))
channelIDAllocator := redisstore.NewChannelIDAllocator(rdb, postgres.NewChannelIDCounterSource(pool)) channelIDAllocator := redisstore.NewChannelIDAllocator(rdb, postgres.NewChannelIDCounterSource(pool))
channelMessageIDAllocator := redisstore.NewChannelMessageIDAllocator(rdb, postgres.NewChannelMessageIDCounterSource(pool)) channelMessageIDAllocator := redisstore.NewChannelMessageIDAllocator(rdb, postgres.NewChannelMessageIDCounterSource(pool))
@ -491,6 +750,15 @@ func run(logger *zap.Logger) error {
zap.Int("blobs", stats.Blobs), zap.Int("blobs", stats.Blobs),
) )
} }
if stats, err := filesService.SeedPremiumPromo(ctx, cfg.PremiumPromoSeedDir); err != nil {
return fmt.Errorf("seed premium promo: %w", err)
} else if !stats.Skipped {
logger.Info("Premium promo 视频种子导入完成",
zap.String("dir", cfg.PremiumPromoSeedDir),
zap.Int("videos", stats.Videos),
zap.Int("blobs", stats.Blobs),
)
}
if stats, err := filesService.SeedAppearance(ctx); err != nil { if stats, err := filesService.SeedAppearance(ctx); err != nil {
return fmt.Errorf("seed appearance: %w", err) return fmt.Errorf("seed appearance: %w", err)
} else if !stats.Skipped { } else if !stats.Skipped {
@ -545,6 +813,8 @@ func run(logger *zap.Logger) error {
tempAuthKeyStore := postgres.NewTempAuthKeyBindingStore(pool) tempAuthKeyStore := postgres.NewTempAuthKeyBindingStore(pool)
inlineRegistryStore := redisstore.NewInlineRegistryStore(rdb) inlineRegistryStore := redisstore.NewInlineRegistryStore(rdb)
codeStore := redisstore.NewCodeStore(rdb) codeStore := redisstore.NewCodeStore(rdb)
authDeliveryReportService := authdiagnosticsapp.NewService(codeStore, authDeliveryReportStore)
clientTelemetryService := clienttelemetryapp.NewService(clientTelemetryStore)
rateLimiter := redisstore.NewRateLimiter(rdb) rateLimiter := redisstore.NewRateLimiter(rdb)
activeSessions := mtprotoedge.NewSessionManager(logger.Named("mtprotoedge").Named("sessions")) activeSessions := mtprotoedge.NewSessionManager(logger.Named("mtprotoedge").Named("sessions"))
adminService := adminapp.NewService(adminapp.Dependencies{ adminService := adminapp.NewService(adminapp.Dependencies{
@ -560,6 +830,9 @@ func run(logger *zap.Logger) error {
WithBotAPIUpdateRetention(botAPIUpdateStore, cfg.BotAPIUpdateRetention). WithBotAPIUpdateRetention(botAPIUpdateStore, cfg.BotAPIUpdateRetention).
WithAuthKeySessionLayerRetention(authKeyStore). WithAuthKeySessionLayerRetention(authKeyStore).
WithLoginCodeDeliveryRetention(messageStore). WithLoginCodeDeliveryRetention(messageStore).
WithClientTelemetryRetention(clientTelemetryStore, 30*24*time.Hour).
WithAuthDeliveryReportRetention(authDeliveryReportStore, 30*24*time.Hour).
WithModerationRetention(moderationReportStore).
WithUserUpdateRetention(updateEventStore). WithUserUpdateRetention(updateEventStore).
WithChannelUpdateRetention(channelStore). WithChannelUpdateRetention(channelStore).
WithOrphanAuthKeyRetention(authKeyStore, activeSessions, cfg.OrphanAuthKeyRetention). WithOrphanAuthKeyRetention(authKeyStore, activeSessions, cfg.OrphanAuthKeyRetention).
@ -600,6 +873,7 @@ func run(logger *zap.Logger) error {
// userCache 与 users 服务共享同一实例bot 元数据写入version bump后必须 // userCache 与 users 服务共享同一实例bot 元数据写入version bump后必须
// 失效缓存,否则 TTL 内 getUsers 回旧 first_name/旧 bot_info_version。 // 失效缓存,否则 TTL 内 getUsers 回旧 first_name/旧 bot_info_version。
userCache := redisstore.NewUserCache(rdb, redisstore.DefaultUserCacheTTL) userCache := redisstore.NewUserCache(rdb, redisstore.DefaultUserCacheTTL)
accountLifecycleStore := postgres.NewAccountLifecycleStore(pool)
accountOptions := []account.ServiceOption{ accountOptions := []account.ServiceOption{
account.WithReactionSettings(passwordStore), account.WithReactionSettings(passwordStore),
account.WithAccountSettings(passwordStore), account.WithAccountSettings(passwordStore),
@ -610,7 +884,7 @@ func run(logger *zap.Logger) error {
account.WithBusinessAutomation(passwordStore), account.WithBusinessAutomation(passwordStore),
account.WithUsers(userStore), account.WithUsers(userStore),
account.WithPhoneChange(phoneChangeStore, authzStore, codeStore, userCache, cfg.DevAuthCode, cfg.AuthCodeTTL, cfg.AuthCodeMaxAttempts), account.WithPhoneChange(phoneChangeStore, authzStore, codeStore, userCache, cfg.DevAuthCode, cfg.AuthCodeTTL, cfg.AuthCodeMaxAttempts),
account.WithAccountLifecycle(postgres.NewAccountLifecycleStore(pool)), account.WithAccountLifecycle(accountLifecycleStore),
account.WithPublicBaseURL(cfg.PublicBaseURL), account.WithPublicBaseURL(cfg.PublicBaseURL),
account.WithEmailSignup(cfg.EmailSignupEnable), account.WithEmailSignup(cfg.EmailSignupEnable),
account.WithEmailSignupPhonePrefixes(cfg.EmailSignupPhonePrefixes), account.WithEmailSignupPhonePrefixes(cfg.EmailSignupPhonePrefixes),
@ -672,6 +946,7 @@ func run(logger *zap.Logger) error {
botsapp.WithStickerSetCreator(filesService), botsapp.WithStickerSetCreator(filesService),
botsapp.WithUserStickerSets(accountService), botsapp.WithUserStickerSets(accountService),
botsapp.WithTelegramLogin(telegramLoginService), botsapp.WithTelegramLogin(telegramLoginService),
botsapp.WithDialogRateLimiter(rateLimiter, cfg.VerificationBotRateLimit, cfg.VerificationBotRateWindow),
botsapp.WithPublicBaseURL(cfg.PublicBaseURL)) botsapp.WithPublicBaseURL(cfg.PublicBaseURL))
groupCallStore := postgres.NewGroupCallStore(pool) groupCallStore := postgres.NewGroupCallStore(pool)
groupCallsService := groupcallsapp.NewService(groupCallStore, groupcallsapp.WithPublicBaseURL(cfg.PublicBaseURL)) groupCallsService := groupcallsapp.NewService(groupCallStore, groupcallsapp.WithPublicBaseURL(cfg.PublicBaseURL))
@ -751,6 +1026,7 @@ func run(logger *zap.Logger) error {
RingTimeout: cfg.CallRingTimeout, RingTimeout: cfg.CallRingTimeout,
TombstoneTTL: cfg.CallTombstoneTTL, TombstoneTTL: cfg.CallTombstoneTTL,
MaxActivePerUser: cfg.CallMaxActivePerUser, MaxActivePerUser: cfg.CallMaxActivePerUser,
MaxRegistryEntries: cfg.CallRegistryMaxEntries,
SignalingRatePerSecond: cfg.CallSignalingRate, SignalingRatePerSecond: cfg.CallSignalingRate,
}) })
// 私聊端对端加密Secret Chat握手状态机 + qts 投递队列(盲中继)。 // 私聊端对端加密Secret Chat握手状态机 + qts 投递队列(盲中继)。
@ -758,7 +1034,10 @@ func run(logger *zap.Logger) error {
encryptedQueueStore := postgres.NewEncryptedQueueStore(pool) encryptedQueueStore := postgres.NewEncryptedQueueStore(pool)
secretChatService := secretchatapp.NewService(secretChatStore, encryptedQueueStore, secretChatIDAllocator) secretChatService := secretchatapp.NewService(secretChatStore, encryptedQueueStore, secretChatIDAllocator)
starsStore := postgres.NewStarsStore(pool) starsStore := postgres.NewStarsStore(pool)
starsService := stars.NewService(starsStore, stars.WithStartingGrant(cfg.StarsStartingGrant)) starsPurchaseStore := postgres.NewStarsPurchaseStore(pool, messageStore, channelStore)
starsService := stars.NewService(starsStore,
stars.WithStartingGrant(cfg.StarsStartingGrant),
stars.WithPurchaseStore(starsPurchaseStore))
starGiftStore := postgres.NewStarGiftStore(pool) starGiftStore := postgres.NewStarGiftStore(pool)
starGiftUpgradeStore := postgres.NewStarGiftUpgradeStore(pool, messageStore, postgres.WithStarGiftLifecyclePolicy(domain.StarGiftLifecyclePolicy{ starGiftUpgradeStore := postgres.NewStarGiftUpgradeStore(pool, messageStore, postgres.WithStarGiftLifecyclePolicy(domain.StarGiftLifecyclePolicy{
TransferStars: cfg.StarGiftTransferStars, DropOriginalDetailsStars: cfg.StarGiftDropOriginalDetailsStars, TransferStars: cfg.StarGiftTransferStars, DropOriginalDetailsStars: cfg.StarGiftDropOriginalDetailsStars,
@ -789,6 +1068,7 @@ func run(logger *zap.Logger) error {
// 自定义云主题(Create a New Theme):主题目录与每用户已安装列表均持久化到 postgres。 // 自定义云主题(Create a New Theme):主题目录与每用户已安装列表均持久化到 postgres。
themeService := themesapp.NewService(postgres.NewThemeStore(pool)) themeService := themesapp.NewService(postgres.NewThemeStore(pool))
usersService := users.NewService(userStore, users.WithBaseUserCache(userCache), users.WithContactStore(contactStore), users.WithPhotoProvider(cachedPhotos), users.WithPrivacyEvaluator(privacyService), users.WithAccountFreezeProvider(adminService)) usersService := users.NewService(userStore, users.WithBaseUserCache(userCache), users.WithContactStore(contactStore), users.WithPhotoProvider(cachedPhotos), users.WithPrivacyEvaluator(privacyService), users.WithAccountFreezeProvider(adminService))
privacyService.ConfigureReadModels(usersService, channelStore)
aiComposeService := aiapp.NewService(aiComposeStore, newAIComposeOptions(cfg, rateLimiter, usersService.PremiumActive, logger)...) aiComposeService := aiapp.NewService(aiComposeStore, newAIComposeOptions(cfg, rateLimiter, usersService.PremiumActive, logger)...)
botsService.SetAIChatGenerator(aiComposeService) botsService.SetAIChatGenerator(aiComposeService)
dialogsService := dialogs.NewService(dialogStore, channelStore).Configure( dialogsService := dialogs.NewService(dialogStore, channelStore).Configure(
@ -809,6 +1089,7 @@ func run(logger *zap.Logger) error {
) )
communitiesService := communitiesapp.NewService(communityStore) communitiesService := communitiesapp.NewService(communityStore)
ephemeralService := ephemeralapp.NewService(ephemeralStore, channelsService, usersService, botsService) ephemeralService := ephemeralapp.NewService(ephemeralStore, channelsService, usersService, botsService)
storiesService := storiesapp.NewService(storyStore, storiesapp.WithChannelStoryAccess(channelsService))
chatlistsService := chatlistsapp.NewService( chatlistsService := chatlistsapp.NewService(
chatlistStore, chatlistStore,
dialogStore, dialogStore,
@ -826,6 +1107,21 @@ func run(logger *zap.Logger) error {
messageapp.WithSendPermissionChecker(adminService), messageapp.WithSendPermissionChecker(adminService),
messageapp.WithBusinessAutomation(passwordStore, businessAutomationOptions...), messageapp.WithBusinessAutomation(passwordStore, businessAutomationOptions...),
) )
moderationService := moderationapp.NewService(
moderationReportStore,
moderationapp.WithMessageReaders(messagesService, channelsService),
moderationapp.WithStoryReader(storiesService),
moderationapp.WithPeerReaders(usersService, channelsService),
moderationapp.WithProfilePhotoReader(filesService),
)
legacyReportsMigrated, err := moderationService.MigrateLegacyEphemeralReports(ctx, ephemeralReportStore, 500)
if err != nil {
return fmt.Errorf("migrate legacy ephemeral reports: %w", err)
}
if legacyReportsMigrated > 0 {
logger.Info("旧 ephemeral 举报已迁移到统一审核管线",
zap.Int("reports", legacyReportsMigrated))
}
translationService := translationapp.NewService( translationService := translationapp.NewService(
messagesService, messagesService,
channelsService, channelsService,
@ -858,10 +1154,82 @@ func run(logger *zap.Logger) error {
}), }),
auth.WithEmailSignup(cfg.EmailSignupEnable), auth.WithEmailSignup(cfg.EmailSignupEnable),
auth.WithEmailSignupPhonePrefixes(cfg.EmailSignupPhonePrefixes)) auth.WithEmailSignupPhonePrefixes(cfg.EmailSignupPhonePrefixes))
// Collectible (NFT) usernames and the gramsrv composite account rating are
// optional read models projected at the protocol edge. The rating worker
// computes and persists scores; profile reads never recompute them.
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"))) updatesService := updates.NewService(updateStateStore, updateEventStore, updates.WithLogger(logger.Named("app").Named("updates")))
rpc.SetModerationWarnings(cfg.ScamWarning, cfg.FakeWarning)
router := rpc.New(rpc.Config{ router := rpc.New(rpc.Config{
DC: cfg.DC, DC: cfg.DC,
DefaultCountryCode: cfg.DefaultCountryCode,
IP: cfg.AdvertiseIP, IP: cfg.AdvertiseIP,
Port: port, Port: port,
OutboundPushTimeout: cfg.OutboundPushTimeout, OutboundPushTimeout: cfg.OutboundPushTimeout,
@ -886,6 +1254,8 @@ func run(logger *zap.Logger) error {
TempKeyResolveCacheMaxEntries: cfg.TempKeyResolveCacheMaxEntries, TempKeyResolveCacheMaxEntries: cfg.TempKeyResolveCacheMaxEntries,
}, rpc.Deps{ }, rpc.Deps{
Auth: authService, Auth: authService,
AuthDeliveryReports: authDeliveryReportService,
ClientTelemetry: clientTelemetryService,
AuthKeySessionLayers: authKeyStore, AuthKeySessionLayers: authKeyStore,
Account: accountService, Account: accountService,
Privacy: privacyService, Privacy: privacyService,
@ -895,42 +1265,48 @@ func run(logger *zap.Logger) error {
help.WithEmailSignupPhonePrefixes(cfg.EmailSignupPhonePrefixes), help.WithEmailSignupPhonePrefixes(cfg.EmailSignupPhonePrefixes),
help.WithAccountFreezeProvider(adminService), help.WithAccountFreezeProvider(adminService),
), ),
AccountFreeze: adminService, AccountFreeze: adminService,
AICompose: aiComposeService, AICompose: aiComposeService,
Ephemeral: ephemeralService, Ephemeral: ephemeralService,
EphemeralPush: ephemeralStore, EphemeralPush: ephemeralStore,
EphemeralReports: ephemeralReportStore, Moderation: moderationService,
Users: usersService, Users: usersService,
TelegramLogin: telegramLoginRPCDependency(telegramLoginService), Usernames: usernamesService,
Updates: updatesService, AccountRatings: ratingService,
BootstrapUpdates: bootstrapUpdateStore, BotVerifications: botVerificationService,
BotAPIUpdates: botAPIUpdateStore, TelegramLogin: telegramLoginRPCDependency(telegramLoginService),
BotCallbacks: botCallbackStore, Updates: updatesService,
Contacts: contactsService, BootstrapUpdates: bootstrapUpdateStore,
Dialogs: dialogsService, BotAPIUpdates: botAPIUpdateStore,
Chatlists: chatlistsService, BotCallbacks: botCallbackStore,
Messages: messagesService, Contacts: contactsService,
Translation: translationService, Dialogs: dialogsService,
Channels: channelsService, Chatlists: chatlistsService,
Communities: communitiesService, Messages: messagesService,
Files: filesService, Translation: translationService,
Bots: botsService, Channels: channelsService,
Polls: pollsapp.NewService(pollStore), Communities: communitiesService,
Stories: storiesapp.NewService(storyStore, storiesapp.WithChannelStoryAccess(channelsService)), Files: filesService,
Phone: phoneService, PremiumPromo: filesService,
SecretChats: secretChatService, Bots: botsService,
Stars: starsService, ServiceBotCallbacks: botsService,
Gifts: giftsService, Polls: pollsapp.NewService(pollStore),
Passkey: passkeyService, Stories: storiesService,
Themes: themeService, Phone: phoneService,
GroupCalls: groupCallsService, SecretChats: secretChatService,
LiveStreams: liveStreamDep(liveStreamService), Stars: starsService,
SFU: sfuService, Gifts: giftsService,
TURN: turnService, Passkey: passkeyService,
LangPack: langPackService, Themes: themeService,
Sessions: activeSessions, GroupCalls: groupCallsService,
Inline: inlineRegistryStore, LiveStreams: liveStreamDep(liveStreamService),
Limiter: rateLimiter, SFU: sfuService,
TURN: turnService,
LangPack: langPackService,
Sessions: activeSessions,
Metrics: metricRegistry,
Inline: inlineRegistryStore,
Limiter: rateLimiter,
}, logger.Named("rpc"), clock.System) }, logger.Named("rpc"), clock.System)
readModelListener := postgres.NewReadModelChangeListener(cfg.PostgresDSN, postgres.ReadModelCacheSet{ readModelListener := postgres.NewReadModelChangeListener(cfg.PostgresDSN, postgres.ReadModelCacheSet{
ReadModelVersions: readModelVersionStore, ReadModelVersions: readModelVersionStore,
@ -940,7 +1316,7 @@ func run(logger *zap.Logger) error {
ChannelBoosts: channelBoostCache, ChannelBoosts: channelBoostCache,
Contacts: postgres.ContactReadModelCaches{contactStore, contactsService}, Contacts: postgres.ContactReadModelCaches{contactStore, contactsService},
Dialogs: dialogsService, Dialogs: dialogsService,
Privacy: privacyStore, Privacy: privacyService,
ProfilePhotos: cachedPhotos, ProfilePhotos: cachedPhotos,
Stories: router, Stories: router,
ChannelFullBots: router, ChannelFullBots: router,
@ -951,27 +1327,108 @@ func run(logger *zap.Logger) error {
BaseUsers: userCache, BaseUsers: userCache,
BotProfiles: botsService, BotProfiles: botsService,
StarGifts: giftsService, StarGifts: giftsService,
AccountSettings: router,
}, logger.Named("store").Named("read-model-listener")) }, logger.Named("store").Named("read-model-listener"))
go readModelListener.Run(ctx) go readModelListener.Run(ctx)
activeSessions.SetLifecycleObserver(router) activeSessions.SetLifecycleObserver(router)
adminService.Configure(adminapp.Dependencies{ adminService.Configure(adminapp.Dependencies{
Auth: authService, Auth: authService,
Revoker: router, Revoker: router,
Users: usersService, Users: usersService,
Stars: starsService, Stars: starsService,
StarsNotifier: router, StarsNotifier: router,
UserNotifier: router, UserNotifier: router,
FreezeNotifier: router, UserModerationNotifier: router,
Channels: channelsService, FreezeNotifier: router,
ChannelNotifier: router, Channels: channelsService,
Messages: messagesService, ChannelNotifier: router,
Gifts: giftsService, Messages: messagesService,
Photos: filesService, Gifts: giftsService,
StickerSets: filesService, Photos: filesService,
GiftGranter: router, StickerSets: filesService,
Bots: botsService, GiftGranter: router,
Emoji: filesService, 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(
moderationActionOptions,
moderationapp.WithAppealLinks(moderationService, cfg.PublicBaseURL),
)
}
moderationActionExecutor := moderationapp.NewActionExecutor(
adminService, channelsService, router, accountLifecycleStore,
moderationActionOptions...,
)
go moderationapp.NewActionWorker(
moderationReportStore,
moderationActionExecutor,
logger.Named("moderation").Named("actions"),
).Run(ctx)
// bot session 撤销、在线通知与 @ChatBot 流式草稿推送经 router 实现(需 tg.* 边界), // bot session 撤销、在线通知与 @ChatBot 流式草稿推送经 router 实现(需 tg.* 边界),
// router 创建后注入。 // router 创建后注入。
botsService.SetRouterHooks(router) botsService.SetRouterHooks(router)
@ -981,6 +1438,7 @@ func run(logger *zap.Logger) error {
rpc.WithOutboxBatch(cfg.OutboxBatch), rpc.WithOutboxBatch(cfg.OutboxBatch),
rpc.WithOutboxInterval(cfg.OutboxInterval), rpc.WithOutboxInterval(cfg.OutboxInterval),
rpc.WithOutboxPushTimeout(cfg.OutboundPushTimeout), rpc.WithOutboxPushTimeout(cfg.OutboundPushTimeout),
rpc.WithOutboxMetrics(metricRegistry),
rpc.WithOutboxUpdateBuilder(router.BuildOutboxUpdates), rpc.WithOutboxUpdateBuilder(router.BuildOutboxUpdates),
).Run(ctx) ).Run(ctx)
go rpc.NewBootstrapUpdateDispatcher(router, logger.Named("rpc").Named("bootstrap")).Run(ctx) go rpc.NewBootstrapUpdateDispatcher(router, logger.Named("rpc").Named("bootstrap")).Run(ctx)
@ -1031,61 +1489,74 @@ func run(logger *zap.Logger) error {
if _, err := botapi.Start(ctx, cfg.BotAPIAddr, botsService, usersService, router, router, logger.Named("botapi")); err != nil { if _, err := botapi.Start(ctx, cfg.BotAPIAddr, botsService, usersService, router, router, logger.Named("botapi")); err != nil {
return fmt.Errorf("start bot api: %w", err) 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) return fmt.Errorf("start admin api: %w", err)
} }
if _, err := web.Start(ctx, web.Config{ if _, err := web.Start(ctx, web.Config{
Addr: cfg.PublicLinkWebAddr, Addr: cfg.PublicLinkWebAddr,
PublicBaseURL: cfg.PublicBaseURL, PublicBaseURL: cfg.PublicBaseURL,
AppScheme: cfg.PublicAppScheme, AppScheme: cfg.PublicAppScheme,
AppLinkBase: cfg.PublicAppLinkBase, AppLinkBase: cfg.PublicAppLinkBase,
WebBaseURL: cfg.PublicWebBaseURL, WebBaseURL: cfg.PublicWebBaseURL,
AppName: cfg.PublicAppName, AppName: cfg.PublicAppName,
DownloadURL: cfg.PublicDownloadURL, DownloadURL: cfg.PublicDownloadURL,
StickerSets: filesService, StickerSets: filesService,
Users: userStore, Users: userStore,
Channels: channelStore, Channels: channelStore,
Privacy: privacyService, Privacy: privacyService,
Photos: filesService, Photos: filesService,
UniqueGifts: giftsService, UniqueGifts: giftsService,
GiftWithdrawals: giftsService, GiftWithdrawals: giftsService,
TelegramLogin: telegramLoginHTTPHandler, ModerationAppeals: moderationService,
TelegramLogin: telegramLoginHTTPHandler,
}, logger.Named("public-web")); err != nil { }, logger.Named("public-web")); err != nil {
return fmt.Errorf("start public Web: %w", err) return fmt.Errorf("start public Web: %w", err)
} }
srv := mtprotoedge.New(mtprotoedge.Options{ srv := mtprotoedge.New(mtprotoedge.Options{
Logger: logger.Named("mtprotoedge"), Logger: logger.Named("mtprotoedge"),
DC: cfg.DC, DC: cfg.DC,
StrictDC: cfg.StrictDCCheck, StrictDC: cfg.StrictDCCheck,
RSAKey: rsaKey, RSAKey: rsaKey,
LayerRPC: router, LayerRPC: router,
AuthKeys: authKeyStore, AuthKeys: authKeyStore,
ActiveSessions: activeSessions, ActiveSessions: activeSessions,
ObfuscatedTCP: true, Metrics: metricRegistry,
WebSocket: cfg.WebSocketEnable, ObfuscatedTCP: true,
WebSocketAllowedOrigins: cfg.WebSocketAllowedOrigins, WebSocket: cfg.WebSocketEnable,
MaxConnections: cfg.MTProtoMaxConnections, WebSocketAllowedOrigins: cfg.WebSocketAllowedOrigins,
MaxConnectionsPerIP: cfg.MTProtoMaxConnectionsPerIP, MaxConnections: cfg.MTProtoMaxConnections,
MaxConcurrentHandshakes: cfg.MTProtoMaxConcurrentHandshakes, MaxConnectionsPerIP: cfg.MTProtoMaxConnectionsPerIP,
RPCMaxInflight: cfg.MTProtoRPCMaxInflight, MaxConcurrentHandshakes: cfg.MTProtoMaxConcurrentHandshakes,
RPCQueueSize: cfg.MTProtoRPCQueueSize, RPCMaxInflight: cfg.MTProtoRPCMaxInflight,
RPCTimeout: cfg.MTProtoRPCTimeout, RPCQueueSize: cfg.MTProtoRPCQueueSize,
RPCGlobalWorkers: cfg.MTProtoRPCGlobalWorkers, RPCTimeout: cfg.MTProtoRPCTimeout,
RPCGlobalMaxTasks: cfg.MTProtoRPCGlobalMaxTasks, RPCGlobalWorkers: cfg.MTProtoRPCGlobalWorkers,
RPCGlobalMaxBytes: cfg.MTProtoRPCGlobalMaxBytes, RPCGlobalMaxTasks: cfg.MTProtoRPCGlobalMaxTasks,
RPCResultCacheMaxEntries: cfg.MTProtoRPCResultCacheMaxEntries, RPCGlobalMaxBytes: cfg.MTProtoRPCGlobalMaxBytes,
RPCResultCacheMaxBytes: cfg.MTProtoRPCResultCacheMaxBytes, RPCExecutionMaxEntries: cfg.MTProtoRPCExecutionMaxEntries,
RPCResultCacheAuthMaxEntries: cfg.MTProtoRPCResultCacheAuthMaxEntries, RPCExecutionAuthMaxEntries: cfg.MTProtoRPCExecutionAuthMaxEntries,
RPCResultCacheAuthMaxBytes: cfg.MTProtoRPCResultCacheAuthMaxBytes, RPCExecutionSessionMaxEntries: cfg.MTProtoRPCExecutionSessionMaxEntries,
RPCResultCacheSessionMaxEntries: cfg.MTProtoRPCResultCacheSessionMaxEntries, RPCExecutionPendingPerAuth: cfg.MTProtoRPCExecutionPendingPerAuth,
RPCResultCacheSessionMaxBytes: cfg.MTProtoRPCResultCacheSessionMaxBytes, InboundFrameGlobalMaxBytes: cfg.MTProtoInboundFrameGlobalMaxBytes,
RPCResultPendingPerAuth: cfg.MTProtoRPCResultPendingPerAuth, OutboundQueueSize: cfg.MTProtoOutboundQueueSize,
InboundFrameGlobalMaxBytes: cfg.MTProtoInboundFrameGlobalMaxBytes, OutboundControlQueueSize: cfg.MTProtoOutboundControlQueueSize,
OutboundQueueSize: cfg.MTProtoOutboundQueueSize, OutboundTrackedGlobalMaxBytes: cfg.MTProtoOutboundTrackedGlobalMaxBytes,
OutboundControlQueueSize: cfg.MTProtoOutboundControlQueueSize, OutboundWriteGlobalMaxBytes: cfg.MTProtoOutboundWriteGlobalMaxBytes,
OutboundTrackedGlobalMaxBytes: cfg.MTProtoOutboundTrackedGlobalMaxBytes,
OutboundWriteGlobalMaxBytes: cfg.MTProtoOutboundWriteGlobalMaxBytes,
OnServing: func(_ net.Addr) { OnServing: func(_ net.Addr) {
logger.Info("telesrv service ready", logger.Info("telesrv service ready",
zap.String("listen", cfg.ListenAddr), zap.String("listen", cfg.ListenAddr),
@ -1097,6 +1568,9 @@ func run(logger *zap.Logger) error {
) )
}, },
}) })
metricRegistry.AddGaugeProvider(func() []obsmetrics.GaugeSample {
return mtprotoRuntimeGaugeSamples(srv.RuntimeSnapshot())
})
// This is intentionally the final startup operation. ListenAndServe owns the // This is intentionally the final startup operation. ListenAndServe owns the
// public listener so no seed/prewarm work can run after port 2398 is exposed. // public listener so no seed/prewarm work can run after port 2398 is exposed.
return srv.ListenAndServe(ctx, cfg.ListenAddr) return srv.ListenAndServe(ctx, cfg.ListenAddr)

View file

@ -1237,9 +1237,7 @@ CREATE TABLE public.account_passwords (
srp_verifier bytea DEFAULT '\x'::bytea NOT NULL, srp_verifier bytea DEFAULT '\x'::bytea NOT NULL,
srp_b_secret bytea DEFAULT '\x'::bytea NOT NULL, srp_b_secret bytea DEFAULT '\x'::bytea NOT NULL,
srp_b bytea DEFAULT '\x'::bytea NOT NULL, srp_b bytea DEFAULT '\x'::bytea NOT NULL,
recovery_email character varying(256) DEFAULT ''::character varying NOT NULL, recovery_email character varying(256) DEFAULT ''::character varying NOT NULL
recovery_code character varying(32) DEFAULT ''::character varying NOT NULL,
recovery_code_expires_at timestamp with time zone
); );
@ -2892,7 +2890,7 @@ CREATE TABLE public.user_saved_reaction_tags (
created_at timestamp with time zone DEFAULT now() NOT NULL, created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL, updated_at timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT user_saved_reaction_tags_reaction_count_check CHECK ((reaction_count >= 0)), CONSTRAINT user_saved_reaction_tags_reaction_count_check CHECK ((reaction_count >= 0)),
CONSTRAINT user_saved_reaction_tags_reaction_type_check CHECK (((reaction_type)::text = 'emoji'::text)), CONSTRAINT user_saved_reaction_tags_reaction_type_check CHECK (((reaction_type)::text = ANY (ARRAY['emoji'::text, 'custom_emoji'::text]))),
CONSTRAINT user_saved_reaction_tags_reaction_value_check CHECK ((reaction_value <> ''::text)), CONSTRAINT user_saved_reaction_tags_reaction_value_check CHECK ((reaction_value <> ''::text)),
CONSTRAINT user_saved_reaction_tags_title_check CHECK ((char_length(title) <= 12)) CONSTRAINT user_saved_reaction_tags_title_check CHECK ((char_length(title) <= 12))
); );
@ -2948,7 +2946,7 @@ CREATE TABLE public.user_update_events (
story_payload jsonb DEFAULT '{}'::jsonb NOT NULL, story_payload jsonb DEFAULT '{}'::jsonb NOT NULL,
reaction_payload jsonb DEFAULT '{}'::jsonb NOT NULL, reaction_payload jsonb DEFAULT '{}'::jsonb NOT NULL,
CONSTRAINT user_update_events_peer_type_check CHECK (((peer_type IS NULL) OR ((peer_type)::text = ANY (ARRAY[('user'::character varying)::text, ('channel'::character varying)::text])))), CONSTRAINT user_update_events_peer_type_check CHECK (((peer_type IS NULL) OR ((peer_type)::text = ANY (ARRAY[('user'::character varying)::text, ('channel'::character varying)::text])))),
CONSTRAINT user_update_events_type_check CHECK (((event_type)::text = ANY (ARRAY[('new_message'::character varying)::text, ('read_history_inbox'::character varying)::text, ('read_history_outbox'::character varying)::text, ('read_message_contents'::character varying)::text, ('edit_message'::character varying)::text, ('message_reactions'::character varying)::text, ('message_poll'::character varying)::text, ('draft_message'::character varying)::text, ('quick_replies'::character varying)::text, ('new_quick_reply'::character varying)::text, ('delete_quick_reply'::character varying)::text, ('quick_reply_message'::character varying)::text, ('delete_quick_reply_messages'::character varying)::text, ('contacts_reset'::character varying)::text, ('dialog_pinned'::character varying)::text, ('pinned_dialogs'::character varying)::text, ('pinned_messages'::character varying)::text, ('dialog_unread_mark'::character varying)::text, ('peer_settings'::character varying)::text, ('peer_story_blocked'::character varying)::text, ('delete_messages'::character varying)::text, ('dialog_filter'::character varying)::text, ('dialog_filter_order'::character varying)::text, ('dialog_filters'::character varying)::text, ('folder_peers'::character varying)::text, ('channel_available_messages'::character varying)::text, ('channel_view_forum_as_messages'::character varying)::text, ('channel_state'::character varying)::text, ('saved_dialog_pinned'::character varying)::text, ('pinned_saved_dialogs'::character varying)::text, ('story'::character varying)::text, ('read_stories'::character varying)::text, ('sent_story_reaction'::character varying)::text, ('new_story_reaction'::character varying)::text, ('noop'::character varying)::text]))) CONSTRAINT user_update_events_type_check CHECK (((event_type)::text = ANY (ARRAY[('new_message'::character varying)::text, ('read_history_inbox'::character varying)::text, ('read_history_outbox'::character varying)::text, ('read_message_contents'::character varying)::text, ('edit_message'::character varying)::text, ('message_poll'::character varying)::text, ('draft_message'::character varying)::text, ('quick_replies'::character varying)::text, ('new_quick_reply'::character varying)::text, ('delete_quick_reply'::character varying)::text, ('quick_reply_message'::character varying)::text, ('delete_quick_reply_messages'::character varying)::text, ('contacts_reset'::character varying)::text, ('dialog_pinned'::character varying)::text, ('pinned_dialogs'::character varying)::text, ('pinned_messages'::character varying)::text, ('dialog_unread_mark'::character varying)::text, ('peer_settings'::character varying)::text, ('peer_story_blocked'::character varying)::text, ('delete_messages'::character varying)::text, ('dialog_filter'::character varying)::text, ('dialog_filter_order'::character varying)::text, ('dialog_filters'::character varying)::text, ('folder_peers'::character varying)::text, ('channel_view_forum_as_messages'::character varying)::text, ('channel_state'::character varying)::text, ('saved_dialog_pinned'::character varying)::text, ('pinned_saved_dialogs'::character varying)::text, ('story'::character varying)::text, ('read_stories'::character varying)::text, ('sent_story_reaction'::character varying)::text, ('new_story_reaction'::character varying)::text, ('noop'::character varying)::text])))
); );

View file

@ -7,7 +7,7 @@ ALTER TABLE public.user_update_events ADD CONSTRAINT user_update_events_type_che
'new_quick_reply', 'delete_quick_reply', 'quick_reply_message', 'delete_quick_reply_messages', 'new_quick_reply', 'delete_quick_reply', 'quick_reply_message', 'delete_quick_reply_messages',
'contacts_reset', 'dialog_pinned', 'pinned_dialogs', 'pinned_messages', 'dialog_unread_mark', 'contacts_reset', 'dialog_pinned', 'pinned_dialogs', 'pinned_messages', 'dialog_unread_mark',
'peer_settings', 'peer_story_blocked', 'delete_messages', 'dialog_filter', 'dialog_filter_order', 'peer_settings', 'peer_story_blocked', 'delete_messages', 'dialog_filter', 'dialog_filter_order',
'dialog_filters', 'folder_peers', 'channel_available_messages', 'channel_view_forum_as_messages', 'dialog_filters', 'folder_peers', 'channel_view_forum_as_messages',
'channel_state', 'saved_dialog_pinned', 'pinned_saved_dialogs', 'story', 'read_stories', 'channel_state', 'saved_dialog_pinned', 'pinned_saved_dialogs', 'story', 'read_stories',
'sent_story_reaction', 'new_story_reaction', 'noop' 'sent_story_reaction', 'new_story_reaction', 'noop'
]::text[]) ]::text[])

View file

@ -12,7 +12,7 @@ ALTER TABLE public.user_update_events ADD CONSTRAINT user_update_events_type_che
'new_quick_reply', 'delete_quick_reply', 'quick_reply_message', 'delete_quick_reply_messages', 'new_quick_reply', 'delete_quick_reply', 'quick_reply_message', 'delete_quick_reply_messages',
'contacts_reset', 'dialog_pinned', 'pinned_dialogs', 'pinned_messages', 'dialog_unread_mark', 'contacts_reset', 'dialog_pinned', 'pinned_dialogs', 'pinned_messages', 'dialog_unread_mark',
'peer_settings', 'peer_story_blocked', 'delete_messages', 'dialog_filter', 'dialog_filter_order', 'peer_settings', 'peer_story_blocked', 'delete_messages', 'dialog_filter', 'dialog_filter_order',
'dialog_filters', 'folder_peers', 'channel_available_messages', 'channel_view_forum_as_messages', 'dialog_filters', 'folder_peers', 'channel_view_forum_as_messages',
'channel_state', 'saved_dialog_pinned', 'pinned_saved_dialogs', 'story', 'read_stories', 'channel_state', 'saved_dialog_pinned', 'pinned_saved_dialogs', 'story', 'read_stories',
'sent_story_reaction', 'new_story_reaction', 'noop', 'sent_story_reaction', 'new_story_reaction', 'noop',
'read_channel_discussion_inbox', 'read_channel_discussion_outbox' 'read_channel_discussion_inbox', 'read_channel_discussion_outbox'

View file

@ -7,7 +7,7 @@ ALTER TABLE public.user_update_events ADD CONSTRAINT user_update_events_type_che
'new_quick_reply', 'delete_quick_reply', 'quick_reply_message', 'delete_quick_reply_messages', 'new_quick_reply', 'delete_quick_reply', 'quick_reply_message', 'delete_quick_reply_messages',
'contacts_reset', 'dialog_pinned', 'pinned_dialogs', 'pinned_messages', 'dialog_unread_mark', 'contacts_reset', 'dialog_pinned', 'pinned_dialogs', 'pinned_messages', 'dialog_unread_mark',
'peer_settings', 'peer_story_blocked', 'delete_messages', 'dialog_filter', 'dialog_filter_order', 'peer_settings', 'peer_story_blocked', 'delete_messages', 'dialog_filter', 'dialog_filter_order',
'dialog_filters', 'folder_peers', 'channel_available_messages', 'channel_view_forum_as_messages', 'dialog_filters', 'folder_peers', 'channel_view_forum_as_messages',
'channel_state', 'saved_dialog_pinned', 'pinned_saved_dialogs', 'story', 'read_stories', 'channel_state', 'saved_dialog_pinned', 'pinned_saved_dialogs', 'story', 'read_stories',
'sent_story_reaction', 'new_story_reaction', 'noop', 'sent_story_reaction', 'new_story_reaction', 'noop',
'read_channel_discussion_inbox', 'read_channel_discussion_outbox' 'read_channel_discussion_inbox', 'read_channel_discussion_outbox'

View file

@ -10,7 +10,7 @@ ALTER TABLE public.user_update_events ADD CONSTRAINT user_update_events_type_che
'new_quick_reply', 'delete_quick_reply', 'quick_reply_message', 'delete_quick_reply_messages', 'new_quick_reply', 'delete_quick_reply', 'quick_reply_message', 'delete_quick_reply_messages',
'contacts_reset', 'dialog_pinned', 'pinned_dialogs', 'pinned_messages', 'dialog_unread_mark', 'contacts_reset', 'dialog_pinned', 'pinned_dialogs', 'pinned_messages', 'dialog_unread_mark',
'peer_settings', 'peer_story_blocked', 'delete_messages', 'dialog_filter', 'dialog_filter_order', 'peer_settings', 'peer_story_blocked', 'delete_messages', 'dialog_filter', 'dialog_filter_order',
'dialog_filters', 'folder_peers', 'channel_available_messages', 'channel_view_forum_as_messages', 'dialog_filters', 'folder_peers', 'channel_view_forum_as_messages',
'channel_state', 'saved_dialog_pinned', 'pinned_saved_dialogs', 'story', 'read_stories', 'channel_state', 'saved_dialog_pinned', 'pinned_saved_dialogs', 'story', 'read_stories',
'sent_story_reaction', 'new_story_reaction', 'noop', 'sent_story_reaction', 'new_story_reaction', 'noop',
'read_channel_discussion_inbox', 'read_channel_discussion_outbox' 'read_channel_discussion_inbox', 'read_channel_discussion_outbox'

View file

@ -6,7 +6,7 @@ ALTER TABLE public.user_update_events ADD CONSTRAINT user_update_events_type_che
'new_quick_reply', 'delete_quick_reply', 'quick_reply_message', 'delete_quick_reply_messages', 'new_quick_reply', 'delete_quick_reply', 'quick_reply_message', 'delete_quick_reply_messages',
'contacts_reset', 'dialog_pinned', 'pinned_dialogs', 'pinned_messages', 'dialog_unread_mark', 'contacts_reset', 'dialog_pinned', 'pinned_dialogs', 'pinned_messages', 'dialog_unread_mark',
'peer_settings', 'peer_story_blocked', 'delete_messages', 'dialog_filter', 'dialog_filter_order', 'peer_settings', 'peer_story_blocked', 'delete_messages', 'dialog_filter', 'dialog_filter_order',
'dialog_filters', 'folder_peers', 'channel_available_messages', 'channel_view_forum_as_messages', 'dialog_filters', 'folder_peers', 'channel_view_forum_as_messages',
'channel_state', 'saved_dialog_pinned', 'pinned_saved_dialogs', 'story', 'read_stories', 'channel_state', 'saved_dialog_pinned', 'pinned_saved_dialogs', 'story', 'read_stories',
'sent_story_reaction', 'new_story_reaction', 'noop', 'read_channel_discussion_inbox', 'sent_story_reaction', 'new_story_reaction', 'noop', 'read_channel_discussion_inbox',
'read_channel_discussion_outbox' 'read_channel_discussion_outbox'

View file

@ -9,7 +9,7 @@ ALTER TABLE public.user_update_events ADD CONSTRAINT user_update_events_type_che
'new_quick_reply', 'delete_quick_reply', 'quick_reply_message', 'delete_quick_reply_messages', 'new_quick_reply', 'delete_quick_reply', 'quick_reply_message', 'delete_quick_reply_messages',
'contacts_reset', 'dialog_pinned', 'pinned_dialogs', 'pinned_messages', 'dialog_unread_mark', 'contacts_reset', 'dialog_pinned', 'pinned_dialogs', 'pinned_messages', 'dialog_unread_mark',
'peer_settings', 'peer_story_blocked', 'user_phone', 'delete_messages', 'dialog_filter', 'peer_settings', 'peer_story_blocked', 'user_phone', 'delete_messages', 'dialog_filter',
'dialog_filter_order', 'dialog_filters', 'folder_peers', 'channel_available_messages', 'dialog_filter_order', 'dialog_filters', 'folder_peers',
'channel_view_forum_as_messages', 'channel_state', 'saved_dialog_pinned', 'channel_view_forum_as_messages', 'channel_state', 'saved_dialog_pinned',
'pinned_saved_dialogs', 'story', 'read_stories', 'sent_story_reaction', 'pinned_saved_dialogs', 'story', 'read_stories', 'sent_story_reaction',
'new_story_reaction', 'noop', 'read_channel_discussion_inbox', 'new_story_reaction', 'noop', 'read_channel_discussion_inbox',

View file

@ -23,7 +23,7 @@ ALTER TABLE public.user_update_events ADD CONSTRAINT user_update_events_type_che
'new_quick_reply', 'delete_quick_reply', 'quick_reply_message', 'delete_quick_reply_messages', 'new_quick_reply', 'delete_quick_reply', 'quick_reply_message', 'delete_quick_reply_messages',
'contacts_reset', 'dialog_pinned', 'pinned_dialogs', 'pinned_messages', 'dialog_unread_mark', 'contacts_reset', 'dialog_pinned', 'pinned_dialogs', 'pinned_messages', 'dialog_unread_mark',
'peer_settings', 'peer_story_blocked', 'user_phone', 'delete_messages', 'dialog_filter', 'peer_settings', 'peer_story_blocked', 'user_phone', 'delete_messages', 'dialog_filter',
'dialog_filter_order', 'dialog_filters', 'folder_peers', 'channel_available_messages', 'dialog_filter_order', 'dialog_filters', 'folder_peers',
'channel_view_forum_as_messages', 'channel_state', 'saved_dialog_pinned', 'channel_view_forum_as_messages', 'channel_state', 'saved_dialog_pinned',
'pinned_saved_dialogs', 'story', 'read_stories', 'sent_story_reaction', 'pinned_saved_dialogs', 'story', 'read_stories', 'sent_story_reaction',
'new_story_reaction', 'noop', 'read_channel_discussion_inbox', 'new_story_reaction', 'noop', 'read_channel_discussion_inbox',

View file

@ -50,7 +50,7 @@ ALTER TABLE public.user_update_events ADD CONSTRAINT user_update_events_type_che
'contacts_reset', 'dialog_pinned', 'pinned_dialogs', 'pinned_messages', 'dialog_unread_mark', 'contacts_reset', 'dialog_pinned', 'pinned_dialogs', 'pinned_messages', 'dialog_unread_mark',
'peer_settings', 'peer_story_blocked', 'user_phone', 'user_emoji_status', 'delete_messages', 'peer_settings', 'peer_story_blocked', 'user_phone', 'user_emoji_status', 'delete_messages',
'dialog_filter', 'dialog_filter_order', 'dialog_filters', 'folder_peers', 'dialog_filter', 'dialog_filter_order', 'dialog_filters', 'folder_peers',
'channel_available_messages', 'channel_view_forum_as_messages', 'channel_state', 'channel_view_forum_as_messages', 'channel_state',
'saved_dialog_pinned', 'pinned_saved_dialogs', 'story', 'read_stories', 'saved_dialog_pinned', 'pinned_saved_dialogs', 'story', 'read_stories',
'sent_story_reaction', 'new_story_reaction', 'noop', 'sent_story_reaction', 'new_story_reaction', 'noop',
'read_channel_discussion_inbox', 'read_channel_discussion_outbox' 'read_channel_discussion_inbox', 'read_channel_discussion_outbox'

View file

@ -0,0 +1,4 @@
DROP TABLE IF EXISTS public.moderation_legacy_ephemeral_migrations;
DROP TABLE IF EXISTS public.moderation_media_holds;
DROP TABLE IF EXISTS public.moderation_report_items;
DROP TABLE IF EXISTS public.moderation_reports;

View file

@ -0,0 +1,112 @@
-- Unified immutable abuse-report submissions. Operational delivery/read/music
-- telemetry and auth-code delivery diagnostics intentionally use separate
-- tables and retention policies.
CREATE TABLE public.moderation_reports (
id bigserial PRIMARY KEY,
reporter_user_id bigint NOT NULL CHECK (reporter_user_id > 0),
source text NOT NULL CHECK (source IN (
'account_peer', 'profile_photo', 'messages_spam', 'messages',
'encrypted_spam', 'reaction', 'channel_spam', 'story', 'ephemeral',
'sponsored', 'antispam_false_positive'
)),
target_peer_type text NOT NULL CHECK (target_peer_type IN ('user', 'channel')),
target_peer_id bigint NOT NULL CHECK (target_peer_id > 0),
reason text NOT NULL CHECK (reason IN (
'spam', 'violence', 'pornography', 'child_abuse', 'other',
'copyright', 'geo_irrelevant', 'fake', 'illegal_drugs',
'personal_details'
)),
report_option text NOT NULL CHECK (
octet_length(report_option) BETWEEN 1 AND 32
),
report_comment text NOT NULL DEFAULT '' CHECK (
char_length(report_comment) <= 512
),
comment_hash bytea NOT NULL CHECK (octet_length(comment_hash) = 32),
fingerprint bytea NOT NULL CHECK (octet_length(fingerprint) = 32),
taxonomy_version smallint NOT NULL CHECK (taxonomy_version > 0),
created_at timestamptz NOT NULL,
CONSTRAINT moderation_reports_idempotency
UNIQUE (reporter_user_id, fingerprint)
);
CREATE INDEX moderation_reports_target_created_idx
ON public.moderation_reports (
target_peer_type, target_peer_id, created_at DESC, id DESC
);
CREATE INDEX moderation_reports_reporter_created_idx
ON public.moderation_reports (
reporter_user_id, created_at DESC, id DESC
);
CREATE TABLE public.moderation_report_items (
report_id bigint NOT NULL REFERENCES public.moderation_reports(id)
ON DELETE CASCADE,
ordinal smallint NOT NULL CHECK (ordinal BETWEEN 0 AND 99),
item_kind text NOT NULL CHECK (item_kind IN (
'peer', 'message', 'profile_photo', 'reaction', 'story',
'encrypted_chat', 'ephemeral', 'sponsored', 'antispam_decision'
)),
peer_type text NOT NULL CHECK (peer_type IN ('user', 'channel')),
peer_id bigint NOT NULL CHECK (peer_id > 0),
item_id bigint NOT NULL CHECK (item_id > 0),
secondary_id bigint NOT NULL DEFAULT 0 CHECK (secondary_id >= 0),
author_user_id bigint NOT NULL DEFAULT 0 CHECK (author_user_id >= 0),
evidence_schema_version smallint NOT NULL CHECK (
evidence_schema_version > 0
),
evidence jsonb NOT NULL CHECK (
jsonb_typeof(evidence) = 'object'
AND octet_length(evidence::text) <= 1048576
),
evidence_hash bytea NOT NULL CHECK (octet_length(evidence_hash) = 32),
PRIMARY KEY (report_id, ordinal),
CONSTRAINT moderation_report_items_identity
UNIQUE (
report_id, item_kind, peer_type, peer_id, item_id, secondary_id
)
);
CREATE INDEX moderation_report_items_lookup_idx
ON public.moderation_report_items (
item_kind, peer_type, peer_id, item_id, report_id
);
CREATE INDEX moderation_report_items_author_idx
ON public.moderation_report_items (
author_user_id, report_id
)
WHERE author_user_id > 0;
CREATE TABLE public.moderation_media_holds (
report_id bigint NOT NULL,
item_ordinal smallint NOT NULL,
media_kind text NOT NULL CHECK (media_kind IN ('photo', 'document', 'blob')),
storage_key text NOT NULL CHECK (
octet_length(storage_key) BETWEEN 1 AND 512
),
created_at timestamptz NOT NULL,
released_at timestamptz,
PRIMARY KEY (report_id, item_ordinal, media_kind, storage_key),
FOREIGN KEY (report_id, item_ordinal)
REFERENCES public.moderation_report_items(report_id, ordinal)
ON DELETE CASCADE,
CHECK (released_at IS NULL OR released_at >= created_at)
);
CREATE INDEX moderation_media_holds_active_key_idx
ON public.moderation_media_holds (media_kind, storage_key, report_id)
WHERE released_at IS NULL;
-- Crash-safe, one-way provenance for rows written by the pre-unified
-- ephemeral.reportMessage implementation. The legacy table remains immutable
-- until every deployed database has completed the application-level evidence
-- conversion; all new writes go exclusively to moderation_reports.
CREATE TABLE public.moderation_legacy_ephemeral_migrations (
legacy_report_id bigint PRIMARY KEY
REFERENCES public.ephemeral_abuse_reports(id) ON DELETE RESTRICT,
moderation_report_id bigint NOT NULL
REFERENCES public.moderation_reports(id) ON DELETE RESTRICT,
migrated_at timestamptz NOT NULL
);

View file

@ -0,0 +1 @@
DROP TABLE IF EXISTS public.auth_delivery_reports;

View file

@ -0,0 +1,27 @@
-- Authentication-code delivery diagnostics have a separate privacy and
-- retention boundary from abuse moderation. Raw phone numbers, raw
-- phone_code_hash values and authentication codes are never stored here.
CREATE TABLE public.auth_delivery_reports (
id bigserial PRIMARY KEY,
auth_key_id bytea NOT NULL CHECK (octet_length(auth_key_id) = 8),
session_id bigint NOT NULL CHECK (session_id <> 0),
client_type text NOT NULL CHECK (octet_length(client_type) <= 32),
phone_hash bytea NOT NULL CHECK (octet_length(phone_hash) = 32),
code_hash bytea NOT NULL CHECK (octet_length(code_hash) = 32),
issued_user_id bigint NOT NULL CHECK (issued_user_id >= 0),
delivery_id text NOT NULL CHECK (octet_length(delivery_id) <= 128),
channel text NOT NULL CHECK (channel IN ('phone', 'sms')),
mnc text NOT NULL CHECK (
octet_length(mnc) <= 8 AND mnc !~ '[^0-9]'
),
fingerprint bytea NOT NULL CHECK (octet_length(fingerprint) = 32),
created_at timestamptz NOT NULL,
CONSTRAINT auth_delivery_reports_idempotency
UNIQUE (auth_key_id, fingerprint)
);
CREATE INDEX auth_delivery_reports_auth_key_created_idx
ON public.auth_delivery_reports (auth_key_id, created_at DESC, id DESC);
CREATE INDEX auth_delivery_reports_phone_created_idx
ON public.auth_delivery_reports (phone_hash, created_at DESC, id DESC);

View file

@ -0,0 +1,6 @@
DROP TABLE IF EXISTS public.moderation_actions;
DROP TABLE IF EXISTS public.moderation_decisions;
DROP TABLE IF EXISTS public.moderation_appeal_links;
DROP TABLE IF EXISTS public.moderation_appeals;
DROP TABLE IF EXISTS public.moderation_case_reports;
DROP TABLE IF EXISTS public.moderation_cases;

View file

@ -0,0 +1,202 @@
-- Target-grouped moderation work queue. Reports stay immutable; cases,
-- decisions, actions and appeals form a separate optimistic-concurrency state
-- machine.
CREATE TABLE public.moderation_cases (
id bigserial PRIMARY KEY,
target_peer_type text NOT NULL CHECK (target_peer_type IN ('user', 'channel')),
target_peer_id bigint NOT NULL CHECK (target_peer_id > 0),
status text NOT NULL CHECK (status IN (
'open', 'in_review', 'action_pending', 'action_failed', 'resolved',
'dismissed', 'appeal_review'
)),
severity smallint NOT NULL CHECK (severity BETWEEN 1 AND 4),
assigned_to text NOT NULL DEFAULT '' CHECK (octet_length(assigned_to) <= 128),
version bigint NOT NULL DEFAULT 1 CHECK (version > 0),
report_count integer NOT NULL CHECK (report_count > 0),
distinct_reporter_count integer NOT NULL CHECK (
distinct_reporter_count > 0
AND distinct_reporter_count <= report_count
),
first_report_at timestamptz NOT NULL,
last_report_at timestamptz NOT NULL,
created_at timestamptz NOT NULL,
updated_at timestamptz NOT NULL,
CHECK (last_report_at >= first_report_at),
CHECK (updated_at >= created_at)
);
CREATE UNIQUE INDEX moderation_cases_one_active_target_idx
ON public.moderation_cases (target_peer_type, target_peer_id)
WHERE status IN ('open', 'in_review');
CREATE INDEX moderation_cases_queue_idx
ON public.moderation_cases (status, severity DESC, updated_at DESC, id DESC);
CREATE INDEX moderation_cases_assignee_idx
ON public.moderation_cases (assigned_to, status, updated_at DESC, id DESC)
WHERE assigned_to <> '';
CREATE TABLE public.moderation_case_reports (
case_id bigint NOT NULL REFERENCES public.moderation_cases(id)
ON DELETE RESTRICT,
report_id bigint NOT NULL UNIQUE REFERENCES public.moderation_reports(id)
ON DELETE RESTRICT,
attached_at timestamptz NOT NULL,
PRIMARY KEY (case_id, report_id)
);
CREATE INDEX moderation_case_reports_case_idx
ON public.moderation_case_reports (case_id, report_id);
CREATE TABLE public.moderation_decisions (
id bigserial PRIMARY KEY,
case_id bigint NOT NULL REFERENCES public.moderation_cases(id)
ON DELETE RESTRICT,
appeal_id bigint,
kind text NOT NULL CHECK (kind IN (
'no_violation', 'violation', 'appeal_granted', 'appeal_denied'
)),
actor text NOT NULL CHECK (octet_length(actor) BETWEEN 1 AND 128),
reason text NOT NULL CHECK (char_length(reason) BETWEEN 1 AND 2000),
command_id text NOT NULL UNIQUE CHECK (octet_length(command_id) BETWEEN 1 AND 120),
fingerprint bytea NOT NULL CHECK (octet_length(fingerprint) = 32),
created_at timestamptz NOT NULL
);
CREATE INDEX moderation_decisions_case_idx
ON public.moderation_decisions (case_id, created_at, id);
CREATE TABLE public.moderation_actions (
id bigserial PRIMARY KEY,
case_id bigint NOT NULL REFERENCES public.moderation_cases(id)
ON DELETE RESTRICT,
decision_id bigint NOT NULL REFERENCES public.moderation_decisions(id)
ON DELETE RESTRICT,
kind text NOT NULL CHECK (kind IN (
'mark_scam', 'mark_fake', 'clear_peer_flags', 'freeze_account',
'unfreeze_account', 'delete_private_message',
'delete_channel_message', 'delete_account'
)),
payload jsonb NOT NULL CHECK (
jsonb_typeof(payload) = 'object'
AND octet_length(payload::text) <= 65536
),
status text NOT NULL CHECK (status IN (
'pending', 'processing', 'succeeded', 'superseded', 'retry', 'failed'
)),
attempts integer NOT NULL DEFAULT 0 CHECK (attempts BETWEEN 0 AND 20),
available_at timestamptz NOT NULL,
lease_until timestamptz,
last_error text NOT NULL DEFAULT '' CHECK (char_length(last_error) <= 4000),
command_id text NOT NULL UNIQUE CHECK (octet_length(command_id) BETWEEN 1 AND 160),
created_at timestamptz NOT NULL,
updated_at timestamptz NOT NULL,
CHECK (updated_at >= created_at)
);
CREATE INDEX moderation_actions_claim_idx
ON public.moderation_actions (available_at, id)
WHERE status IN ('pending', 'retry', 'processing');
CREATE INDEX moderation_actions_case_idx
ON public.moderation_actions (case_id, id);
CREATE TABLE public.moderation_appeals (
id bigserial PRIMARY KEY,
case_id bigint NOT NULL REFERENCES public.moderation_cases(id)
ON DELETE RESTRICT,
appellant_user_id bigint NOT NULL CHECK (appellant_user_id > 0),
appeal_text text NOT NULL CHECK (char_length(appeal_text) BETWEEN 1 AND 4000),
text_hash bytea NOT NULL CHECK (octet_length(text_hash) = 32),
fingerprint bytea NOT NULL UNIQUE CHECK (octet_length(fingerprint) = 32),
status text NOT NULL CHECK (status IN ('pending', 'granted', 'rejected')),
previous_case_status text NOT NULL CHECK (
previous_case_status IN ('resolved', 'dismissed')
),
reviewer text NOT NULL DEFAULT '' CHECK (octet_length(reviewer) <= 128),
review_reason text NOT NULL DEFAULT '' CHECK (char_length(review_reason) <= 2000),
created_at timestamptz NOT NULL,
reviewed_at timestamptz
);
CREATE UNIQUE INDEX moderation_appeals_one_pending_case_actor_idx
ON public.moderation_appeals (case_id, appellant_user_id)
WHERE status = 'pending';
CREATE INDEX moderation_appeals_queue_idx
ON public.moderation_appeals (status, created_at, id);
CREATE TABLE public.moderation_appeal_links (
id bigserial PRIMARY KEY,
case_id bigint NOT NULL REFERENCES public.moderation_cases(id)
ON DELETE RESTRICT,
appellant_user_id bigint NOT NULL CHECK (appellant_user_id > 0),
token_hash bytea NOT NULL UNIQUE CHECK (octet_length(token_hash) = 32),
expires_at timestamptz NOT NULL,
appeal_id bigint REFERENCES public.moderation_appeals(id)
ON DELETE RESTRICT,
created_at timestamptz NOT NULL,
consumed_at timestamptz,
CHECK (expires_at > created_at),
CHECK (expires_at <= created_at + interval '90 days'),
CHECK (
(appeal_id IS NULL AND consumed_at IS NULL)
OR (appeal_id IS NOT NULL AND consumed_at IS NOT NULL)
)
);
CREATE INDEX moderation_appeal_links_expiry_idx
ON public.moderation_appeal_links (expires_at, id)
WHERE consumed_at IS NULL;
CREATE INDEX moderation_appeal_links_case_idx
ON public.moderation_appeal_links (case_id, id);
ALTER TABLE public.moderation_decisions
ADD CONSTRAINT moderation_decisions_appeal_fk
FOREIGN KEY (appeal_id) REFERENCES public.moderation_appeals(id)
ON DELETE RESTRICT;
CREATE UNIQUE INDEX moderation_decisions_one_per_appeal_idx
ON public.moderation_decisions (appeal_id)
WHERE appeal_id IS NOT NULL;
-- Existing unified reports become one open case per target. This backfill is
-- deterministic and keeps every report linked exactly once.
INSERT INTO public.moderation_cases (
target_peer_type, target_peer_id, status, severity, assigned_to,
version, report_count, distinct_reporter_count, first_report_at,
last_report_at, created_at, updated_at
)
SELECT
target_peer_type,
target_peer_id,
'open',
max(CASE reason
WHEN 'child_abuse' THEN 4
WHEN 'violence' THEN 3
WHEN 'pornography' THEN 3
WHEN 'illegal_drugs' THEN 3
WHEN 'personal_details' THEN 3
WHEN 'fake' THEN 2
WHEN 'copyright' THEN 2
ELSE 1
END)::smallint,
'',
1,
count(*)::integer,
count(DISTINCT reporter_user_id)::integer,
min(created_at),
max(created_at),
min(created_at),
max(created_at)
FROM public.moderation_reports
GROUP BY target_peer_type, target_peer_id;
INSERT INTO public.moderation_case_reports (case_id, report_id, attached_at)
SELECT c.id, r.id, r.created_at
FROM public.moderation_reports r
JOIN public.moderation_cases c
ON c.target_peer_type = r.target_peer_type
AND c.target_peer_id = r.target_peer_id
AND c.status = 'open';

View file

@ -0,0 +1,4 @@
-- Irreversible privacy cleanup: restoring users.phone here would recreate the
-- disclosure this migration removes. Contact relations and all non-phone
-- owner-scoped fields are preserved by the up migration.
SELECT 1;

View file

@ -0,0 +1,15 @@
-- contacts.addContact historically replaced an omitted phone with users.phone.
-- Those rows are indistinguishable from a client-supplied copy of the same
-- number, so privacy-safe repair must treat every exact account-phone copy as
-- ambiguous. The contact relationship and owner-scoped names/notes remain; a
-- later contacts.importContacts sync can explicitly restore a known phone.
--
-- This is a one-time write-path repair. Runtime reads must not normalize or
-- second-guess the bad shape.
UPDATE contacts AS c
SET contact_phone = '',
updated_at = now()
FROM users AS u
WHERE u.id = c.contact_user_id
AND c.contact_phone <> ''
AND c.contact_phone = u.phone;

View file

@ -0,0 +1 @@
DROP TABLE IF EXISTS public.client_telemetry_events;

View file

@ -0,0 +1,30 @@
-- Operational client telemetry is not an abuse-report source. It has its own
-- idempotency/rate-limit indexes and TTL retention boundary.
CREATE TABLE public.client_telemetry_events (
id bigserial PRIMARY KEY,
user_id bigint NOT NULL CHECK (user_id > 0),
kind text NOT NULL CHECK (
kind IN ('message_delivery', 'read_metrics', 'music_listen')
),
peer_type text NOT NULL CHECK (peer_type IN ('', 'user', 'channel')),
peer_id bigint NOT NULL CHECK (
(peer_type = '' AND peer_id = 0)
OR (peer_type <> '' AND peer_id > 0)
),
subject_ids bigint[] NOT NULL CHECK (
cardinality(subject_ids) BETWEEN 1 AND 100
),
payload jsonb NOT NULL CHECK (
jsonb_typeof(payload) = 'object'
AND octet_length(payload::text) <= 65536
),
fingerprint bytea NOT NULL CHECK (octet_length(fingerprint) = 32),
created_at timestamptz NOT NULL,
CONSTRAINT client_telemetry_idempotency UNIQUE (user_id, fingerprint)
);
CREATE INDEX client_telemetry_user_created_idx
ON public.client_telemetry_events (user_id, created_at DESC, id DESC);
CREATE INDEX client_telemetry_retention_idx
ON public.client_telemetry_events (created_at, id);

View file

@ -0,0 +1,2 @@
DROP TABLE IF EXISTS public.channel_antispam_decisions;
DROP TABLE IF EXISTS public.sponsored_message_impressions;

View file

@ -0,0 +1,47 @@
-- Server-issued evidence registries. These prevent arbitrary sponsored IDs or
-- ordinary deleted messages from being accepted as human reports.
CREATE TABLE public.sponsored_message_impressions (
id bigserial PRIMARY KEY,
user_id bigint NOT NULL CHECK (user_id > 0),
random_id_hash bytea NOT NULL CHECK (octet_length(random_id_hash) = 32),
target_peer_type text NOT NULL CHECK (target_peer_type IN ('user', 'channel')),
target_peer_id bigint NOT NULL CHECK (target_peer_id > 0),
author_user_id bigint NOT NULL CHECK (author_user_id >= 0),
evidence_schema_version smallint NOT NULL CHECK (evidence_schema_version > 0),
evidence jsonb NOT NULL CHECK (
jsonb_typeof(evidence) = 'object'
AND octet_length(evidence::text) <= 1048576
),
evidence_hash bytea NOT NULL CHECK (octet_length(evidence_hash) = 32),
report_id bigint UNIQUE REFERENCES public.moderation_reports(id) ON DELETE RESTRICT,
created_at timestamptz NOT NULL,
expires_at timestamptz NOT NULL,
CHECK (expires_at > created_at),
CHECK (expires_at <= created_at + interval '30 days'),
CONSTRAINT sponsored_message_impressions_identity
UNIQUE (user_id, random_id_hash)
);
CREATE INDEX sponsored_message_impressions_expiry_idx
ON public.sponsored_message_impressions (expires_at, id);
CREATE TABLE public.channel_antispam_decisions (
id bigserial PRIMARY KEY,
channel_id bigint NOT NULL CHECK (channel_id > 0),
message_id integer NOT NULL CHECK (message_id > 0),
author_user_id bigint NOT NULL CHECK (author_user_id > 0),
evidence_schema_version smallint NOT NULL CHECK (evidence_schema_version > 0),
evidence jsonb NOT NULL CHECK (
jsonb_typeof(evidence) = 'object'
AND octet_length(evidence::text) <= 1048576
),
evidence_hash bytea NOT NULL CHECK (octet_length(evidence_hash) = 32),
report_id bigint UNIQUE REFERENCES public.moderation_reports(id) ON DELETE RESTRICT,
created_at timestamptz NOT NULL,
CONSTRAINT channel_antispam_decisions_identity
UNIQUE (channel_id, message_id)
);
CREATE INDEX channel_antispam_decisions_unreported_idx
ON public.channel_antispam_decisions (channel_id, created_at DESC, id DESC)
WHERE report_id IS NULL;

View file

@ -0,0 +1 @@
-- Reserved development migration version; up is intentionally a no-op.

View file

@ -0,0 +1,5 @@
-- Reserved development migration version.
-- account privacy is authoritative absolute state: updatePrivacy has no
-- pts/pts_count, so this migration must not add a privacy event type or payload
-- table. The earlier development-only definition was corrected in place
-- because no user/production database can contain that unpublished shape.

View file

@ -0,0 +1,2 @@
DROP TRIGGER IF EXISTS account_settings_read_model_changed ON account_settings;
DROP FUNCTION IF EXISTS telesrv_notify_account_settings_read_model();

Some files were not shown because too many files have changed in this diff Show more