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
22846e340f
commit
2bdb1ecf37
21 changed files with 843 additions and 6 deletions
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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue