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.
355 lines
14 KiB
Go
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
|
|
}
|