package main import ( "context" "encoding/json" "errors" "fmt" "os" "strconv" "strings" "time" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" "telesrv/internal/domain" ) const ( accountSearchLimit = 20 accountListDefaultLimit = 50 accountListMaxLimit = 100 channelSearchLimit = 50 channelListDefaultLimit = 50 channelListMaxLimit = 100 messagePageLimit = 100 // Collectible username pages. The bounds mirror the use-case layer, so a // table page costs the same whichever surface asks. collectibleListDefaultLimit = 50 collectibleListMaxLimit = 200 collectibleTransferLimit = 50 // Verification review queue pages. The bounds mirror app/verification, so the // panel and the admin API page the queue identically. verificationListDefaultLimit = 50 verificationListMaxLimit = 200 verificationEventLimit = 100 // Third-party bot verification pages. The bounds mirror app/botverification, so // the panel and the admin API page the verifier tables identically. botVerificationListDefaultLimit = 50 botVerificationListMaxLimit = 200 ) // errReadNotFound reports a detail row that does not exist, so the API layer can // answer 404 without importing the driver's sentinel. var errReadNotFound = errors.New("read row not found") // escapeLikePattern neutralises LIKE metacharacters in an operator query. // Usernames legitimately contain '_', so an unescaped search for "crypto_" would // silently match "cryptoX" instead of the name the operator typed. func escapeLikePattern(value string) string { replaced := strings.ReplaceAll(value, `\`, `\\`) replaced = strings.ReplaceAll(replaced, "%", `\%`) return strings.ReplaceAll(replaced, "_", `\_`) } type readStore struct { pool *pgxpool.Pool } func newReadStore(pool *pgxpool.Pool) *readStore { return &readStore{pool: pool} } // AccountUsername is one collectible (Fragment-style) username a peer holds. // // Active mirrors the username#b4073647 flag: an inactive collectible is still // owned, it just does not resolve publicly, and an operator has to be able to // tell those two apart -- so the row is listed either way and carries the flag // rather than being filtered out. type AccountUsername struct { Username string Active bool } type AccountRow struct { ID int64 Phone string Username string FirstName string LastName string // Collectibles are the peer's collectible usernames in the order clients // project them. The editable slot in Username is never repeated here: it is a // different kind of row that a different RPC owns. Collectibles []AccountUsername CreatedAt time.Time UpdatedAt time.Time Frozen bool Reason string Verified bool Scam bool Fake bool PremiumUntil int64 LastActiveAt time.Time DeviceCount int LoginEmail string } // accountCollectibleUsernamesColumn aggregates a peer's collectible usernames // into one jsonb value, so a list page costs one indexed subquery per row instead // of a second round trip per account. // // The object keys are the AccountUsername field names on purpose: pgx unmarshals // jsonb straight into the Go value, and matching the field names keeps the panel's // JSON shape identical to every other field on the row (PascalCase) instead of // introducing one lowercase island. Ordering matches domain.SortUsernames for // collectible rows -- stored order, then the name as a stable tiebreak. const accountCollectibleUsernamesColumn = `COALESCE(( SELECT jsonb_agg(jsonb_build_object('Username', pc.username, 'Active', pc.active) ORDER BY pc.sort_order, pc.username_lower) FROM peer_usernames pc WHERE pc.peer_type = 'user' AND pc.peer_id = u.id AND pc.collectible_id IS NOT NULL ), '[]'::jsonb)` type AccountDetail struct { Account AccountRow About string LastSeenAt int64 Verified bool Scam bool Fake bool Support bool Bot bool Restriction RestrictionRow HasRestriction bool Authorizations []AuthorizationRow AuditLogs []AuditLogRow } type RestrictionRow struct { Frozen bool Since *time.Time Until *time.Time AppealURL string Reason string Actor string CommandID string UpdatedAt time.Time } type AuthorizationRow struct { AuthKeyID int64 Hash int64 Layer int DeviceModel string Platform string SystemVersion string APIID int AppVersion string IP string PasswordPending bool CreatedAt time.Time ActiveAt time.Time } // SharedDeviceAccount is one account whose authorizations matched a // SharedDeviceGroup's device fingerprint. type SharedDeviceAccount struct { UserID int64 Phone string Username string FirstName string LastName string ActiveAt time.Time } // SharedDeviceGroup is a device fingerprint (device_model + system_version + // platform + ip) shared by more than one distinct account's authorizations -- // a heuristic multi-accounting signal, not proof: device_model/system_version // are client-reported and spoofable, and ip alone collides naturally behind // NAT, shared wifi, or carrier CGNAT. Treat this as a lead to investigate, not // a verdict. type SharedDeviceGroup struct { DeviceModel string SystemVersion string Platform string IP string AccountCount int LastActiveAt time.Time Accounts []SharedDeviceAccount } type AuditLogRow struct { ID int64 CommandID string Actor string Action string DryRun bool Reason string Status string Error string Result string CreatedAt time.Time } type ChannelRow struct { ID int64 AccessHash int64 CreatorUserID int64 Title string About string Username string Broadcast bool Megagroup bool Forum bool Monoforum bool Verified bool Scam bool Fake bool Gigagroup bool Deleted bool AntiSpam bool ParticipantsHidden bool NoForwards bool JoinToSend bool JoinRequest bool SlowmodeSeconds int ParticipantsCount int AdminsCount int KickedCount int BannedCount int TopMessageID int PinnedMessageID int PTS int Date int CreatedAt time.Time UpdatedAt time.Time } type ChannelDetail struct { Channel ChannelRow ChannelJSON string AuditLogs []AuditLogRow } type StickerSetRow struct { // ID must round-trip through JSON as a string: these are Telegram-style // snowflake ids (18-19 digits), well past JS's 2^53 safe-integer limit, so // a plain JSON number gets silently rounded by the browser. ID int64 `json:"ID,string"` ShortName string Title string Count int Kind string SystemKey string Official bool Archived bool Installed bool SortOrder int CreatedAt time.Time // CoverDocumentID is the first document in the set, used as a small // thumbnail in the admin list — empty when the set has no documents yet. // Extracted straight from the jsonb array as text (document_ids->>0), so // it never round-trips through a JSON number and risks the same 2^53 // precision loss as ID above. CoverDocumentID string } // ListStickerSets lists non-system sticker/emoji sets. kind filters to exactly // that set_kind ("stickers", "emoji", "masks"); an empty kind lists all of // them (still excluding "system" — dice, animated emoji, premium/TON gifts // and similar built-in packs are never admin-editable). func (s *readStore) ListStickerSets(ctx context.Context, kind string) ([]StickerSetRow, error) { rows, err := s.pool.Query(ctx, ` SELECT id, short_name, title, count, set_kind, system_key, official, archived, installed, sort_order, created_at, COALESCE(document_ids->>0, '') FROM sticker_sets WHERE deleted = false AND set_kind <> 'system' AND ($1 = '' OR set_kind = $1) ORDER BY set_kind, sort_order, id`, kind) if err != nil { return nil, fmt.Errorf("list sticker sets: %w", err) } defer rows.Close() out := make([]StickerSetRow, 0) for rows.Next() { var item StickerSetRow if err := rows.Scan(&item.ID, &item.ShortName, &item.Title, &item.Count, &item.Kind, &item.SystemKey, &item.Official, &item.Archived, &item.Installed, &item.SortOrder, &item.CreatedAt, &item.CoverDocumentID); err != nil { return nil, err } out = append(out, item) } return out, rows.Err() } // GifCatalogRow is one entry of the admin-curated GIF catalog @gif serves for // the client's GIF picker. type GifCatalogRow struct { ID int64 `json:"ID,string"` Title string DocumentID int64 `json:"DocumentID,string"` Enabled bool SortOrder int Category string CreatedBy string CreatedAt time.Time } func (s *readStore) ListGifCatalog(ctx context.Context) ([]GifCatalogRow, error) { rows, err := s.pool.Query(ctx, ` SELECT id, title, document_id, enabled, sort_order, category, created_by, created_at FROM gif_catalog ORDER BY sort_order, id`) if err != nil { return nil, fmt.Errorf("list gif catalog: %w", err) } defer rows.Close() out := make([]GifCatalogRow, 0) for rows.Next() { var item GifCatalogRow if err := rows.Scan(&item.ID, &item.Title, &item.DocumentID, &item.Enabled, &item.SortOrder, &item.Category, &item.CreatedBy, &item.CreatedAt); err != nil { return nil, err } out = append(out, item) } return out, rows.Err() } // StickerSetDocumentIDs returns document ids as strings, not int64 — a plain // JSON number array would let the browser silently round these snowflake ids // past 2^53 (see StickerSetRow.ID for the same issue on the set id itself). func (s *readStore) StickerSetDocumentIDs(ctx context.Context, setID int64) ([]string, error) { // jsonb must be cast to text and scanned as a string — see the identical // ::text-cast pattern in internal/store/postgres/media.go's sticker set // select; scanning jsonb straight into []byte does not reliably decode here. var raw string err := s.pool.QueryRow(ctx, `SELECT document_ids::text FROM sticker_sets WHERE id = $1 AND deleted = false`, setID).Scan(&raw) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, nil } return nil, fmt.Errorf("sticker set documents: %w", err) } var ids []int64 if raw != "" && raw != "[]" { if err := json.Unmarshal([]byte(raw), &ids); err != nil { return nil, fmt.Errorf("sticker set documents: %w", err) } } out := make([]string, len(ids)) for i, id := range ids { out[i] = strconv.FormatInt(id, 10) } return out, nil } // SearchAccounts matches by exact id, phone prefix (uses users_phone_prefix_idx), // or substring on username/display name (uses the users_username_lower_trgm_idx // and users_name_lower_trgm_idx GIN indexes -- those existed unused by this // query before; exact-only matching made "search" effectively useless for // anything but a fully-known id/phone/username). Keyset-paginated exactly // like ListAccounts so results beyond the page limit are reachable instead of // being silently dropped. func (s *readStore) SearchAccounts(ctx context.Context, q string, beforeActiveUS, beforeID int64, limit int) ([]AccountRow, bool, error) { q = strings.TrimSpace(q) if q == "" { return nil, false, nil } if limit <= 0 { limit = accountListDefaultLimit } if limit > accountListMaxLimit { limit = accountListMaxLimit } id := int64(-1) if n, err := strconv.ParseInt(q, 10, 64); err == nil { id = n } phonePrefix := strings.TrimPrefix(strings.ReplaceAll(q, " ", ""), "+") + "%" term := strings.ToLower(strings.TrimPrefix(q, "@")) substring := "%" + term + "%" rows, err := s.pool.Query(ctx, ` WITH auth AS ( SELECT user_id, max(active_at) AS last_active_at, count(*)::int AS device_count FROM authorizations GROUP BY user_id ) SELECT u.id, u.phone, u.username, u.first_name, u.last_name, u.created_at, u.updated_at, COALESCE(r.frozen, false), COALESCE(r.reason, ''), u.verified, u.scam, u.fake, COALESCE(EXTRACT(EPOCH FROM u.premium_expires_at), 0)::bigint, COALESCE(a.last_active_at, '0001-01-01 00:00:00+00'::timestamptz), COALESCE(a.device_count, 0)::int, COALESCE(NULLIF(u.username, ''), p.username_lower, '') AS display_username, COALESCE(ap.login_email, '') FROM users u LEFT JOIN account_restrictions r ON r.user_id = u.id LEFT JOIN peer_usernames p ON p.peer_type = 'user' AND p.peer_id = u.id AND p.editable LEFT JOIN auth a ON a.user_id = u.id LEFT JOIN account_passwords ap ON ap.user_id = u.id WHERE NOT u.is_bot AND ( u.id = $1 OR u.phone LIKE $2 OR lower((u.username)::text) LIKE $3 OR p.username_lower = $4 OR lower(TRIM(BOTH FROM ((u.first_name)::text || ' '::text || (u.last_name)::text))) LIKE $3 OR lower(COALESCE(ap.login_email, '')) LIKE $3 ) AND ($5::bigint = 0 OR (COALESCE(a.last_active_at, '0001-01-01 00:00:00+00'::timestamptz), u.id) < (to_timestamp(($5::double precision) / 1000000.0), $6::bigint)) ORDER BY COALESCE(a.last_active_at, '0001-01-01 00:00:00+00'::timestamptz) DESC, u.id DESC LIMIT $7`, id, phonePrefix, substring, term, beforeActiveUS, beforeID, limit+1) if err != nil { return nil, false, fmt.Errorf("search accounts: %w", err) } defer rows.Close() out := make([]AccountRow, 0, limit+1) for rows.Next() { var item AccountRow if err := rows.Scan(&item.ID, &item.Phone, &item.Username, &item.FirstName, &item.LastName, &item.CreatedAt, &item.UpdatedAt, &item.Frozen, &item.Reason, &item.Verified, &item.Scam, &item.Fake, &item.PremiumUntil, &item.LastActiveAt, &item.DeviceCount, &item.Username, &item.LoginEmail); err != nil { return nil, false, err } out = append(out, item) } if err := rows.Err(); err != nil { return nil, false, err } hasMore := len(out) > limit if hasMore { out = out[:limit] } return out, hasMore, nil } // systemAccountIDs excludes telesrv's built-in accounts (domain.SystemUserByID) // from counts meant to reflect real end users. BotFather/Stickers/ChatBot are // already is_bot=true and excluded that way, but the official 777000 service // account is deliberately NOT flagged is_bot (see domain.OfficialSystemUser) // so it still needs an explicit exclusion here. var systemAccountIDs = []int64{ domain.OfficialSystemUserID, domain.BotFatherUserID, domain.StickersBotUserID, domain.ChatBotUserID, } // CountAccounts returns the total number of real (non-bot, non-system) user accounts. func (s *readStore) CountAccounts(ctx context.Context) (int64, error) { var n int64 if err := s.pool.QueryRow(ctx, ` SELECT count(*) FROM users WHERE NOT is_bot AND id <> ALL($1::bigint[])`, systemAccountIDs).Scan(&n); err != nil { return 0, fmt.Errorf("count accounts: %w", err) } return n, nil } // ListAllAccountIDs resolves a "broadcast to all users" target into an // explicit id list -- the same real-account exclusion CountAccounts uses // (no bots, no built-in system accounts), so a broadcast never targets // @BotFather/@Stickers/@ChatBot or 777000 itself. func (s *readStore) ListAllAccountIDs(ctx context.Context) ([]int64, error) { rows, err := s.pool.Query(ctx, ` SELECT id FROM users WHERE NOT is_bot AND id <> ALL($1::bigint[])`, systemAccountIDs) if err != nil { return nil, fmt.Errorf("list all account ids: %w", err) } defer rows.Close() out := make([]int64, 0, 256) for rows.Next() { var id int64 if err := rows.Scan(&id); err != nil { return nil, fmt.Errorf("scan account id: %w", err) } out = append(out, id) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("iterate account ids: %w", err) } return out, nil } // CountOnlineAccounts counts accounts the live server currently considers // online. Presence itself lives only in the live server's in-process // presenceTracker (internal/rpc/presence.go), unreachable from this // out-of-process admin panel, but that tracker persists users.last_seen_at // continuously (at most every lastSeenPersistDebounce=25s) while a session // stays online -- unlike authorizations.active_at, which is only touched at // new-session/layer-negotiation time and goes stale for a long-lived // connection. last_seen_at within the same userOnlineTTL=5m window the // server itself uses is therefore the closest DB-side proxy available. func (s *readStore) CountOnlineAccounts(ctx context.Context) (int64, error) { var n int64 if err := s.pool.QueryRow(ctx, ` SELECT count(*) FROM users WHERE NOT is_bot AND id <> ALL($1::bigint[]) AND last_seen_at > extract(epoch FROM now() - interval '5 minutes')::bigint`, systemAccountIDs).Scan(&n); err != nil { return 0, fmt.Errorf("count online accounts: %w", err) } return n, nil } // DashboardCounts is the entity/queue counts shown on the admin panel's // overview page. Each field is its own COUNT(*)/GROUP BY against an indexed // column, run sequentially -- this endpoint is opened occasionally by an // operator, not on a request hot path, so the extra round trips cost far // less than the complexity of parallelizing them. type DashboardCounts struct { Users int64 OnlineUsers int64 Bots int64 BroadcastChannels int64 Supergroups int64 StickerSets int64 EmojiSets int64 Gifs int64 PendingReports int64 PendingVerifications int64 } func (s *readStore) DashboardCounts(ctx context.Context) (DashboardCounts, error) { var out DashboardCounts var err error if out.Users, err = s.CountAccounts(ctx); err != nil { return out, err } if out.OnlineUsers, err = s.CountOnlineAccounts(ctx); err != nil { return out, err } if err := s.pool.QueryRow(ctx, ` SELECT count(*) FROM users WHERE is_bot AND deleted_at IS NULL`).Scan(&out.Bots); err != nil { return out, fmt.Errorf("count bots: %w", err) } if err := s.pool.QueryRow(ctx, ` SELECT count(*) FILTER (WHERE broadcast), count(*) FILTER (WHERE megagroup) FROM channels WHERE NOT deleted AND NOT monoforum`).Scan(&out.BroadcastChannels, &out.Supergroups); err != nil { return out, fmt.Errorf("count channels: %w", err) } if err := s.pool.QueryRow(ctx, ` SELECT count(*) FILTER (WHERE set_kind = 'stickers'), count(*) FILTER (WHERE set_kind = 'emoji') FROM sticker_sets WHERE deleted = false`).Scan(&out.StickerSets, &out.EmojiSets); err != nil { return out, fmt.Errorf("count sticker sets: %w", err) } // There's no global GIF catalog -- a GIF is just a document a user saved to // their personal collection (messages.saveGif). This counts distinct // documents saved by anyone, the closest thing to "how many GIFs does this // server know about." if err := s.pool.QueryRow(ctx, ` SELECT count(DISTINCT document_id) FROM user_sticker_collections WHERE kind = 'gif'`).Scan(&out.Gifs); err != nil { return out, fmt.Errorf("count gifs: %w", err) } if err := s.pool.QueryRow(ctx, ` SELECT count(*) FROM moderation_cases WHERE status NOT IN ('resolved', 'dismissed')`).Scan(&out.PendingReports); err != nil { return out, fmt.Errorf("count pending moderation cases: %w", err) } if err := s.pool.QueryRow(ctx, ` SELECT count(*) FROM verification_applications WHERE status IN ('submitted', 'in_review')`).Scan(&out.PendingVerifications); err != nil { return out, fmt.Errorf("count pending verification applications: %w", err) } return out, nil } type BotRow struct { ID int64 Username string FirstName string Verified bool Scam bool Fake bool System bool OwnerUserID int64 CreatedAt time.Time UpdatedAt time.Time } type BotDetail struct { Bot BotRow About string Description string OwnerUsername string AuditLogs []AuditLogRow } // ListBots pages over live bot accounts (users.is_bot, not tombstoned) by // descending id. Bots are excluded from ListAccounts, so this is the dedicated // projection for them. func (s *readStore) ListBots(ctx context.Context, beforeID int64, limit int) ([]BotRow, bool, error) { if limit <= 0 { limit = accountListDefaultLimit } if limit > accountListMaxLimit { limit = accountListMaxLimit } rows, err := s.pool.Query(ctx, ` SELECT u.id, COALESCE(NULLIF(u.username, ''), p.username_lower, ''), u.first_name, u.verified, u.scam, u.fake, COALESCE(b.owner_user_id, 0), u.created_at, u.updated_at FROM users u LEFT JOIN bots b ON b.bot_user_id = u.id LEFT JOIN peer_usernames p ON p.peer_type = 'user' AND p.peer_id = u.id AND p.editable WHERE u.is_bot AND u.deleted_at IS NULL AND ($1::bigint = 0 OR u.id < $1) ORDER BY u.id DESC LIMIT $2`, beforeID, limit+1) if err != nil { return nil, false, fmt.Errorf("list bots: %w", err) } defer rows.Close() out := make([]BotRow, 0, limit+1) for rows.Next() { var item BotRow if err := rows.Scan(&item.ID, &item.Username, &item.FirstName, &item.Verified, &item.Scam, &item.Fake, &item.OwnerUserID, &item.CreatedAt, &item.UpdatedAt); err != nil { return nil, false, err } item.System = domain.IsSystemUserID(item.ID) out = append(out, item) } if err := rows.Err(); err != nil { return nil, false, err } hasMore := len(out) > limit if hasMore { out = out[:limit] } return out, hasMore, nil } func (s *readStore) SearchBots(ctx context.Context, q string) ([]BotRow, error) { q = strings.TrimSpace(q) if q == "" { return nil, nil } id := int64(-1) if n, err := strconv.ParseInt(q, 10, 64); err == nil { id = n } username := strings.ToLower(strings.TrimPrefix(q, "@")) rows, err := s.pool.Query(ctx, ` SELECT u.id, COALESCE(NULLIF(u.username, ''), p.username_lower, ''), u.first_name, u.verified, u.scam, u.fake, COALESCE(b.owner_user_id, 0), u.created_at, u.updated_at FROM users u LEFT JOIN bots b ON b.bot_user_id = u.id LEFT JOIN peer_usernames p ON p.peer_type = 'user' AND p.peer_id = u.id AND p.editable WHERE u.is_bot AND u.deleted_at IS NULL AND (u.id = $1 OR lower(u.username) = $2 OR p.username_lower = $2) ORDER BY u.id DESC LIMIT $3`, id, username, accountSearchLimit) if err != nil { return nil, fmt.Errorf("search bots: %w", err) } defer rows.Close() out := make([]BotRow, 0) for rows.Next() { var item BotRow if err := rows.Scan(&item.ID, &item.Username, &item.FirstName, &item.Verified, &item.Scam, &item.Fake, &item.OwnerUserID, &item.CreatedAt, &item.UpdatedAt); err != nil { return nil, err } item.System = domain.IsSystemUserID(item.ID) out = append(out, item) } return out, rows.Err() } func (s *readStore) BotDetail(ctx context.Context, botUserID int64) (BotDetail, error) { var out BotDetail err := s.pool.QueryRow(ctx, ` SELECT u.id, COALESCE(NULLIF(u.username, ''), p.username_lower, ''), u.first_name, u.about, u.verified, u.scam, u.fake, COALESCE(b.owner_user_id, 0), COALESCE(b.description, ''), u.created_at, u.updated_at FROM users u LEFT JOIN bots b ON b.bot_user_id = u.id LEFT JOIN peer_usernames p ON p.peer_type = 'user' AND p.peer_id = u.id AND p.editable WHERE u.id = $1 AND u.is_bot AND u.deleted_at IS NULL`, botUserID).Scan( &out.Bot.ID, &out.Bot.Username, &out.Bot.FirstName, &out.About, &out.Bot.Verified, &out.Bot.Scam, &out.Bot.Fake, &out.Bot.OwnerUserID, &out.Description, &out.Bot.CreatedAt, &out.Bot.UpdatedAt, ) if err != nil { return out, fmt.Errorf("get bot: %w", err) } out.Bot.System = domain.IsSystemUserID(out.Bot.ID) if out.Bot.OwnerUserID > 0 { var ownerUsername string if err := s.pool.QueryRow(ctx, ` SELECT COALESCE(NULLIF(u.username, ''), p.username_lower, '') FROM users u LEFT JOIN peer_usernames p ON p.peer_type = 'user' AND p.peer_id = u.id AND p.editable WHERE u.id = $1`, out.Bot.OwnerUserID).Scan(&ownerUsername); err != nil && err != pgx.ErrNoRows { return out, fmt.Errorf("get bot owner: %w", err) } else { out.OwnerUsername = ownerUsername } } out.AuditLogs, err = s.auditLogs(ctx, botUserID) if err != nil { return out, err } return out, nil } func (s *readStore) SearchChannels(ctx context.Context, q string) ([]ChannelRow, error) { q = strings.TrimSpace(q) if q == "" { return nil, nil } id := int64(-1) if n, err := strconv.ParseInt(q, 10, 64); err == nil { id = n } username := strings.ToLower(strings.TrimPrefix(q, "@")) rows, err := s.pool.Query(ctx, ` SELECT c.id, c.access_hash, c.creator_user_id, c.title, c.about, COALESCE(NULLIF(c.username, ''), p.username_lower, '') AS display_username, c.broadcast, c.megagroup, c.forum, c.monoforum, c.verified, c.scam, c.fake, c.gigagroup, c.deleted, c.antispam, c.participants_hidden, c.noforwards, c.join_to_send, c.join_request, c.slowmode_seconds, c.participants_count, c.admins_count, c.kicked_count, c.banned_count, c.top_message_id, c.pinned_message_id, c.pts, c.date, c.created_at, c.updated_at FROM channels c LEFT JOIN peer_usernames p ON p.peer_type = 'channel' AND p.peer_id = c.id AND p.editable WHERE NOT c.deleted AND NOT c.monoforum AND (c.broadcast OR c.megagroup) AND ( c.id = $1 OR lower(COALESCE(c.username, '')) = $2 OR p.username_lower = $2 OR lower(c.title) LIKE '%' || $2 || '%' ) ORDER BY c.updated_at DESC, c.id DESC LIMIT $3`, id, username, channelSearchLimit) if err != nil { return nil, fmt.Errorf("search channels: %w", err) } defer rows.Close() return scanChannelRows(rows) } func (s *readStore) ListChannels(ctx context.Context, beforeUpdatedUS, beforeID int64, limit int) ([]ChannelRow, bool, error) { if limit <= 0 { limit = channelListDefaultLimit } if limit > channelListMaxLimit { limit = channelListMaxLimit } rows, err := s.pool.Query(ctx, ` SELECT c.id, c.access_hash, c.creator_user_id, c.title, c.about, COALESCE(NULLIF(c.username, ''), p.username_lower, '') AS display_username, c.broadcast, c.megagroup, c.forum, c.monoforum, c.verified, c.scam, c.fake, c.gigagroup, c.deleted, c.antispam, c.participants_hidden, c.noforwards, c.join_to_send, c.join_request, c.slowmode_seconds, c.participants_count, c.admins_count, c.kicked_count, c.banned_count, c.top_message_id, c.pinned_message_id, c.pts, c.date, c.created_at, c.updated_at FROM channels c LEFT JOIN peer_usernames p ON p.peer_type = 'channel' AND p.peer_id = c.id AND p.editable WHERE NOT c.deleted AND NOT c.monoforum AND (c.broadcast OR c.megagroup) AND ($1::bigint = 0 OR (c.updated_at, c.id) < (to_timestamp(($1::double precision) / 1000000.0), $2::bigint)) ORDER BY c.updated_at DESC, c.id DESC LIMIT $3`, beforeUpdatedUS, beforeID, limit+1) if err != nil { return nil, false, fmt.Errorf("list channels: %w", err) } defer rows.Close() out, err := scanChannelRows(rows) if err != nil { return nil, false, err } hasMore := len(out) > limit if hasMore { out = out[:limit] } return out, hasMore, nil } func (s *readStore) ChannelDetail(ctx context.Context, channelID int64) (ChannelDetail, error) { var out ChannelDetail var raw []byte err := s.pool.QueryRow(ctx, ` SELECT c.id, c.access_hash, c.creator_user_id, c.title, c.about, COALESCE(NULLIF(c.username, ''), p.username_lower, '') AS display_username, c.broadcast, c.megagroup, c.forum, c.monoforum, c.verified, c.scam, c.fake, c.gigagroup, c.deleted, c.antispam, c.participants_hidden, c.noforwards, c.join_to_send, c.join_request, c.slowmode_seconds, c.participants_count, c.admins_count, c.kicked_count, c.banned_count, c.top_message_id, c.pinned_message_id, c.pts, c.date, c.created_at, c.updated_at, row_to_json(c)::jsonb FROM channels c LEFT JOIN peer_usernames p ON p.peer_type = 'channel' AND p.peer_id = c.id AND p.editable WHERE c.id = $1 AND NOT c.deleted AND NOT c.monoforum AND (c.broadcast OR c.megagroup)`, channelID).Scan(channelScanDestWithRaw(&out.Channel, &raw)...) if err != nil { return out, fmt.Errorf("get channel: %w", err) } out.ChannelJSON = prettyJSON(raw) out.AuditLogs, err = s.channelAuditLogs(ctx, channelID) if err != nil { return out, err } return out, nil } type channelScanner interface { Scan(dest ...any) error } func scanChannelRows(rows pgx.Rows) ([]ChannelRow, error) { out := make([]ChannelRow, 0) for rows.Next() { var item ChannelRow if err := scanChannelRow(rows, &item); err != nil { return nil, err } out = append(out, item) } return out, rows.Err() } func scanChannelRow(row channelScanner, item *ChannelRow) error { return row.Scan(channelScanDest(item)...) } func channelScanDest(item *ChannelRow) []any { return []any{ &item.ID, &item.AccessHash, &item.CreatorUserID, &item.Title, &item.About, &item.Username, &item.Broadcast, &item.Megagroup, &item.Forum, &item.Monoforum, &item.Verified, &item.Scam, &item.Fake, &item.Gigagroup, &item.Deleted, &item.AntiSpam, &item.ParticipantsHidden, &item.NoForwards, &item.JoinToSend, &item.JoinRequest, &item.SlowmodeSeconds, &item.ParticipantsCount, &item.AdminsCount, &item.KickedCount, &item.BannedCount, &item.TopMessageID, &item.PinnedMessageID, &item.PTS, &item.Date, &item.CreatedAt, &item.UpdatedAt, } } func channelScanDestWithRaw(item *ChannelRow, raw *[]byte) []any { dest := channelScanDest(item) return append(dest, raw) } func (s *readStore) ListAccounts(ctx context.Context, beforeActiveUS, beforeID int64, limit int) ([]AccountRow, bool, error) { if limit <= 0 { limit = accountListDefaultLimit } if limit > accountListMaxLimit { limit = accountListMaxLimit } rows, err := s.pool.Query(ctx, ` WITH auth AS ( SELECT user_id, max(active_at) AS last_active_at, count(*)::int AS device_count FROM authorizations GROUP BY user_id ) SELECT u.id, u.phone, u.username, u.first_name, u.last_name, u.created_at, u.updated_at, COALESCE(r.frozen, false), COALESCE(r.reason, ''), u.verified, u.scam, u.fake, COALESCE(EXTRACT(EPOCH FROM u.premium_expires_at), 0)::bigint, COALESCE(auth.last_active_at, '0001-01-01 00:00:00+00'::timestamptz), COALESCE(auth.device_count, 0)::int, COALESCE(NULLIF(u.username, ''), p.username_lower, '') AS display_username, COALESCE(ap.login_email, ''), `+accountCollectibleUsernamesColumn+` AS collectibles FROM users u -- LEFT JOIN, not JOIN: an account with no authorizations (never finished login, -- all sessions revoked, frozen-then-unfrozen) must still appear here, matching -- CountAccounts and SearchAccounts. LEFT JOIN auth ON auth.user_id = u.id LEFT JOIN account_restrictions r ON r.user_id = u.id LEFT JOIN peer_usernames p ON p.peer_type = 'user' AND p.peer_id = u.id AND p.editable LEFT JOIN account_passwords ap ON ap.user_id = u.id WHERE NOT u.is_bot AND ($1::bigint = 0 OR (COALESCE(auth.last_active_at, '0001-01-01 00:00:00+00'::timestamptz), u.id) < (to_timestamp(($1::double precision) / 1000000.0), $2::bigint)) ORDER BY COALESCE(auth.last_active_at, '0001-01-01 00:00:00+00'::timestamptz) DESC, u.id DESC LIMIT $3`, beforeActiveUS, beforeID, limit+1) if err != nil { return nil, false, fmt.Errorf("list accounts: %w", err) } defer rows.Close() out := make([]AccountRow, 0, limit+1) for rows.Next() { var item AccountRow if err := rows.Scan(&item.ID, &item.Phone, &item.Username, &item.FirstName, &item.LastName, &item.CreatedAt, &item.UpdatedAt, &item.Frozen, &item.Reason, &item.Verified, &item.Scam, &item.Fake, &item.PremiumUntil, &item.LastActiveAt, &item.DeviceCount, &item.Username, &item.LoginEmail, &item.Collectibles); err != nil { return nil, false, err } out = append(out, item) } if err := rows.Err(); err != nil { return nil, false, err } hasMore := len(out) > limit if hasMore { out = out[:limit] } return out, hasMore, nil } // ListSharedDeviceGroups pages through device fingerprints (device_model + // system_version + platform + ip) that more than one distinct account has // authorized from -- see SharedDeviceGroup's doc comment on why this is a // heuristic, not a verdict. Pagination is plain offset/limit over the // aggregated group list (not the raw authorizations table): the number of // *groups* is expected to stay small relative to total session count, so an // offset scan over the pre-aggregated CTE is cheap even though offset // pagination over raw rows elsewhere in this file deliberately uses a keyset // cursor instead. func (s *readStore) ListSharedDeviceGroups(ctx context.Context, offset, limit int) ([]SharedDeviceGroup, bool, error) { if limit <= 0 { limit = accountListDefaultLimit } if limit > accountListMaxLimit { limit = accountListMaxLimit } if offset < 0 { offset = 0 } rows, err := s.pool.Query(ctx, ` WITH device_groups AS ( SELECT device_model, system_version, platform, ip, count(DISTINCT user_id)::int AS account_count, max(active_at) AS last_active_at FROM authorizations WHERE device_model <> '' GROUP BY device_model, system_version, platform, ip HAVING count(DISTINCT user_id) > 1 ORDER BY max(active_at) DESC, device_model, system_version, platform, ip LIMIT $1 OFFSET $2 ), members AS ( SELECT DISTINCT ON (a.device_model, a.system_version, a.platform, a.ip, a.user_id) a.device_model, a.system_version, a.platform, a.ip, a.user_id, a.active_at FROM authorizations a JOIN device_groups g ON a.device_model = g.device_model AND a.system_version = g.system_version AND a.platform = g.platform AND a.ip = g.ip ORDER BY a.device_model, a.system_version, a.platform, a.ip, a.user_id, a.active_at DESC ) SELECT g.device_model, g.system_version, g.platform, g.ip, g.account_count, g.last_active_at, m.user_id, m.active_at, u.phone, u.username, u.first_name, u.last_name FROM device_groups g JOIN members m ON m.device_model = g.device_model AND m.system_version = g.system_version AND m.platform = g.platform AND m.ip = g.ip JOIN users u ON u.id = m.user_id ORDER BY g.last_active_at DESC, g.device_model, g.system_version, g.platform, g.ip, m.active_at DESC`, limit+1, offset) if err != nil { return nil, false, fmt.Errorf("list shared device groups: %w", err) } defer rows.Close() groups := make([]SharedDeviceGroup, 0, limit+1) for rows.Next() { var ( deviceModel, systemVersion, platform, ip string accountCount int lastActiveAt time.Time acc SharedDeviceAccount ) if err := rows.Scan(&deviceModel, &systemVersion, &platform, &ip, &accountCount, &lastActiveAt, &acc.UserID, &acc.ActiveAt, &acc.Phone, &acc.Username, &acc.FirstName, &acc.LastName); err != nil { return nil, false, err } if len(groups) == 0 { groups = append(groups, SharedDeviceGroup{DeviceModel: deviceModel, SystemVersion: systemVersion, Platform: platform, IP: ip, AccountCount: accountCount, LastActiveAt: lastActiveAt}) } else if last := &groups[len(groups)-1]; last.DeviceModel != deviceModel || last.SystemVersion != systemVersion || last.Platform != platform || last.IP != ip { groups = append(groups, SharedDeviceGroup{DeviceModel: deviceModel, SystemVersion: systemVersion, Platform: platform, IP: ip, AccountCount: accountCount, LastActiveAt: lastActiveAt}) } last := &groups[len(groups)-1] last.Accounts = append(last.Accounts, acc) } if err := rows.Err(); err != nil { return nil, false, err } hasMore := len(groups) > limit if hasMore { groups = groups[:limit] } return groups, hasMore, nil } // BroadcastRow is one system-broadcast campaign, with sent/failed counts // derived live from broadcast_recipients (never stored, so they can't drift). type BroadcastRow struct { ID int64 Message string TargetMode string TotalCount int SentCount int FailedCount int CreatedBy string CreatedAt time.Time } const broadcastRowColumns = ` b.id, b.message, b.target_mode, b.total_count, b.created_by, b.created_at, count(*) FILTER (WHERE r.status = 'sent')::int AS sent_count, count(*) FILTER (WHERE r.status = 'failed')::int AS failed_count` func scanBroadcastRow(row interface{ Scan(...any) error }, item *BroadcastRow) error { return row.Scan(&item.ID, &item.Message, &item.TargetMode, &item.TotalCount, &item.CreatedBy, &item.CreatedAt, &item.SentCount, &item.FailedCount) } // ListBroadcasts pages campaigns newest-first. func (s *readStore) ListBroadcasts(ctx context.Context, beforeID int64, limit int) ([]BroadcastRow, bool, error) { if limit <= 0 || limit > 200 { limit = 50 } rows, err := s.pool.Query(ctx, ` SELECT `+broadcastRowColumns+` FROM broadcasts b LEFT JOIN broadcast_recipients r ON r.broadcast_id = b.id WHERE $1::bigint = 0 OR b.id < $1 GROUP BY b.id ORDER BY b.id DESC LIMIT $2`, beforeID, limit+1) if err != nil { return nil, false, fmt.Errorf("list broadcasts: %w", err) } defer rows.Close() out := make([]BroadcastRow, 0, limit+1) for rows.Next() { var item BroadcastRow if err := scanBroadcastRow(rows, &item); err != nil { return nil, false, fmt.Errorf("scan broadcast: %w", err) } out = append(out, item) } if err := rows.Err(); err != nil { return nil, false, fmt.Errorf("iterate broadcasts: %w", err) } hasMore := len(out) > limit if hasMore { out = out[:limit] } return out, hasMore, nil } func (s *readStore) AccountDetail(ctx context.Context, userID int64) (AccountDetail, error) { var out AccountDetail err := s.pool.QueryRow(ctx, ` SELECT u.id, u.phone, u.username, u.first_name, u.last_name, u.created_at, u.updated_at, u.about, u.last_seen_at, u.verified, u.scam, u.fake, u.support, u.is_bot, COALESCE(r.frozen, false), COALESCE(r.reason, ''), COALESCE(EXTRACT(EPOCH FROM u.premium_expires_at), 0)::bigint, COALESCE(NULLIF(u.username, ''), p.username_lower, '') AS display_username, `+accountCollectibleUsernamesColumn+` AS collectibles FROM users u LEFT JOIN account_restrictions r ON r.user_id = u.id LEFT JOIN peer_usernames p ON p.peer_type = 'user' AND p.peer_id = u.id AND p.editable WHERE u.id = $1`, userID).Scan( &out.Account.ID, &out.Account.Phone, &out.Account.Username, &out.Account.FirstName, &out.Account.LastName, &out.Account.CreatedAt, &out.Account.UpdatedAt, &out.About, &out.LastSeenAt, &out.Verified, &out.Scam, &out.Fake, &out.Support, &out.Bot, &out.Account.Frozen, &out.Account.Reason, &out.Account.PremiumUntil, &out.Account.Username, &out.Account.Collectibles, ) if err != nil { return out, fmt.Errorf("get account: %w", err) } out.Restriction, out.HasRestriction, err = s.restriction(ctx, userID) if err != nil { return out, err } out.Authorizations, err = s.authorizations(ctx, userID) if err != nil { return out, err } out.AuditLogs, err = s.auditLogs(ctx, userID) if err != nil { return out, err } return out, nil } func (s *readStore) restriction(ctx context.Context, userID int64) (RestrictionRow, bool, error) { var r RestrictionRow err := s.pool.QueryRow(ctx, ` SELECT frozen, frozen_since, frozen_until, appeal_url, reason, actor, command_id, updated_at FROM account_restrictions WHERE user_id = $1`, userID).Scan( &r.Frozen, &r.Since, &r.Until, &r.AppealURL, &r.Reason, &r.Actor, &r.CommandID, &r.UpdatedAt, ) if err != nil { if err == pgx.ErrNoRows { return RestrictionRow{}, false, nil } return RestrictionRow{}, false, fmt.Errorf("get restriction: %w", err) } return r, true, nil } func (s *readStore) authorizations(ctx context.Context, userID int64) ([]AuthorizationRow, error) { rows, err := s.pool.Query(ctx, ` SELECT auth_key_id, hash, layer, device_model, platform, system_version, api_id, app_version, ip, password_pending, created_at, active_at FROM authorizations WHERE user_id = $1 ORDER BY active_at DESC, created_at DESC LIMIT 100`, userID) if err != nil { return nil, fmt.Errorf("list authorizations: %w", err) } defer rows.Close() out := make([]AuthorizationRow, 0) for rows.Next() { var a AuthorizationRow if err := rows.Scan(&a.AuthKeyID, &a.Hash, &a.Layer, &a.DeviceModel, &a.Platform, &a.SystemVersion, &a.APIID, &a.AppVersion, &a.IP, &a.PasswordPending, &a.CreatedAt, &a.ActiveAt); err != nil { return nil, err } out = append(out, a) } return out, rows.Err() } func (s *readStore) auditLogs(ctx context.Context, userID int64) ([]AuditLogRow, error) { rows, err := s.pool.Query(ctx, ` SELECT id, command_id, actor, action, dry_run, reason, status, error, result, created_at FROM admin_audit_logs WHERE target_user_id = $1 ORDER BY id DESC LIMIT 30`, userID) if err != nil { return nil, fmt.Errorf("list audit logs: %w", err) } defer rows.Close() out := make([]AuditLogRow, 0) for rows.Next() { var a AuditLogRow var result []byte if err := rows.Scan(&a.ID, &a.CommandID, &a.Actor, &a.Action, &a.DryRun, &a.Reason, &a.Status, &a.Error, &result, &a.CreatedAt); err != nil { return nil, err } a.Result = prettyJSON(result) out = append(out, a) } return out, rows.Err() } func (s *readStore) channelAuditLogs(ctx context.Context, channelID int64) ([]AuditLogRow, error) { rows, err := s.pool.Query(ctx, ` SELECT id, command_id, actor, action, dry_run, reason, status, error, result, created_at FROM admin_audit_logs WHERE target_peer_type = 'channel' AND target_peer_id = $1 ORDER BY id DESC LIMIT 30`, channelID) if err != nil { return nil, fmt.Errorf("list channel audit logs: %w", err) } defer rows.Close() out := make([]AuditLogRow, 0) for rows.Next() { var a AuditLogRow var result []byte if err := rows.Scan(&a.ID, &a.CommandID, &a.Actor, &a.Action, &a.DryRun, &a.Reason, &a.Status, &a.Error, &result, &a.CreatedAt); err != nil { return nil, err } a.Result = prettyJSON(result) out = append(out, a) } return out, rows.Err() } type MessageRow struct { OwnerUserID int64 BoxID int PrivateMessageID int64 MessageSenderID int64 PeerID int64 FromUserID int64 Date int64 Outgoing bool Body string PTS int Deleted bool Media string } type GroupMessageRow struct { ChannelID int64 ID int SenderUserID int64 FromPeerType string FromPeerID int64 Date int64 Post bool Body string PTS int Deleted bool Media string ViewsCount int EditDate int Pinned bool } func (s *readStore) ListMessages(ctx context.Context, ownerUserID, peerID int64, beforeDate int64, beforeID int, limit int) ([]MessageRow, error) { if limit <= 0 || limit > messagePageLimit { limit = messagePageLimit } rows, err := s.pool.Query(ctx, ` SELECT owner_user_id, box_id, private_message_id, message_sender_id, peer_id, from_user_id, message_date, outgoing, body, pts, deleted, COALESCE(media, '{}'::jsonb) FROM message_boxes WHERE owner_user_id = $1 AND peer_type = 'user' AND peer_id = $2 AND ($3::bigint = 0 OR (message_date, box_id) < ($3::bigint, $4::int)) ORDER BY message_date DESC, box_id DESC LIMIT $5`, ownerUserID, peerID, beforeDate, beforeID, limit) if err != nil { return nil, fmt.Errorf("list messages: %w", err) } defer rows.Close() out := make([]MessageRow, 0) for rows.Next() { var item MessageRow var media []byte if err := rows.Scan(&item.OwnerUserID, &item.BoxID, &item.PrivateMessageID, &item.MessageSenderID, &item.PeerID, &item.FromUserID, &item.Date, &item.Outgoing, &item.Body, &item.PTS, &item.Deleted, &media); err != nil { return nil, err } item.Media = prettyJSON(media) out = append(out, item) } return out, rows.Err() } func (s *readStore) ListGroupMessages(ctx context.Context, channelID int64, beforeDate int64, beforeID int, limit int) ([]GroupMessageRow, error) { if limit <= 0 || limit > messagePageLimit { limit = messagePageLimit } rows, err := s.pool.Query(ctx, ` SELECT channel_id, id, sender_user_id, from_peer_type, from_peer_id, message_date, post, body, pts, deleted, COALESCE(media, '{}'::jsonb), views_count, edit_date, pinned FROM channel_messages WHERE channel_id = $1 AND NOT deleted AND ($2::bigint = 0 OR (message_date, id) < ($2::bigint, $3::int)) ORDER BY message_date DESC, id DESC LIMIT $4`, channelID, beforeDate, beforeID, limit) if err != nil { return nil, fmt.Errorf("list group messages: %w", err) } defer rows.Close() out := make([]GroupMessageRow, 0) for rows.Next() { var item GroupMessageRow var media []byte if err := rows.Scan(&item.ChannelID, &item.ID, &item.SenderUserID, &item.FromPeerType, &item.FromPeerID, &item.Date, &item.Post, &item.Body, &item.PTS, &item.Deleted, &media, &item.ViewsCount, &item.EditDate, &item.Pinned); err != nil { return nil, err } item.Media = prettyJSON(media) out = append(out, item) } return out, rows.Err() } type MessageDetail struct { Message MessageRow MessageJSON string DialogJSON string PrivateJSON string UpdateEvents []UpdateEventRow Outbox []OutboxRow } type GroupMessageDetail struct { Message GroupMessageRow MessageJSON string ChannelJSON string UpdateEvents []ChannelUpdateEventRow } type UpdateEventRow struct { PTS int PTSCount int Type string Date int64 JSON string } type ChannelUpdateEventRow struct { PTS int PTSCount int Type string MessageID int Date int64 SenderUserID int64 JSON string } type OutboxRow struct { ID int64 TargetUserID int64 PTS int EventType string Status string Attempts int CreatedAt time.Time UpdatedAt time.Time } func (s *readStore) MessageDetail(ctx context.Context, ownerUserID int64, msgID int) (MessageDetail, error) { var out MessageDetail var media []byte var messageJSON []byte err := s.pool.QueryRow(ctx, ` SELECT owner_user_id, box_id, private_message_id, message_sender_id, peer_id, from_user_id, message_date, outgoing, body, pts, deleted, COALESCE(media, '{}'::jsonb), row_to_json(mb)::jsonb FROM message_boxes mb WHERE owner_user_id = $1 AND box_id = $2`, ownerUserID, msgID).Scan( &out.Message.OwnerUserID, &out.Message.BoxID, &out.Message.PrivateMessageID, &out.Message.MessageSenderID, &out.Message.PeerID, &out.Message.FromUserID, &out.Message.Date, &out.Message.Outgoing, &out.Message.Body, &out.Message.PTS, &out.Message.Deleted, &media, &messageJSON, ) if err != nil { return out, fmt.Errorf("get message: %w", err) } out.Message.Media = prettyJSON(media) out.MessageJSON = prettyJSON(messageJSON) out.DialogJSON, _ = s.rowJSON(ctx, `SELECT row_to_json(d)::jsonb FROM dialogs d WHERE user_id = $1 AND peer_type = 'user' AND peer_id = $2`, ownerUserID, out.Message.PeerID) out.PrivateJSON, _ = s.rowJSON(ctx, `SELECT row_to_json(pm)::jsonb FROM private_messages pm WHERE id = $1`, out.Message.PrivateMessageID) events, err := s.updateEvents(ctx, ownerUserID, msgID) if err != nil { return out, err } out.UpdateEvents = events outbox, err := s.outbox(ctx, ownerUserID, out.Message.PTS) if err != nil { return out, err } out.Outbox = outbox return out, nil } func (s *readStore) GroupMessageDetail(ctx context.Context, channelID int64, msgID int) (GroupMessageDetail, error) { var out GroupMessageDetail var media []byte var messageJSON []byte err := s.pool.QueryRow(ctx, ` SELECT channel_id, id, sender_user_id, from_peer_type, from_peer_id, message_date, post, body, pts, deleted, COALESCE(media, '{}'::jsonb), views_count, edit_date, pinned, row_to_json(cm)::jsonb FROM channel_messages cm WHERE channel_id = $1 AND id = $2`, channelID, msgID).Scan( &out.Message.ChannelID, &out.Message.ID, &out.Message.SenderUserID, &out.Message.FromPeerType, &out.Message.FromPeerID, &out.Message.Date, &out.Message.Post, &out.Message.Body, &out.Message.PTS, &out.Message.Deleted, &media, &out.Message.ViewsCount, &out.Message.EditDate, &out.Message.Pinned, &messageJSON, ) if err != nil { return out, fmt.Errorf("get group message: %w", err) } out.Message.Media = prettyJSON(media) out.MessageJSON = prettyJSON(messageJSON) out.ChannelJSON, _ = s.rowJSON(ctx, `SELECT row_to_json(c)::jsonb FROM channels c WHERE id = $1`, channelID) events, err := s.channelUpdateEvents(ctx, channelID, msgID) if err != nil { return out, err } out.UpdateEvents = events return out, nil } func (s *readStore) rowJSON(ctx context.Context, sql string, args ...any) (string, error) { var raw []byte if err := s.pool.QueryRow(ctx, sql, args...).Scan(&raw); err != nil { return "", err } return prettyJSON(raw), nil } func (s *readStore) updateEvents(ctx context.Context, ownerUserID int64, msgID int) ([]UpdateEventRow, error) { rows, err := s.pool.Query(ctx, ` SELECT pts, pts_count, event_type, date, row_to_json(e)::jsonb FROM user_update_events e WHERE user_id = $1 AND ( message_box_id = $2 OR message_ids @> $3::jsonb ) ORDER BY pts DESC LIMIT 20`, ownerUserID, msgID, fmt.Sprintf("[%d]", msgID)) if err != nil { return nil, fmt.Errorf("list update events: %w", err) } defer rows.Close() out := make([]UpdateEventRow, 0) for rows.Next() { var e UpdateEventRow var raw []byte if err := rows.Scan(&e.PTS, &e.PTSCount, &e.Type, &e.Date, &raw); err != nil { return nil, err } e.JSON = prettyJSON(raw) out = append(out, e) } return out, rows.Err() } func (s *readStore) channelUpdateEvents(ctx context.Context, channelID int64, msgID int) ([]ChannelUpdateEventRow, error) { rows, err := s.pool.Query(ctx, ` SELECT pts, pts_count, event_type, message_id, date, sender_user_id, row_to_json(e)::jsonb FROM channel_update_events e WHERE channel_id = $1 AND ( message_id = $2 OR message_ids @> $3::jsonb ) ORDER BY pts DESC LIMIT 20`, channelID, msgID, fmt.Sprintf("[%d]", msgID)) if err != nil { return nil, fmt.Errorf("list channel update events: %w", err) } defer rows.Close() out := make([]ChannelUpdateEventRow, 0) for rows.Next() { var e ChannelUpdateEventRow var raw []byte if err := rows.Scan(&e.PTS, &e.PTSCount, &e.Type, &e.MessageID, &e.Date, &e.SenderUserID, &raw); err != nil { return nil, err } e.JSON = prettyJSON(raw) out = append(out, e) } return out, rows.Err() } func (s *readStore) outbox(ctx context.Context, targetUserID int64, pts int) ([]OutboxRow, error) { rows, err := s.pool.Query(ctx, ` SELECT id, target_user_id, pts, event_type, status, attempts, created_at, updated_at FROM dispatch_outbox WHERE target_user_id = $1 AND pts = $2 ORDER BY id DESC LIMIT 20`, targetUserID, pts) if err != nil { return nil, fmt.Errorf("list outbox: %w", err) } defer rows.Close() out := make([]OutboxRow, 0) for rows.Next() { var row OutboxRow if err := rows.Scan(&row.ID, &row.TargetUserID, &row.PTS, &row.EventType, &row.Status, &row.Attempts, &row.CreatedAt, &row.UpdatedAt); err != nil { return nil, err } out = append(out, row) } return out, rows.Err() } func prettyJSON(raw []byte) string { if len(raw) == 0 { return "{}" } var v any if err := json.Unmarshal(raw, &v); err != nil { return string(raw) } out, err := json.MarshalIndent(v, "", " ") if err != nil { return string(raw) } return string(out) } // EmojiRow is a custom-emoji document projection for the admin emoji browser. type EmojiRow struct { DocumentID int64 `json:"DocumentID,string"` Alt string MimeType string Size int64 SetTitle string CreatedAt time.Time } const emojiListDefaultLimit = 60 const emojiListMaxLimit = 200 func scanEmojiRows(rows pgx.Rows) ([]EmojiRow, error) { out := make([]EmojiRow, 0) for rows.Next() { var item EmojiRow if err := rows.Scan(&item.DocumentID, &item.Alt, &item.MimeType, &item.Size, &item.CreatedAt, &item.SetTitle); err != nil { return nil, err } out = append(out, item) } return out, rows.Err() } const emojiSelectColumns = `d.id, COALESCE((SELECT a->>'alt' FROM jsonb_array_elements(d.attributes) a WHERE a->>'kind' = 'custom_emoji' LIMIT 1), ''), d.mime_type, d.size, d.created_at, COALESCE((SELECT s.title FROM sticker_sets s WHERE s.emojis AND NOT s.deleted AND s.document_ids @> to_jsonb(d.id) LIMIT 1), '')` // ListEmoji pages over custom-emoji documents by descending id. func (s *readStore) ListEmoji(ctx context.Context, beforeID int64, limit int) ([]EmojiRow, bool, error) { if limit <= 0 { limit = emojiListDefaultLimit } if limit > emojiListMaxLimit { limit = emojiListMaxLimit } rows, err := s.pool.Query(ctx, ` SELECT `+emojiSelectColumns+` FROM documents d WHERE d.attributes @> '[{"kind":"custom_emoji"}]'::jsonb AND ($1::bigint = 0 OR d.id < $1) ORDER BY d.id DESC LIMIT $2`, beforeID, limit+1) if err != nil { return nil, false, fmt.Errorf("list emoji: %w", err) } defer rows.Close() out, err := scanEmojiRows(rows) if err != nil { return nil, false, err } hasMore := len(out) > limit if hasMore { out = out[:limit] } return out, hasMore, nil } // SearchEmoji finds custom-emoji documents by document id or emoticon substring. func (s *readStore) SearchEmoji(ctx context.Context, q string) ([]EmojiRow, error) { q = strings.TrimSpace(q) if q == "" { return nil, nil } id := int64(-1) if n, err := strconv.ParseInt(q, 10, 64); err == nil { id = n } rows, err := s.pool.Query(ctx, ` SELECT `+emojiSelectColumns+` FROM documents d WHERE d.attributes @> '[{"kind":"custom_emoji"}]'::jsonb AND (d.id = $1 OR EXISTS ( SELECT 1 FROM jsonb_array_elements(d.attributes) a WHERE a->>'kind' = 'custom_emoji' AND a->>'alt' ILIKE '%' || $2 || '%' )) ORDER BY d.id DESC LIMIT $3`, id, q, emojiListMaxLimit) if err != nil { return nil, fmt.Errorf("search emoji: %w", err) } defer rows.Close() return scanEmojiRows(rows) } // CollectibleUsernameRow is one collectible (Fragment-style) username asset with // its holder resolved for display. // // Every int64 is tagged as a JSON string: asset ids, nanoton amounts and the // optimistic-concurrency version all exceed the range a JSON number represents // exactly, and a rounded id would address the wrong asset. type CollectibleUsernameRow struct { ID int64 `json:"ID,string"` Username string Status string OwnerPeerType string OwnerPeerID int64 `json:"OwnerPeerID,string"` OwnerUsername string OwnerName string PurchaseDate time.Time Currency string Amount int64 `json:"Amount,string"` CryptoCurrency string CryptoAmount int64 `json:"CryptoAmount,string"` URL string OriginalOwnerPeerType string OriginalOwnerPeerID int64 `json:"OriginalOwnerPeerID,string"` OriginalOwnerUsername string TransferCount int Version int64 `json:"Version,string"` CreatedAt time.Time UpdatedAt time.Time // RegistryActive / RegistrySortOrder mirror the holder's username registry // row, so the panel can tell an owned-but-hidden name from an active one. RegistryActive bool RegistrySortOrder int } // CollectibleUsernameTransferRow is one provenance log entry. type CollectibleUsernameTransferRow struct { ID int64 `json:"ID,string"` CollectibleID int64 `json:"CollectibleID,string"` Kind string FromPeerType string FromPeerID int64 `json:"FromPeerID,string"` FromUsername string ToPeerType string ToPeerID int64 `json:"ToPeerID,string"` ToUsername string Currency string Amount int64 `json:"Amount,string"` Actor string Reason string CommandKey string CreatedAt time.Time } // CollectibleUsernameDetail is the asset plus its provenance log. type CollectibleUsernameDetail struct { Asset CollectibleUsernameRow Transfers []CollectibleUsernameTransferRow } const collectibleUsernameSelectColumns = `cu.id, cu.username, cu.status, cu.owner_peer_type, cu.owner_peer_id, COALESCE(NULLIF(ou.username, ''), NULLIF(oc.username, ''), '') AS owner_username, COALESCE(NULLIF(ou.first_name, ''), NULLIF(oc.title, ''), '') AS owner_name, cu.purchase_date, cu.currency, cu.amount, cu.crypto_currency, cu.crypto_amount, cu.url, cu.original_owner_peer_type, cu.original_owner_peer_id, COALESCE(NULLIF(gu.username, ''), NULLIF(gc.username, ''), '') AS original_owner_username, cu.transfer_count, cu.version, cu.created_at, cu.updated_at, COALESCE(pu.active, false), COALESCE(pu.sort_order, 0)` // collectibleUsernameJoins resolves the current holder, the original holder and // the holder's registry row. Owners are users or channels, so both sides are // joined and the peer type decides which one contributes. const collectibleUsernameJoins = ` FROM collectible_usernames cu LEFT JOIN users ou ON cu.owner_peer_type = 'user' AND ou.id = cu.owner_peer_id LEFT JOIN channels oc ON cu.owner_peer_type = 'channel' AND oc.id = cu.owner_peer_id LEFT JOIN users gu ON cu.original_owner_peer_type = 'user' AND gu.id = cu.original_owner_peer_id LEFT JOIN channels gc ON cu.original_owner_peer_type = 'channel' AND gc.id = cu.original_owner_peer_id LEFT JOIN peer_usernames pu ON pu.collectible_id = cu.id` func collectibleUsernameScanDest(item *CollectibleUsernameRow) []any { return []any{ &item.ID, &item.Username, &item.Status, &item.OwnerPeerType, &item.OwnerPeerID, &item.OwnerUsername, &item.OwnerName, &item.PurchaseDate, &item.Currency, &item.Amount, &item.CryptoCurrency, &item.CryptoAmount, &item.URL, &item.OriginalOwnerPeerType, &item.OriginalOwnerPeerID, &item.OriginalOwnerUsername, &item.TransferCount, &item.Version, &item.CreatedAt, &item.UpdatedAt, &item.RegistryActive, &item.RegistrySortOrder, } } // ListCollectibleUsernames pages over collectible assets newest first, keyset by // descending id. status/ownerUserID/q are optional filters; q matches a username // prefix, which is how an operator looks a name up. func (s *readStore) ListCollectibleUsernames(ctx context.Context, status string, ownerUserID, beforeID int64, q string, limit int) ([]CollectibleUsernameRow, bool, error) { if limit <= 0 { limit = collectibleListDefaultLimit } if limit > collectibleListMaxLimit { limit = collectibleListMaxLimit } status = strings.TrimSpace(status) query := escapeLikePattern(strings.ToLower(strings.TrimPrefix(strings.TrimSpace(q), "@"))) rows, err := s.pool.Query(ctx, ` SELECT `+collectibleUsernameSelectColumns+collectibleUsernameJoins+` WHERE ($1 = '' OR cu.status = $1) AND ($2::bigint = 0 OR (cu.owner_peer_type = 'user' AND cu.owner_peer_id = $2)) AND ($3 = '' OR cu.username_lower LIKE $3 || '%') AND ($4::bigint = 0 OR cu.id < $4) ORDER BY cu.id DESC LIMIT $5`, status, ownerUserID, query, beforeID, limit+1) if err != nil { return nil, false, fmt.Errorf("list collectible usernames: %w", err) } defer rows.Close() out := make([]CollectibleUsernameRow, 0, limit+1) for rows.Next() { var item CollectibleUsernameRow if err := rows.Scan(collectibleUsernameScanDest(&item)...); err != nil { return nil, false, err } out = append(out, item) } if err := rows.Err(); err != nil { return nil, false, err } hasMore := len(out) > limit if hasMore { out = out[:limit] } return out, hasMore, nil } // CollectibleUsernameDetail returns one asset with its provenance log. A missing // asset reports errReadNotFound so the API answers 404 rather than 500. func (s *readStore) CollectibleUsernameDetail(ctx context.Context, id int64) (CollectibleUsernameDetail, error) { var out CollectibleUsernameDetail err := s.pool.QueryRow(ctx, ` SELECT `+collectibleUsernameSelectColumns+collectibleUsernameJoins+` WHERE cu.id = $1`, id).Scan(collectibleUsernameScanDest(&out.Asset)...) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return out, errReadNotFound } return out, fmt.Errorf("get collectible username: %w", err) } out.Transfers, err = s.collectibleUsernameTransfers(ctx, id) if err != nil { return out, err } return out, nil } func (s *readStore) collectibleUsernameTransfers(ctx context.Context, collectibleID int64) ([]CollectibleUsernameTransferRow, error) { rows, err := s.pool.Query(ctx, ` SELECT t.id, t.collectible_id, t.kind, t.from_peer_type, t.from_peer_id, COALESCE(NULLIF(fu.username, ''), NULLIF(fc.username, ''), '') AS from_username, t.to_peer_type, t.to_peer_id, COALESCE(NULLIF(tu.username, ''), NULLIF(tc.username, ''), '') AS to_username, t.currency, t.amount, t.actor, t.reason, COALESCE(t.command_key, ''), t.created_at FROM collectible_username_transfers t LEFT JOIN users fu ON t.from_peer_type = 'user' AND fu.id = t.from_peer_id LEFT JOIN channels fc ON t.from_peer_type = 'channel' AND fc.id = t.from_peer_id LEFT JOIN users tu ON t.to_peer_type = 'user' AND tu.id = t.to_peer_id LEFT JOIN channels tc ON t.to_peer_type = 'channel' AND tc.id = t.to_peer_id WHERE t.collectible_id = $1 ORDER BY t.id DESC LIMIT $2`, collectibleID, collectibleTransferLimit) if err != nil { return nil, fmt.Errorf("list collectible username transfers: %w", err) } defer rows.Close() out := make([]CollectibleUsernameTransferRow, 0) for rows.Next() { var item CollectibleUsernameTransferRow if err := rows.Scan( &item.ID, &item.CollectibleID, &item.Kind, &item.FromPeerType, &item.FromPeerID, &item.FromUsername, &item.ToPeerType, &item.ToPeerID, &item.ToUsername, &item.Currency, &item.Amount, &item.Actor, &item.Reason, &item.CommandKey, &item.CreatedAt, ); err != nil { return nil, err } out = append(out, item) } return out, rows.Err() } // Official platform verification review queue. // // The application record is the audit subject and is read here directly, with the // applicant resolved through the same users/peer_usernames join every other view // uses. target_verified is read from the live peer rather than from the // submission snapshot: a reviewer has to see the badge as it is now, and the // snapshot columns exist precisely because the live peer may have drifted. // // Every int64 is tagged as a JSON string. Application ids, peer ids and the // optimistic-locking version all exceed the range a JSON number represents // exactly, and a rounded version would send a decision against the wrong // revision of the row. type VerificationApplicationRow struct { ID int64 `json:"ID,string"` ApplicantUserID int64 `json:"ApplicantUserID,string"` ApplicantUsername string ApplicantName string TargetType string TargetID int64 `json:"TargetID,string"` TargetTitle string TargetUsername string TargetVerified bool Category string Description string OfficialWebsite string SocialLinks []string PressLinks []string AdditionalNote string Status string ReviewerAdminID string DecisionReason string // InternalNote is operator-only: it is the reviewer handover note and is never // projected to the applicant. Every caller of this store already holds // verification.review. InternalNote string CorrelationID string CreatedAt time.Time UpdatedAt time.Time SubmittedAt time.Time ReviewedAt time.Time Version int64 `json:"Version,string"` } // VerificationEventRow is one entry of the immutable application history. type VerificationEventRow struct { ID int64 `json:"ID,string"` Kind string FromStatus string ToStatus string Actor string Reason string Note string CreatedAt time.Time } // VerificationApplicationDetail is the application, its history, and whether the // applicant still controls the target. type VerificationApplicationDetail struct { Application VerificationApplicationRow Events []VerificationEventRow // ApplicantControlsTarget re-derives ownership from the live records, using the // same authorities the use-case layer does: the bots table for a bot, the // public-channel admin index for a channel or supergroup, identity for a user. // Control can be lost between submission and review, and approving a peer the // applicant no longer holds is exactly what the flag exists to prevent. ApplicantControlsTarget bool } const verificationSelectColumns = `va.id, va.applicant_user_id, COALESCE(NULLIF(au.username, ''), p.username_lower, '') AS applicant_username, TRIM(BOTH ' ' FROM COALESCE(au.first_name, '') || ' ' || COALESCE(au.last_name, '')) AS applicant_name, va.target_type, va.target_id, va.target_title, va.target_username, COALESCE(tu.verified, tc.verified, false) AS target_verified, va.category, va.description, va.official_website, va.social_links, va.press_links, va.additional_note, va.status, va.reviewer_admin_id, va.decision_reason, va.internal_note, va.correlation_id, va.created_at, va.updated_at, va.submitted_at, va.reviewed_at, va.version` // verificationJoins resolves the applicant and the live target. Bots and users // live in the user namespace, channels and supergroups in the channel one, so // both sides are joined and the target type decides which one contributes. const verificationJoins = ` FROM verification_applications va LEFT JOIN users au ON au.id = va.applicant_user_id LEFT JOIN peer_usernames p ON p.peer_type = 'user' AND p.peer_id = va.applicant_user_id AND p.editable LEFT JOIN users tu ON va.target_type IN ('bot', 'user') AND tu.id = va.target_id LEFT JOIN channels tc ON va.target_type IN ('channel', 'supergroup') AND tc.id = va.target_id` func scanVerificationApplicationRow(scan func(dest ...any) error, item *VerificationApplicationRow) error { // submitted_at is NULL while the application is still a draft and reviewed_at // until a reviewer closes it. var submittedAt, reviewedAt *time.Time if err := scan( &item.ID, &item.ApplicantUserID, &item.ApplicantUsername, &item.ApplicantName, &item.TargetType, &item.TargetID, &item.TargetTitle, &item.TargetUsername, &item.TargetVerified, &item.Category, &item.Description, &item.OfficialWebsite, &item.SocialLinks, &item.PressLinks, &item.AdditionalNote, &item.Status, &item.ReviewerAdminID, &item.DecisionReason, &item.InternalNote, &item.CorrelationID, &item.CreatedAt, &item.UpdatedAt, &submittedAt, &reviewedAt, &item.Version, ); err != nil { return err } if submittedAt != nil { item.SubmittedAt = submittedAt.UTC() } if reviewedAt != nil { item.ReviewedAt = reviewedAt.UTC() } if item.SocialLinks == nil { item.SocialLinks = []string{} } if item.PressLinks == nil { item.PressLinks = []string{} } return nil } // ListVerificationApplications pages the review queue newest first, keyset by // descending id. status/targetType/reviewer are exact filters; q matches an // application id, a target peer id, or a target/applicant username prefix, which // is how an operator looks a case up from a report. func (s *readStore) ListVerificationApplications( ctx context.Context, status, targetType, reviewer, q string, beforeID int64, limit int, ) ([]VerificationApplicationRow, bool, error) { if limit <= 0 { limit = verificationListDefaultLimit } if limit > verificationListMaxLimit { limit = verificationListMaxLimit } status = strings.TrimSpace(status) targetType = strings.TrimSpace(targetType) reviewer = strings.TrimSpace(reviewer) query := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(q), "@")) pattern := "" queryID := int64(0) if query != "" { pattern = strings.ToLower(escapeLikePattern(query)) + "%" if parsed, err := strconv.ParseInt(query, 10, 64); err == nil && parsed > 0 { queryID = parsed } } rows, err := s.pool.Query(ctx, ` SELECT `+verificationSelectColumns+verificationJoins+` WHERE ($1 = '' OR va.status = $1) AND ($2 = '' OR va.target_type = $2) AND ($3 = '' OR va.reviewer_admin_id = $3) AND ($4::bigint = 0 OR va.id < $4) AND ($5::text = '' OR ( ($6::bigint <> 0 AND (va.id = $6 OR va.target_id = $6 OR va.applicant_user_id = $6)) OR lower(va.target_username) LIKE $5 OR lower(va.target_title) LIKE $5 OR lower(COALESCE(au.username, '')) LIKE $5 )) ORDER BY va.id DESC LIMIT $7`, status, targetType, reviewer, beforeID, pattern, queryID, limit+1) if err != nil { return nil, false, fmt.Errorf("list verification applications: %w", err) } defer rows.Close() out := make([]VerificationApplicationRow, 0, limit+1) for rows.Next() { var item VerificationApplicationRow if err := scanVerificationApplicationRow(rows.Scan, &item); err != nil { return nil, false, err } out = append(out, item) } if err := rows.Err(); err != nil { return nil, false, err } hasMore := len(out) > limit if hasMore { out = out[:limit] } return out, hasMore, nil } // VerificationApplicationDetail returns one application with its history and the // live ownership check. A missing application reports errReadNotFound so the API // answers 404 rather than 500. func (s *readStore) VerificationApplicationDetail(ctx context.Context, id int64) (VerificationApplicationDetail, error) { var out VerificationApplicationDetail row := s.pool.QueryRow(ctx, ` SELECT `+verificationSelectColumns+verificationJoins+` WHERE va.id = $1`, id) // The single-row path reuses the list scanner, so one column order serves // both: a drift between them would silently mis-assign columns. err := scanVerificationApplicationRow(row.Scan, &out.Application) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return out, errReadNotFound } return out, fmt.Errorf("get verification application: %w", err) } out.Events, err = s.verificationApplicationEvents(ctx, id) if err != nil { return out, err } controls, err := s.applicantControlsVerificationTarget( ctx, out.Application.ApplicantUserID, out.Application.TargetType, out.Application.TargetID, ) if err != nil { return out, err } out.ApplicantControlsTarget = controls return out, nil } func (s *readStore) verificationApplicationEvents(ctx context.Context, applicationID int64) ([]VerificationEventRow, error) { rows, err := s.pool.Query(ctx, ` SELECT id, kind, from_status, to_status, actor, reason, note, created_at FROM verification_application_events WHERE application_id = $1 ORDER BY id DESC LIMIT $2`, applicationID, verificationEventLimit) if err != nil { return nil, fmt.Errorf("list verification application events: %w", err) } defer rows.Close() out := make([]VerificationEventRow, 0) for rows.Next() { var item VerificationEventRow if err := rows.Scan( &item.ID, &item.Kind, &item.FromStatus, &item.ToStatus, &item.Actor, &item.Reason, &item.Note, &item.CreatedAt, ); err != nil { return nil, err } out = append(out, item) } return out, rows.Err() } // VerificationStatusCounts is the queue summary above the list. Every modelled // status is present with a zero, so the panel never has to tell "none" from // "missing", and the counts are decimal strings for the same exactness reason as // the ids. func (s *readStore) VerificationStatusCounts(ctx context.Context) (map[string]string, error) { out := map[string]string{} for _, status := range []domain.VerificationStatus{ domain.VerificationStatusDraft, domain.VerificationStatusSubmitted, domain.VerificationStatusInReview, domain.VerificationStatusApproved, domain.VerificationStatusRejected, domain.VerificationStatusCancelled, } { out[string(status)] = "0" } rows, err := s.pool.Query(ctx, ` SELECT status, count(*) FROM verification_applications GROUP BY status`) if err != nil { return nil, fmt.Errorf("count verification applications: %w", err) } defer rows.Close() for rows.Next() { var status string var count int64 if err := rows.Scan(&status, &count); err != nil { return nil, err } out[status] = strconv.FormatInt(count, 10) } return out, rows.Err() } // applicantControlsVerificationTarget re-derives ownership from the live records. // // The authorities are the ones app/verification uses, so the panel's answer and // the approval path's answer cannot disagree: the bots table for a bot (minus // BotFather, which nobody owns), the public-channel admin index for a channel or // supergroup, and plain identity for a user account. func (s *readStore) applicantControlsVerificationTarget(ctx context.Context, applicantUserID int64, targetType string, targetID int64) (bool, error) { if applicantUserID <= 0 || targetID <= 0 { return false, nil } switch domain.VerificationTargetType(targetType) { case domain.VerificationTargetUser: return applicantUserID == targetID, nil case domain.VerificationTargetBot: var owns bool err := s.pool.QueryRow(ctx, ` SELECT EXISTS ( SELECT 1 FROM bots b WHERE b.bot_user_id = $1 AND b.owner_user_id = $2 AND b.bot_user_id <> $3 )`, targetID, applicantUserID, domain.BotFatherUserID).Scan(&owns) if err != nil { return false, fmt.Errorf("check verification bot ownership: %w", err) } return owns, nil case domain.VerificationTargetChannel, domain.VerificationTargetSupergroup: var admins bool err := s.pool.QueryRow(ctx, ` SELECT EXISTS ( SELECT 1 FROM user_channel_member_index i WHERE i.user_id = $1 AND i.channel_id = $2 AND i.status = 'active' AND i.role IN ('creator', 'admin') AND i.public_username AND NOT i.deleted )`, applicantUserID, targetID).Scan(&admins) if err != nil { return false, fmt.Errorf("check verification channel ownership: %w", err) } return admins, nil default: return false, nil } } // Third-party bot verification (core.telegram.org/api/bots/verification). // // This is NOT the official platform badge read above. Official verification is a // boolean on the peer that only the operator sets; 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 and neither // reads the other's, which is why these queries never touch // verification_applications or users.verified. // // Peer titles and usernames are resolved from the live peer rather than from the // application's snapshot columns: an operator has to see the peer as it is now, // and the snapshot is only the fallback for a peer that has since gone. Usernames // come from the same users/channels + peer_usernames join every other view uses, // with `AND p.editable` on the peer_usernames side -- a collectible username sits // in the same table and is not the peer's own editable handle, so joining without // the predicate would report somebody else's asset as the peer's name. // // Every int64 is tagged as a JSON string. Bot ids, peer ids, custom emoji document // ids and the optimistic-locking version all exceed the range a JSON number // represents exactly, and a rounded version would send a decision against the // wrong revision of the row. // BotVerifierRow is one verifier bot: its operator-granted settings, the catalogue // name of the icon it marks with, and how many peers it has marked. type BotVerifierRow struct { BotID int64 `json:"BotID,string"` BotUsername string BotName string IconDocumentID int64 `json:"IconDocumentID,string"` IconName string CompanyName string DefaultDescription string CanModifyCustomDescription bool Enabled bool GrantedBy string GrantReason string // MarkCount is how many peers this verifier currently marks. It is the number // that would cascade away with a revocation, so it is counted rather than // estimated. MarkCount int64 `json:"MarkCount,string"` CreatedAt time.Time UpdatedAt time.Time Version int64 `json:"Version,string"` } // VerificationIconRow is one catalogue entry. type VerificationIconRow struct { ID int64 `json:"ID,string"` DocumentID int64 `json:"DocumentID,string"` // OwnerBotID is 0 for a shared entry and a bot id when the operator reserved // the icon for one verifier. OwnerBotID int64 `json:"OwnerBotID,string"` OwnerBotUsername string Name string Active bool // UsedByVerifiers is a plain number: it counts verifier rows pointing at this // document and can never approach the exactness limit an id can. UsedByVerifiers int `json:"UsedByVerifiers"` CreatedAt time.Time UpdatedAt time.Time } // CustomVerificationRow is one granted mark. type CustomVerificationRow struct { ID int64 `json:"ID,string"` VerifierBotID int64 `json:"VerifierBotID,string"` VerifierBotUsername string CompanyName string PeerType string PeerID int64 `json:"PeerID,string"` PeerTitle string PeerUsername string // IconDocumentID is the icon the mark was granted with, denormalised at grant // time, so it keeps rendering even after the verifier changes its own. IconDocumentID int64 `json:"IconDocumentID,string"` Description string CreatedAt time.Time UpdatedAt time.Time Version int64 `json:"Version,string"` } // CustomVerificationRequestRow is one application filed with a verifier bot. type CustomVerificationRequestRow struct { ID int64 `json:"ID,string"` VerifierBotID int64 `json:"VerifierBotID,string"` VerifierBotUsername string ApplicantUserID int64 `json:"ApplicantUserID,string"` ApplicantUsername string PeerType string PeerID int64 `json:"PeerID,string"` PeerTitle string PeerUsername string Reason string RequestedDescription string Status string DecidedBy string DecisionReason string // InternalNote is operator-only: it is the reviewer handover note and is never // projected to the applicant. Every caller of this store already holds // botverification.review. InternalNote string CorrelationID string CreatedAt time.Time UpdatedAt time.Time ApprovedAt time.Time RejectedAt time.Time Version int64 `json:"Version,string"` } // CustomVerificationRequestDetail is one application, the verifier behind it, and // whether the mark is on the peer right now. type CustomVerificationRequestDetail struct { Request CustomVerificationRequestRow Verifier BotVerifierRow // MarkActive tells "approved" apart from "approved and since stripped by the // operator", which is the one thing the status alone cannot say. MarkActive bool } const botVerifierSelectColumns = `s.bot_id, COALESCE(NULLIF(u.username, ''), p.username_lower, '') AS bot_username, TRIM(BOTH ' ' FROM COALESCE(u.first_name, '') || ' ' || COALESCE(u.last_name, '')) AS bot_name, s.icon_document_id, COALESCE(i.name, '') AS icon_name, s.company_name, s.default_description, s.can_modify_custom_description, s.enabled, s.granted_by, s.grant_reason, (SELECT count(*) FROM custom_verifications cv WHERE cv.verifier_bot_id = s.bot_id) AS mark_count, s.created_at, s.updated_at, s.version` // botVerifierJoins resolves the bot account behind the verifier row and the // catalogue label of its icon. The icon join is by document id, not by catalogue // id: the settings row stores the document, and an icon dropped from the catalogue // must still leave the verifier readable. const botVerifierJoins = ` FROM bot_verifier_settings s LEFT JOIN users u ON u.id = s.bot_id LEFT JOIN peer_usernames p ON p.peer_type = 'user' AND p.peer_id = s.bot_id AND p.editable LEFT JOIN verification_icons i ON i.document_id = s.icon_document_id` func scanBotVerifierRow(scan func(dest ...any) error, item *BotVerifierRow) error { return scan( &item.BotID, &item.BotUsername, &item.BotName, &item.IconDocumentID, &item.IconName, &item.CompanyName, &item.DefaultDescription, &item.CanModifyCustomDescription, &item.Enabled, &item.GrantedBy, &item.GrantReason, &item.MarkCount, &item.CreatedAt, &item.UpdatedAt, &item.Version, ) } // ListBotVerifiers lists verifier bots, ordered by bot id so the table is stable // across reloads. enabledOnly hides the ones the operator switched off. func (s *readStore) ListBotVerifiers(ctx context.Context, enabledOnly bool, limit int) ([]BotVerifierRow, error) { limit = clampBotVerificationLimit(limit) rows, err := s.pool.Query(ctx, ` SELECT `+botVerifierSelectColumns+botVerifierJoins+` WHERE NOT $1::boolean OR s.enabled ORDER BY s.bot_id LIMIT $2`, enabledOnly, limit) if err != nil { return nil, fmt.Errorf("list bot verifiers: %w", err) } defer rows.Close() out := make([]BotVerifierRow, 0, limit) for rows.Next() { var item BotVerifierRow if err := scanBotVerifierRow(rows.Scan, &item); err != nil { return nil, err } out = append(out, item) } return out, rows.Err() } // BotVerifier reads one verifier row, enabled or not: the panel needs the disabled // one too, to render the kill switch. A missing row reports errReadNotFound. func (s *readStore) BotVerifier(ctx context.Context, botID int64) (BotVerifierRow, error) { var out BotVerifierRow row := s.pool.QueryRow(ctx, ` SELECT `+botVerifierSelectColumns+botVerifierJoins+` WHERE s.bot_id = $1`, botID) // The single-row path reuses the list scanner, so one column order serves both: // a drift between them would silently mis-assign columns. if err := scanBotVerifierRow(row.Scan, &out); err != nil { if errors.Is(err, pgx.ErrNoRows) { return out, errReadNotFound } return out, fmt.Errorf("get bot verifier: %w", err) } return out, nil } // ListVerificationIcons lists the icon catalogue newest first, with the number of // verifiers each entry is currently configured on -- retiring an entry that // verifiers still point at is the operator's decision to make knowingly. func (s *readStore) ListVerificationIcons(ctx context.Context, activeOnly bool, limit int) ([]VerificationIconRow, error) { limit = clampBotVerificationLimit(limit) rows, err := s.pool.Query(ctx, ` SELECT i.id, i.document_id, i.owner_bot_id, COALESCE(NULLIF(u.username, ''), p.username_lower, '') AS owner_bot_username, i.name, i.active, (SELECT count(*) FROM bot_verifier_settings s WHERE s.icon_document_id = i.document_id) AS used_by_verifiers, i.created_at, i.updated_at FROM verification_icons i LEFT JOIN users u ON i.owner_bot_id <> 0 AND u.id = i.owner_bot_id LEFT JOIN peer_usernames p ON i.owner_bot_id <> 0 AND p.peer_type = 'user' AND p.peer_id = i.owner_bot_id AND p.editable WHERE NOT $1::boolean OR i.active ORDER BY i.id DESC LIMIT $2`, activeOnly, limit) if err != nil { return nil, fmt.Errorf("list verification icons: %w", err) } defer rows.Close() out := make([]VerificationIconRow, 0, limit) for rows.Next() { var item VerificationIconRow if err := rows.Scan( &item.ID, &item.DocumentID, &item.OwnerBotID, &item.OwnerBotUsername, &item.Name, &item.Active, &item.UsedByVerifiers, &item.CreatedAt, &item.UpdatedAt, ); err != nil { return nil, err } out = append(out, item) } return out, rows.Err() } // customVerificationPeerJoins resolves a marked or applied-for peer on both sides // of the namespace. A third-party mark can sit on a user (bots included) or a // channel, so both are joined and the row's peer_type decides which contributes. // Each username side carries `AND editable`, so a collectible username parked in // peer_usernames is never mistaken for the peer's own handle. const customVerificationPeerJoins = ` LEFT JOIN users tu ON %[1]s.peer_type = 'user' AND tu.id = %[1]s.peer_id LEFT JOIN peer_usernames tup ON %[1]s.peer_type = 'user' AND tup.peer_type = 'user' AND tup.peer_id = %[1]s.peer_id AND tup.editable LEFT JOIN channels tc ON %[1]s.peer_type = 'channel' AND tc.id = %[1]s.peer_id LEFT JOIN peer_usernames tcp ON %[1]s.peer_type = 'channel' AND tcp.peer_type = 'channel' AND tcp.peer_id = %[1]s.peer_id AND tcp.editable` // ListCustomVerifications pages granted marks newest first, keyset by descending // id. verifierBotID/peerType are exact filters; q matches a mark id, a peer id, or // a peer username prefix, which is how an operator looks a badge up from a report. func (s *readStore) ListCustomVerifications( ctx context.Context, verifierBotID int64, peerType, q string, beforeID int64, limit int, ) ([]CustomVerificationRow, bool, error) { limit = clampBotVerificationLimit(limit) peerType = strings.TrimSpace(peerType) pattern, queryID := botVerificationSearchTerms(q) rows, err := s.pool.Query(ctx, ` SELECT cv.id, cv.verifier_bot_id, COALESCE(NULLIF(vu.username, ''), vp.username_lower, '') AS verifier_bot_username, COALESCE(s.company_name, '') AS company_name, cv.peer_type, cv.peer_id, CASE cv.peer_type WHEN 'user' THEN TRIM(BOTH ' ' FROM COALESCE(tu.first_name, '') || ' ' || COALESCE(tu.last_name, '')) ELSE COALESCE(tc.title, '') END AS peer_title, CASE cv.peer_type WHEN 'user' THEN COALESCE(NULLIF(tu.username, ''), tup.username_lower, '') ELSE COALESCE(NULLIF(tc.username, ''), tcp.username_lower, '') END AS peer_username, cv.icon_document_id, cv.description, cv.created_at, cv.updated_at, cv.version FROM custom_verifications cv LEFT JOIN users vu ON vu.id = cv.verifier_bot_id LEFT JOIN peer_usernames vp ON vp.peer_type = 'user' AND vp.peer_id = cv.verifier_bot_id AND vp.editable LEFT JOIN bot_verifier_settings s ON s.bot_id = cv.verifier_bot_id`+ fmt.Sprintf(customVerificationPeerJoins, "cv")+` WHERE ($1::bigint = 0 OR cv.verifier_bot_id = $1) AND ($2::text = '' OR cv.peer_type = $2) AND ($3::bigint = 0 OR cv.id < $3) AND ($4::text = '' OR ( ($5::bigint <> 0 AND (cv.id = $5 OR cv.peer_id = $5 OR cv.verifier_bot_id = $5)) OR lower(COALESCE(tu.username, '')) LIKE $4 OR lower(COALESCE(tc.username, '')) LIKE $4 OR lower(COALESCE(tc.title, '')) LIKE $4 )) ORDER BY cv.id DESC LIMIT $6`, verifierBotID, peerType, beforeID, pattern, queryID, limit+1) if err != nil { return nil, false, fmt.Errorf("list custom verifications: %w", err) } defer rows.Close() out := make([]CustomVerificationRow, 0, limit+1) for rows.Next() { var item CustomVerificationRow if err := rows.Scan( &item.ID, &item.VerifierBotID, &item.VerifierBotUsername, &item.CompanyName, &item.PeerType, &item.PeerID, &item.PeerTitle, &item.PeerUsername, &item.IconDocumentID, &item.Description, &item.CreatedAt, &item.UpdatedAt, &item.Version, ); err != nil { return nil, false, err } out = append(out, item) } if err := rows.Err(); err != nil { return nil, false, err } hasMore := len(out) > limit if hasMore { out = out[:limit] } return out, hasMore, nil } const customVerificationRequestSelectColumns = `r.id, r.verifier_bot_id, COALESCE(NULLIF(vu.username, ''), vp.username_lower, '') AS verifier_bot_username, r.applicant_user_id, COALESCE(NULLIF(au.username, ''), ap.username_lower, '') AS applicant_username, r.peer_type, r.peer_id, CASE r.peer_type WHEN 'user' THEN COALESCE(NULLIF(TRIM(BOTH ' ' FROM COALESCE(tu.first_name, '') || ' ' || COALESCE(tu.last_name, '')), ''), r.peer_title) ELSE COALESCE(NULLIF(tc.title, ''), r.peer_title) END AS peer_title, CASE r.peer_type WHEN 'user' THEN COALESCE(NULLIF(tu.username, ''), NULLIF(tup.username_lower, ''), r.peer_username) ELSE COALESCE(NULLIF(tc.username, ''), NULLIF(tcp.username_lower, ''), r.peer_username) END AS peer_username, r.reason, r.requested_description, r.status, r.decided_by, r.decision_reason, r.internal_note, r.correlation_id, r.created_at, r.updated_at, r.approved_at, r.rejected_at, r.version` // customVerificationRequestJoins resolves the verifier bot, the applicant and the // target peer. The peer title and username fall back to the application's snapshot // columns: a peer deleted since it applied still has to render as something the // reviewer recognises. var customVerificationRequestJoins = ` FROM custom_verification_requests r LEFT JOIN users vu ON vu.id = r.verifier_bot_id LEFT JOIN peer_usernames vp ON vp.peer_type = 'user' AND vp.peer_id = r.verifier_bot_id AND vp.editable LEFT JOIN users au ON au.id = r.applicant_user_id LEFT JOIN peer_usernames ap ON ap.peer_type = 'user' AND ap.peer_id = r.applicant_user_id AND ap.editable` + fmt.Sprintf(customVerificationPeerJoins, "r") func scanCustomVerificationRequestRow(scan func(dest ...any) error, item *CustomVerificationRequestRow) error { // approved_at is NULL until an approval and rejected_at until a rejection; the // table's CHECK constraints keep each in step with the status. var approvedAt, rejectedAt *time.Time if err := scan( &item.ID, &item.VerifierBotID, &item.VerifierBotUsername, &item.ApplicantUserID, &item.ApplicantUsername, &item.PeerType, &item.PeerID, &item.PeerTitle, &item.PeerUsername, &item.Reason, &item.RequestedDescription, &item.Status, &item.DecidedBy, &item.DecisionReason, &item.InternalNote, &item.CorrelationID, &item.CreatedAt, &item.UpdatedAt, &approvedAt, &rejectedAt, &item.Version, ); err != nil { return err } if approvedAt != nil { item.ApprovedAt = approvedAt.UTC() } if rejectedAt != nil { item.RejectedAt = rejectedAt.UTC() } return nil } // ListCustomVerificationRequests pages the third-party review queue newest first, // keyset by descending id. status/verifierBotID/peerType are exact filters; q // matches an application id, a peer id, a verifier id, or a peer/applicant // username prefix. func (s *readStore) ListCustomVerificationRequests( ctx context.Context, status string, verifierBotID int64, peerType, q string, beforeID int64, limit int, ) ([]CustomVerificationRequestRow, bool, error) { limit = clampBotVerificationLimit(limit) status = strings.TrimSpace(status) peerType = strings.TrimSpace(peerType) pattern, queryID := botVerificationSearchTerms(q) rows, err := s.pool.Query(ctx, ` SELECT `+customVerificationRequestSelectColumns+customVerificationRequestJoins+` WHERE ($1::text = '' OR r.status = $1) AND ($2::bigint = 0 OR r.verifier_bot_id = $2) AND ($3::text = '' OR r.peer_type = $3) AND ($4::bigint = 0 OR r.id < $4) AND ($5::text = '' OR ( ($6::bigint <> 0 AND (r.id = $6 OR r.peer_id = $6 OR r.verifier_bot_id = $6 OR r.applicant_user_id = $6)) OR lower(r.peer_username) LIKE $5 OR lower(r.peer_title) LIKE $5 OR lower(COALESCE(tu.username, '')) LIKE $5 OR lower(COALESCE(tc.username, '')) LIKE $5 OR lower(COALESCE(au.username, '')) LIKE $5 )) ORDER BY r.id DESC LIMIT $7`, status, verifierBotID, peerType, beforeID, pattern, queryID, limit+1) if err != nil { return nil, false, fmt.Errorf("list custom verification requests: %w", err) } defer rows.Close() out := make([]CustomVerificationRequestRow, 0, limit+1) for rows.Next() { var item CustomVerificationRequestRow if err := scanCustomVerificationRequestRow(rows.Scan, &item); err != nil { return nil, false, err } out = append(out, item) } if err := rows.Err(); err != nil { return nil, false, err } hasMore := len(out) > limit if hasMore { out = out[:limit] } return out, hasMore, nil } // CustomVerificationRequestDetail returns one application with the verifier behind // it and whether the mark is live. A missing application reports errReadNotFound so // the API answers 404 rather than 500. func (s *readStore) CustomVerificationRequestDetail(ctx context.Context, id int64) (CustomVerificationRequestDetail, error) { var out CustomVerificationRequestDetail row := s.pool.QueryRow(ctx, ` SELECT `+customVerificationRequestSelectColumns+customVerificationRequestJoins+` WHERE r.id = $1`, id) if err := scanCustomVerificationRequestRow(row.Scan, &out.Request); err != nil { if errors.Is(err, pgx.ErrNoRows) { return out, errReadNotFound } return out, fmt.Errorf("get custom verification request: %w", err) } // The verifier may have been revoked since the application was filed, and the // application survives that (it references users, not the settings row). An // absent verifier is reported as a row carrying only its id, so the reviewer can // still see which bot it was. verifier, err := s.BotVerifier(ctx, out.Request.VerifierBotID) switch { case err == nil: out.Verifier = verifier case errors.Is(err, errReadNotFound): out.Verifier = BotVerifierRow{BotID: out.Request.VerifierBotID} default: return out, err } if err := s.pool.QueryRow(ctx, ` SELECT EXISTS ( SELECT 1 FROM custom_verifications WHERE verifier_bot_id = $1 AND peer_type = $2 AND peer_id = $3 )`, out.Request.VerifierBotID, out.Request.PeerType, out.Request.PeerID).Scan(&out.MarkActive); err != nil { return out, fmt.Errorf("check custom verification mark: %w", err) } return out, nil } // CustomVerificationRequestCounts is the queue summary above the list. Every // modelled status is present, so the panel never has to tell "zero" from "absent", // and the values are decimal strings for the same exactness reason as the ids. func (s *readStore) CustomVerificationRequestCounts(ctx context.Context) (map[string]string, error) { out := map[string]string{} for _, status := range []domain.CustomVerificationRequestStatus{ domain.CustomVerificationPending, domain.CustomVerificationApproved, domain.CustomVerificationRejected, domain.CustomVerificationRevoked, } { out[string(status)] = "0" } rows, err := s.pool.Query(ctx, ` SELECT status, count(*) FROM custom_verification_requests GROUP BY status`) if err != nil { return nil, fmt.Errorf("count custom verification requests: %w", err) } defer rows.Close() for rows.Next() { var status string var count int64 if err := rows.Scan(&status, &count); err != nil { return nil, err } out[status] = strconv.FormatInt(count, 10) } return out, rows.Err() } // botVerificationSearchTerms turns an operator query into the LIKE pattern and the // optional exact id the list predicates use. LIKE metacharacters are escaped: // usernames legitimately contain '_', so an unescaped search for "crypto_" would // match "cryptoX" instead of the name that was typed. func botVerificationSearchTerms(q string) (string, int64) { query := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(q), "@")) if query == "" { return "", 0 } pattern := strings.ToLower(escapeLikePattern(query)) + "%" queryID := int64(0) if parsed, err := strconv.ParseInt(query, 10, 64); err == nil && parsed > 0 { queryID = parsed } return pattern, queryID } func clampBotVerificationLimit(limit int) int { if limit <= 0 { return botVerificationListDefaultLimit } if limit > botVerificationListMaxLimit { return botVerificationListMaxLimit } return limit } // storageUsageListDefaultLimit/storageUsageListMaxLimit bound the per-account // storage breakdown page, same convention as the account rating list above. const ( storageUsageListDefaultLimit = 50 storageUsageListMaxLimit = 200 ) // perOwnerMediaSizeSQL is shared between StorageStats and // ListAccountStorageUsage: each document row's size is its stored column; // each photo row has no single size column (JSONB sizes holds one entry per // rendition), so its "attributed size" is the largest rendition -- the // dominant cost, thumbnails are comparatively tiny. This is an // approximation (not the exact sum of every rendition's blob bytes, which // would require joining file_blobs by location_key prefix) chosen for admin // visibility, not billing precision. const perOwnerMediaSizeSQL = ` SELECT owner_user_id, size FROM documents UNION ALL SELECT p.owner_user_id, COALESCE(( SELECT MAX((elem->>'size')::bigint) FROM jsonb_array_elements(p.sizes) elem ), 0) AS size FROM photos p ` // StorageStatsRow is the admin panel's storage overview: physical bytes // (from file_blobs, backend-dedup-aware -- what's actually consuming disk // or S3) versus logical bytes (sum of the same approximate per-row // attribution the per-account breakdown uses, which can legitimately be // higher than physical when identical content is shared by more than one // document/photo). type StorageStatsRow struct { PhysicalBytes int64 `json:"PhysicalBytes,string"` LogicalBytes int64 `json:"LogicalBytes,string"` UnattributedBytes int64 `json:"UnattributedBytes,string"` DocumentCount int64 `json:"DocumentCount,string"` PhotoCount int64 `json:"PhotoCount,string"` AccountCount int64 `json:"AccountCount,string"` BackendKind string } // StorageStats returns the admin panel's storage overview. func (s *readStore) StorageStats(ctx context.Context) (StorageStatsRow, error) { var stats StorageStatsRow if err := s.pool.QueryRow(ctx, `SELECT COALESCE(SUM(size), 0)::bigint FROM file_blobs`).Scan(&stats.PhysicalBytes); err != nil { return StorageStatsRow{}, fmt.Errorf("sum physical blob bytes: %w", err) } if err := s.pool.QueryRow(ctx, ` SELECT COALESCE(SUM(size), 0)::bigint FROM (`+perOwnerMediaSizeSQL+`) x`).Scan(&stats.LogicalBytes); err != nil { return StorageStatsRow{}, fmt.Errorf("sum logical media bytes: %w", err) } if err := s.pool.QueryRow(ctx, ` SELECT COALESCE(SUM(size), 0)::bigint FROM (`+perOwnerMediaSizeSQL+`) x WHERE owner_user_id = 0`).Scan(&stats.UnattributedBytes); err != nil { return StorageStatsRow{}, fmt.Errorf("sum unattributed media bytes: %w", err) } if err := s.pool.QueryRow(ctx, `SELECT count(*)::bigint FROM documents`).Scan(&stats.DocumentCount); err != nil { return StorageStatsRow{}, fmt.Errorf("count documents: %w", err) } if err := s.pool.QueryRow(ctx, `SELECT count(*)::bigint FROM photos`).Scan(&stats.PhotoCount); err != nil { return StorageStatsRow{}, fmt.Errorf("count photos: %w", err) } if err := s.pool.QueryRow(ctx, ` SELECT count(DISTINCT owner_user_id)::bigint FROM (`+perOwnerMediaSizeSQL+`) x WHERE owner_user_id <> 0`).Scan(&stats.AccountCount); err != nil { return StorageStatsRow{}, fmt.Errorf("count storage accounts: %w", err) } stats.BackendKind = strings.ToLower(strings.TrimSpace(os.Getenv("TELESRV_BLOB_BACKEND"))) if stats.BackendKind == "" { stats.BackendKind = "s3" } return stats, nil } // AccountStorageRow is one account's row in the per-account storage // breakdown table. type AccountStorageRow struct { UserID int64 `json:"UserID,string"` Username string FirstName string Bytes int64 `json:"Bytes,string"` FileCount int64 `json:"FileCount,string"` } // ListAccountStorageUsage pages the per-account storage breakdown, largest // user first. Offset-based (not keyset): storage administration on a // self-hosted deployment doesn't need to support arbitrarily deep pages // efficiently the way an infinite-scroll feed does. func (s *readStore) ListAccountStorageUsage(ctx context.Context, offset, limit int) ([]AccountStorageRow, bool, error) { if limit <= 0 { limit = storageUsageListDefaultLimit } if limit > storageUsageListMaxLimit { limit = storageUsageListMaxLimit } if offset < 0 { offset = 0 } rows, err := s.pool.Query(ctx, ` WITH totals AS ( SELECT owner_user_id, SUM(size)::bigint AS bytes, COUNT(*)::bigint AS file_count FROM (`+perOwnerMediaSizeSQL+`) x WHERE owner_user_id <> 0 GROUP BY owner_user_id ) SELECT t.owner_user_id, COALESCE(u.username, ''), COALESCE(u.first_name, ''), t.bytes, t.file_count FROM totals t LEFT JOIN users u ON u.id = t.owner_user_id ORDER BY t.bytes DESC, t.owner_user_id OFFSET $1 LIMIT $2`, offset, limit+1) if err != nil { return nil, false, fmt.Errorf("list account storage usage: %w", err) } defer rows.Close() out := make([]AccountStorageRow, 0, limit+1) for rows.Next() { var item AccountStorageRow if err := rows.Scan(&item.UserID, &item.Username, &item.FirstName, &item.Bytes, &item.FileCount); err != nil { return nil, false, err } out = append(out, item) } if err := rows.Err(); err != nil { return nil, false, err } hasMore := len(out) > limit if hasMore { out = out[:limit] } return out, hasMore, nil }