owpengram-server/internal/store/postgres/peer_username.go
Astra ea17d7da0d admin: bypass the reserved-username blocklist at write time too
UpdateUsernameAdmin already skipped the reserved-word check in the
availability lookup, but UserStore.UpdateUsername's own write path
(replacePeerUsernameTx / CollectibleUsernameStore.SetEditableUsername)
enforces the same operator blocklist a second time, independently and
unconditionally. That second check is what was still rejecting an admin
handing out a word they'd deliberately reserved, with "username occupied".

Add UpdateUsernameAdmin/SetEditableUsernameAdmin bypass variants down the
write path (postgres and memory) and route users.Service's actual write
through them when the availability check was already bypassed.
2026-09-15 16:22:00 +01:00

355 lines
14 KiB
Go

package postgres
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgerrcode"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"telesrv/internal/domain"
"telesrv/internal/store/postgres/sqlcgen"
)
const (
peerUsernameTypeUser = "user"
peerUsernameTypeChannel = "channel"
)
// peerUsernameColumns is the registry projection shared by every reader. The
// collectible id is coalesced so domain.Username keeps a plain int64 zero for
// the editable slot.
const peerUsernameColumns = `username, active, editable, sort_order, COALESCE(collectible_id, 0)`
type peerUsernameOwner struct {
peerType string
peerID int64
// collectible marks a row backed by a collectible asset. Such a row is never
// editable, so the client-driven username path must not reuse or delete it.
collectible bool
// active mirrors the registry flag. An inactive name stays occupied for
// uniqueness purposes but must not resolve to its holder.
active bool
// editable distinguishes the ordinary username slot from any future
// non-collectible reserved rows. Only an ordinary editable user slot may be
// displaced by the official product-username claim.
editable bool
}
func (o peerUsernameOwner) matches(peerType string, peerID int64) bool {
return o.peerType == peerType && o.peerID == peerID
}
// getPeerUsernameOwner resolves the holder of a name across the whole registry:
// username uniqueness is global and covers collectible rows as well as editable
// ones, so occupancy checks and username resolution keep a single source of
// truth.
func getPeerUsernameOwner(ctx context.Context, db sqlcgen.DBTX, usernameLower string, forUpdate bool) (peerUsernameOwner, bool, error) {
if usernameLower == "" {
return peerUsernameOwner{}, false, nil
}
query := `SELECT peer_type, peer_id, collectible_id IS NOT NULL, active, editable FROM peer_usernames WHERE username_lower = $1`
if forUpdate {
query += ` FOR UPDATE`
}
var owner peerUsernameOwner
err := db.QueryRow(ctx, query, usernameLower).Scan(&owner.peerType, &owner.peerID, &owner.collectible, &owner.active, &owner.editable)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return peerUsernameOwner{}, false, nil
}
return peerUsernameOwner{}, false, fmt.Errorf("get peer username owner: %w", err)
}
return owner, true, nil
}
// usernameReservedTx reports whether a name is on the operator blocklist. It is
// consulted before every editable-username write and before a collectible mint.
func usernameReservedTx(ctx context.Context, db sqlcgen.DBTX, usernameLower string) (bool, error) {
if usernameLower == "" {
return false, nil
}
var exists bool
if err := db.QueryRow(ctx,
`SELECT EXISTS (SELECT 1 FROM reserved_usernames WHERE username_lower = $1)`,
usernameLower).Scan(&exists); err != nil {
return false, fmt.Errorf("check reserved username: %w", err)
}
return exists, nil
}
func peerUsernameAvailable(ctx context.Context, db sqlcgen.DBTX, usernameLower, peerType string, peerID int64) (bool, error) {
return peerUsernameAvailableChecked(ctx, db, usernameLower, peerType, peerID, true)
}
// peerUsernameAvailableChecked is peerUsernameAvailable with the operator
// blocklist check optional: an operator deliberately reserving a word still
// needs to be able to hand it to a specific account via the admin console,
// so the admin-initiated username-set path skips it (checkReserved=false)
// while self-service username changes always enforce it.
func peerUsernameAvailableChecked(ctx context.Context, db sqlcgen.DBTX, usernameLower, peerType string, peerID int64, checkReserved bool) (bool, error) {
if checkReserved {
if reserved, err := usernameReservedTx(ctx, db, usernameLower); err != nil {
return false, err
} else if reserved {
return false, nil
}
}
owner, found, err := getPeerUsernameOwner(ctx, db, usernameLower, false)
if err != nil || !found {
return !found, err
}
return owner.matches(peerType, peerID), nil
}
// activeCollectibleUsernamePeerIDs returns the requested peers that own at
// least one active collectible username. Editable registry rows are excluded:
// their scalar users.username/channels.username value is the cross-check that
// prevents a stale registry row from making a private peer public.
func activeCollectibleUsernamePeerIDs(ctx context.Context, db sqlcgen.DBTX, peerType string, peerIDs []int64) (map[int64]struct{}, error) {
out := make(map[int64]struct{})
if len(peerIDs) == 0 {
return out, nil
}
rows, err := db.Query(ctx, `
SELECT DISTINCT peer_id
FROM peer_usernames
WHERE peer_type = $1
AND active
AND collectible_id IS NOT NULL
AND peer_id = ANY($2::bigint[])`, peerType, peerIDs)
if err != nil {
return nil, fmt.Errorf("list peers with active usernames: %w", err)
}
defer rows.Close()
for rows.Next() {
var peerID int64
if err := rows.Scan(&peerID); err != nil {
return nil, fmt.Errorf("scan peer with active username: %w", err)
}
out[peerID] = struct{}{}
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("list peers with active usernames: %w", err)
}
return out, nil
}
// replacePeerUsernameTx rewrites the peer's editable username slot. username is
// the display form (original case) and usernameLower its registry key; an empty
// pair clears the slot.
//
// Only the editable row is replaced. Collectible rows belong to assets in
// collectible_usernames and must survive every client-driven username edit,
// otherwise account.updateUsername would silently release a minted asset.
func replacePeerUsernameTx(ctx context.Context, tx pgx.Tx, peerType string, peerID int64, username, usernameLower string) error {
return replacePeerUsernameTxChecked(ctx, tx, peerType, peerID, username, usernameLower, true)
}
// replacePeerUsernameTxChecked is replacePeerUsernameTx with the operator
// blocklist check optional: the admin-initiated username-set path
// (UserStore.UpdateUsernameAdmin) skips it so an operator can deliberately
// hand a reserved word to a specific account, while every other caller
// (self-service, bots, channel settings, account deletion) always enforces
// it via replacePeerUsernameTx.
func replacePeerUsernameTxChecked(ctx context.Context, tx pgx.Tx, peerType string, peerID int64, username, usernameLower string, checkReserved bool) error {
if usernameLower != "" {
if checkReserved {
if reserved, err := usernameReservedTx(ctx, tx, usernameLower); err != nil {
return err
} else if reserved {
return domain.ErrUsernameOccupied
}
}
owner, found, err := getPeerUsernameOwner(ctx, tx, usernameLower, true)
if err != nil {
return err
}
// A collectible row occupies the name even for its own holder: the
// editable slot cannot duplicate a name the peer already holds as an asset.
if found && (!owner.matches(peerType, peerID) || owner.collectible) {
return domain.ErrUsernameOccupied
}
}
if _, err := tx.Exec(ctx, `
DELETE FROM peer_usernames WHERE peer_type = $1 AND peer_id = $2 AND editable`, peerType, peerID); err != nil {
return fmt.Errorf("delete peer username: %w", err)
}
if usernameLower == "" {
return nil
}
if _, err := tx.Exec(ctx, `
INSERT INTO peer_usernames (username_lower, peer_type, peer_id, username, active, editable, sort_order, collectible_id)
VALUES ($1, $2, $3, $4, true, true, 0, NULL)`, usernameLower, peerType, peerID, username); err != nil {
if isUniqueViolation(err) {
return domain.ErrUsernameOccupied
}
return fmt.Errorf("insert peer username: %w", err)
}
return nil
}
// deletePeerUsernameTx clears the peer's editable slot only. Collectible rows are
// released through the asset lifecycle (revoke/burn) or by the peer-deletion
// trigger, never by an editable-slot edit.
func deletePeerUsernameTx(ctx context.Context, tx pgx.Tx, peerType string, peerID int64) error {
if _, err := tx.Exec(ctx, `
DELETE FROM peer_usernames WHERE peer_type = $1 AND peer_id = $2 AND editable`, peerType, peerID); err != nil {
return fmt.Errorf("delete peer username: %w", err)
}
return nil
}
// listPeerUsernames returns the peer's registry rows in projection order:
// editable slot first, then collectibles by stored sort order.
func listPeerUsernames(ctx context.Context, db sqlcgen.DBTX, peer domain.Peer) ([]domain.Username, error) {
if peer.Type == "" || peer.ID <= 0 {
return nil, nil
}
rows, err := db.Query(ctx, `
SELECT `+peerUsernameColumns+`
FROM peer_usernames
WHERE peer_type = $1 AND peer_id = $2
ORDER BY editable DESC, sort_order, username_lower`, string(peer.Type), peer.ID)
if err != nil {
return nil, fmt.Errorf("list peer usernames: %w", err)
}
defer rows.Close()
out := make([]domain.Username, 0, 4)
for rows.Next() {
var item domain.Username
if err := rows.Scan(&item.Username, &item.Active, &item.Editable, &item.SortOrder, &item.CollectibleID); err != nil {
return nil, fmt.Errorf("scan peer username: %w", err)
}
out = append(out, item)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate peer usernames: %w", err)
}
return domain.SortUsernames(out), nil
}
// listPeerUsernamesBatch resolves several peers in one round trip.
func listPeerUsernamesBatch(ctx context.Context, db sqlcgen.DBTX, peers []domain.Peer) (map[domain.Peer][]domain.Username, error) {
out := make(map[domain.Peer][]domain.Username, len(peers))
types := make([]string, 0, len(peers))
ids := make([]int64, 0, len(peers))
seen := make(map[domain.Peer]struct{}, len(peers))
for _, peer := range peers {
if peer.Type == "" || peer.ID <= 0 {
continue
}
if _, dup := seen[peer]; dup {
continue
}
seen[peer] = struct{}{}
types = append(types, string(peer.Type))
ids = append(ids, peer.ID)
}
if len(types) == 0 {
return out, nil
}
rows, err := db.Query(ctx, `
SELECT peer_type, peer_id, `+peerUsernameColumns+`
FROM peer_usernames
WHERE (peer_type, peer_id) IN (SELECT t, i FROM unnest($1::text[], $2::bigint[]) AS s(t, i))
ORDER BY peer_type, peer_id, editable DESC, sort_order, username_lower`, types, ids)
if err != nil {
return nil, fmt.Errorf("list peer usernames batch: %w", err)
}
defer rows.Close()
for rows.Next() {
var peer domain.Peer
var peerType string
var item domain.Username
if err := rows.Scan(&peerType, &peer.ID, &item.Username, &item.Active, &item.Editable,
&item.SortOrder, &item.CollectibleID); err != nil {
return nil, fmt.Errorf("scan peer username batch: %w", err)
}
peer.Type = domain.PeerType(peerType)
out[peer] = append(out[peer], item)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate peer usernames batch: %w", err)
}
for peer, list := range out {
out[peer] = domain.SortUsernames(list)
}
return out, nil
}
// lockPeerUsernamesTx reads the peer's registry rows with row locks so a toggle
// or reorder validated against the list cannot race a concurrent asset move.
func lockPeerUsernamesTx(ctx context.Context, tx pgx.Tx, peer domain.Peer) ([]domain.Username, error) {
rows, err := tx.Query(ctx, `
SELECT `+peerUsernameColumns+`
FROM peer_usernames
WHERE peer_type = $1 AND peer_id = $2
ORDER BY username_lower
FOR UPDATE`, string(peer.Type), peer.ID)
if err != nil {
return nil, fmt.Errorf("lock peer usernames: %w", err)
}
defer rows.Close()
out := make([]domain.Username, 0, 4)
for rows.Next() {
var item domain.Username
if err := rows.Scan(&item.Username, &item.Active, &item.Editable, &item.SortOrder, &item.CollectibleID); err != nil {
return nil, fmt.Errorf("scan locked peer username: %w", err)
}
out = append(out, item)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate locked peer usernames: %w", err)
}
return domain.SortUsernames(out), nil
}
// countPeerCollectibleUsernamesTx bounds the collectible rows a peer may hold.
func countPeerCollectibleUsernamesTx(ctx context.Context, tx pgx.Tx, peerType string, peerID int64) (int, error) {
var count int
if err := tx.QueryRow(ctx, `
SELECT count(*) FROM peer_usernames
WHERE peer_type = $1 AND peer_id = $2 AND collectible_id IS NOT NULL`, peerType, peerID).Scan(&count); err != nil {
return 0, fmt.Errorf("count peer collectible usernames: %w", err)
}
return count, nil
}
// insertCollectiblePeerUsernameTx projects an owned asset into the registry. The
// row sorts after every collectible the peer already holds and always carries
// collectible_id, which the schema forbids on an editable row.
func insertCollectiblePeerUsernameTx(ctx context.Context, tx pgx.Tx, peerType string, peerID int64, username, usernameLower string, collectibleID int64) error {
if _, err := tx.Exec(ctx, `
INSERT INTO peer_usernames (username_lower, peer_type, peer_id, username, active, editable, sort_order, collectible_id, updated_at)
SELECT $1, $2, $3, $4, true, false, LEAST(
COALESCE((
SELECT max(sort_order) + 1 FROM peer_usernames
WHERE peer_type = $2 AND peer_id = $3 AND collectible_id IS NOT NULL
), 0), $6::int
), $5, now()`,
usernameLower, peerType, peerID, username, collectibleID, domain.MaxUsernameSortOrder); err != nil {
if isUniqueViolation(err) {
return domain.ErrUsernameOccupied
}
return fmt.Errorf("insert collectible peer username: %w", err)
}
return nil
}
// deleteCollectiblePeerUsernameTx removes the registry projection of an asset,
// releasing the name for anyone else the moment the asset stops being owned.
func deleteCollectiblePeerUsernameTx(ctx context.Context, tx pgx.Tx, collectibleID int64) error {
if _, err := tx.Exec(ctx, `
DELETE FROM peer_usernames WHERE collectible_id = $1`, collectibleID); err != nil {
return fmt.Errorf("delete collectible peer username: %w", err)
}
return nil
}
func isUniqueConstraint(err error, constraintName string) bool {
var pgErr *pgconn.PgError
return errors.As(err, &pgErr) && pgErr.Code == pgerrcode.UniqueViolation && pgErr.ConstraintName == constraintName
}