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.
99 lines
3 KiB
Go
99 lines
3 KiB
Go
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()
|
|
}
|