usernames: operator reserved-username blocklist
A plain blocklist for names like @support - separate from the collectible
system, so a reservation has no owner, no price and no "bought on Fragment"
badge.
- reserved_usernames table + migration.
- Enforced in replacePeerUsernameTx (the single editable-username write point:
account.updateUsername, channels.updateUsername, @BotFather /setusername) and
in the collectible mint path; a reserved name returns USERNAME_OCCUPIED.
- admin.Service: ReserveUsername / UnreserveUsername (journalled commands) and
the ReservedUsernames listing.
- adminapi: /v1/reserved-usernames{,/reserve,/unreserve}.
- telesrv-admin panel + a "Reserved Usernames" page in the web UI (dist rebuilt).
- Postgres and in-memory store implementations; the memory registry gains an
optional reserved-name check so tests exercise the same rule.
This commit is contained in:
parent
d2ffaa92bf
commit
a83aa45fb8
23 changed files with 874 additions and 39 deletions
|
|
@ -55,6 +55,24 @@ type CollectibleUsernameStore struct {
|
|||
transfers map[int64][]domain.CollectibleUsernameTransfer
|
||||
// commands maps a provenance command key onto the asset it touched.
|
||||
commands map[string]int64
|
||||
// reserved, when set, is the operator blocklist consulted before a name is
|
||||
// assigned to an editable slot or minted, mirroring the PostgreSQL checks.
|
||||
reserved *ReservedUsernameStore
|
||||
}
|
||||
|
||||
// WithReservedUsernames wires the operator blocklist into the registry so a
|
||||
// reserved name is refused, matching PostgreSQL.
|
||||
func (s *CollectibleUsernameStore) WithReservedUsernames(reserved *ReservedUsernameStore) *CollectibleUsernameStore {
|
||||
s.reserved = reserved
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *CollectibleUsernameStore) nameReservedLocked(usernameLower string) bool {
|
||||
if s.reserved == nil {
|
||||
return false
|
||||
}
|
||||
r, _ := s.reserved.IsReserved(context.Background(), usernameLower)
|
||||
return r
|
||||
}
|
||||
|
||||
// collectibleRegistryRow is one peer_usernames row: the owning peer plus the
|
||||
|
|
@ -101,6 +119,9 @@ func (s *CollectibleUsernameStore) SetEditableUsername(_ context.Context, peer d
|
|||
return false, domain.ErrUsernameInvalid
|
||||
}
|
||||
key := strings.ToLower(username)
|
||||
if s.nameReservedLocked(key) {
|
||||
return false, domain.ErrUsernameOccupied
|
||||
}
|
||||
if existing, ok := s.registry[key]; ok {
|
||||
if existing.peer == peer && existing.row.Editable {
|
||||
if existing.row.Username == username {
|
||||
|
|
@ -313,6 +334,9 @@ func (s *CollectibleUsernameStore) MintCollectibleUsername(_ context.Context, re
|
|||
if _, ok := s.registry[key]; ok {
|
||||
return domain.CollectibleUsername{}, false, domain.ErrUsernameOccupied
|
||||
}
|
||||
if s.nameReservedLocked(key) {
|
||||
return domain.CollectibleUsername{}, false, domain.ErrUsernameOccupied
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
purchaseDate := req.PurchaseDate
|
||||
if purchaseDate.IsZero() {
|
||||
|
|
|
|||
100
internal/store/memory/reserved_username.go
Normal file
100
internal/store/memory/reserved_username.go
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// ReservedUsernameStore is the in-memory operator username blocklist.
|
||||
type ReservedUsernameStore struct {
|
||||
mu sync.Mutex
|
||||
entries map[string]domain.ReservedUsername // keyed by username_lower
|
||||
}
|
||||
|
||||
// NewReservedUsernameStore creates an empty blocklist.
|
||||
func NewReservedUsernameStore() *ReservedUsernameStore {
|
||||
return &ReservedUsernameStore{entries: make(map[string]domain.ReservedUsername)}
|
||||
}
|
||||
|
||||
func (s *ReservedUsernameStore) IsReserved(_ context.Context, usernameLower string) (bool, error) {
|
||||
if s == nil {
|
||||
return false, nil
|
||||
}
|
||||
usernameLower = strings.ToLower(strings.TrimSpace(usernameLower))
|
||||
if usernameLower == "" {
|
||||
return false, nil
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
_, ok := s.entries[usernameLower]
|
||||
return ok, nil
|
||||
}
|
||||
|
||||
func (s *ReservedUsernameStore) ReserveUsername(_ context.Context, username, reason, actor string) (bool, error) {
|
||||
username = strings.TrimSpace(username)
|
||||
lower := strings.ToLower(username)
|
||||
if lower == "" {
|
||||
return false, domain.ErrUsernameInvalid
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if _, ok := s.entries[lower]; ok {
|
||||
return false, nil
|
||||
}
|
||||
s.entries[lower] = domain.ReservedUsername{Username: username, Reason: reason, Actor: actor, CreatedAt: time.Now().UTC()}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (s *ReservedUsernameStore) UnreserveUsername(_ context.Context, username string) (bool, error) {
|
||||
lower := strings.ToLower(strings.TrimSpace(username))
|
||||
if lower == "" {
|
||||
return false, domain.ErrUsernameInvalid
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if _, ok := s.entries[lower]; !ok {
|
||||
return false, nil
|
||||
}
|
||||
delete(s.entries, lower)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (s *ReservedUsernameStore) ReservedUsernames(_ context.Context, filter domain.ReservedUsernameFilter) ([]domain.ReservedUsername, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
q := strings.ToLower(strings.TrimSpace(filter.Query))
|
||||
out := make([]domain.ReservedUsername, 0, len(s.entries))
|
||||
for key, entry := range s.entries {
|
||||
if q != "" && !strings.HasPrefix(key, q) {
|
||||
continue
|
||||
}
|
||||
out = append(out, entry)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if !out[i].CreatedAt.Equal(out[j].CreatedAt) {
|
||||
return out[i].CreatedAt.After(out[j].CreatedAt)
|
||||
}
|
||||
return strings.ToLower(out[i].Username) < strings.ToLower(out[j].Username)
|
||||
})
|
||||
limit := filter.Limit
|
||||
if limit <= 0 || limit > 500 {
|
||||
limit = 100
|
||||
}
|
||||
offset := filter.Offset
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
if offset >= len(out) {
|
||||
return []domain.ReservedUsername{}, nil
|
||||
}
|
||||
end := offset + limit
|
||||
if end > len(out) {
|
||||
end = len(out)
|
||||
}
|
||||
return out[offset:end], nil
|
||||
}
|
||||
|
|
@ -206,6 +206,11 @@ func (s *CollectibleUsernameStore) MintCollectibleUsername(ctx context.Context,
|
|||
} else if found {
|
||||
return domain.ErrUsernameOccupied
|
||||
}
|
||||
if reserved, err := usernameReservedTx(ctx, tx, usernameLower); err != nil {
|
||||
return err
|
||||
} else if reserved {
|
||||
return domain.ErrUsernameOccupied
|
||||
}
|
||||
var existing int64
|
||||
switch err := tx.QueryRow(ctx, `
|
||||
SELECT id FROM collectible_usernames
|
||||
|
|
|
|||
|
|
@ -61,6 +61,21 @@ func getPeerUsernameOwner(ctx context.Context, db sqlcgen.DBTX, usernameLower st
|
|||
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) {
|
||||
owner, found, err := getPeerUsernameOwner(ctx, db, usernameLower, false)
|
||||
if err != nil || !found {
|
||||
|
|
@ -111,6 +126,11 @@ WHERE peer_type = $1
|
|||
// 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 {
|
||||
if usernameLower != "" {
|
||||
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
|
||||
|
|
|
|||
99
internal/store/postgres/reserved_username.go
Normal file
99
internal/store/postgres/reserved_username.go
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store/postgres/sqlcgen"
|
||||
)
|
||||
|
||||
// ReservedUsernameStore is the operator username blocklist backed by the
|
||||
// reserved_usernames table.
|
||||
type ReservedUsernameStore struct {
|
||||
db sqlcgen.DBTX
|
||||
}
|
||||
|
||||
// NewReservedUsernameStore builds the store on a pgx pool or transaction.
|
||||
func NewReservedUsernameStore(db sqlcgen.DBTX) *ReservedUsernameStore {
|
||||
return &ReservedUsernameStore{db: db}
|
||||
}
|
||||
|
||||
func (s *ReservedUsernameStore) IsReserved(ctx context.Context, usernameLower string) (bool, error) {
|
||||
usernameLower = strings.ToLower(strings.TrimSpace(usernameLower))
|
||||
if usernameLower == "" {
|
||||
return false, nil
|
||||
}
|
||||
var exists bool
|
||||
if err := s.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 (s *ReservedUsernameStore) ReserveUsername(ctx context.Context, username, reason, actor string) (bool, error) {
|
||||
username = strings.TrimSpace(username)
|
||||
lower := strings.ToLower(username)
|
||||
if lower == "" {
|
||||
return false, domain.ErrUsernameInvalid
|
||||
}
|
||||
tag, err := s.db.Exec(ctx, `
|
||||
INSERT INTO reserved_usernames (username_lower, username, reason, actor)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (username_lower) DO NOTHING`, lower, username, reason, actor)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("reserve username: %w", err)
|
||||
}
|
||||
return tag.RowsAffected() > 0, nil
|
||||
}
|
||||
|
||||
func (s *ReservedUsernameStore) UnreserveUsername(ctx context.Context, username string) (bool, error) {
|
||||
lower := strings.ToLower(strings.TrimSpace(username))
|
||||
if lower == "" {
|
||||
return false, domain.ErrUsernameInvalid
|
||||
}
|
||||
tag, err := s.db.Exec(ctx, `DELETE FROM reserved_usernames WHERE username_lower = $1`, lower)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("unreserve username: %w", err)
|
||||
}
|
||||
return tag.RowsAffected() > 0, nil
|
||||
}
|
||||
|
||||
func (s *ReservedUsernameStore) ReservedUsernames(ctx context.Context, filter domain.ReservedUsernameFilter) ([]domain.ReservedUsername, error) {
|
||||
limit := filter.Limit
|
||||
if limit <= 0 || limit > 500 {
|
||||
limit = 100
|
||||
}
|
||||
offset := filter.Offset
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
args := []any{limit, offset}
|
||||
where := ""
|
||||
if q := strings.ToLower(strings.TrimSpace(filter.Query)); q != "" {
|
||||
args = append(args, q+"%")
|
||||
where = "WHERE username_lower LIKE $3"
|
||||
}
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT username, reason, actor, created_at
|
||||
FROM reserved_usernames
|
||||
`+where+`
|
||||
ORDER BY created_at DESC, username_lower
|
||||
LIMIT $1 OFFSET $2`, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list reserved usernames: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]domain.ReservedUsername, 0, limit)
|
||||
for rows.Next() {
|
||||
var item domain.ReservedUsername
|
||||
if err := rows.Scan(&item.Username, &item.Reason, &item.Actor, &item.CreatedAt); err != nil {
|
||||
return nil, fmt.Errorf("scan reserved username: %w", err)
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
22
internal/store/reserved_username.go
Normal file
22
internal/store/reserved_username.go
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
// ReservedUsernameStore owns the operator username blocklist. IsReserved is the
|
||||
// hot path consulted on every editable-username write; the rest are the admin
|
||||
// lifecycle.
|
||||
type ReservedUsernameStore interface {
|
||||
// IsReserved reports whether usernameLower (already lowercased) is blocked.
|
||||
IsReserved(ctx context.Context, usernameLower string) (bool, error)
|
||||
// ReserveUsername adds an entry. Returns created=false if it already existed
|
||||
// (the existing reason/actor are kept).
|
||||
ReserveUsername(ctx context.Context, username, reason, actor string) (created bool, err error)
|
||||
// UnreserveUsername removes an entry. Returns removed=false if absent.
|
||||
UnreserveUsername(ctx context.Context, username string) (removed bool, err error)
|
||||
// ReservedUsernames pages the blocklist, newest first.
|
||||
ReservedUsernames(ctx context.Context, filter domain.ReservedUsernameFilter) ([]domain.ReservedUsername, error)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue