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
|
|
@ -66,6 +66,9 @@ const (
|
|||
ActionTransferCollectibleUsername = "usernames.collectible.transfer"
|
||||
ActionRevokeCollectibleUsername = "usernames.collectible.revoke"
|
||||
ActionDeleteCollectibleUsername = "usernames.collectible.delete"
|
||||
// Operator username blocklist.
|
||||
ActionReserveUsername = "usernames.reserve"
|
||||
ActionUnreserveUsername = "usernames.unreserve"
|
||||
// Official platform verification review. Claim/approve/reject act on one
|
||||
// application; revoke acts on a target, because clearing a badge is not a
|
||||
// decision on the application that granted it.
|
||||
|
|
@ -363,6 +366,16 @@ type CollectibleUsernamesService interface {
|
|||
Transfers(ctx context.Context, collectibleID int64, limit int) ([]domain.CollectibleUsernameTransfer, error)
|
||||
}
|
||||
|
||||
// ReservedUsernamesService is the operator username blocklist: a plain list of
|
||||
// names no peer may take. Separate from the collectible lifecycle - a reservation
|
||||
// has no owner, no price and no Fragment badge.
|
||||
type ReservedUsernamesService interface {
|
||||
IsReserved(ctx context.Context, usernameLower string) (bool, error)
|
||||
ReserveUsername(ctx context.Context, username, reason, actor string) (created bool, err error)
|
||||
UnreserveUsername(ctx context.Context, username string) (removed bool, err error)
|
||||
ReservedUsernames(ctx context.Context, filter domain.ReservedUsernameFilter) ([]domain.ReservedUsername, error)
|
||||
}
|
||||
|
||||
// collectibleUsernameByIDLookup is the optional by-identity read. Stores that
|
||||
// expose it answer a detail request in one round trip; the keyset fallback in
|
||||
// CollectibleUsernameByID keeps a service without it correct.
|
||||
|
|
@ -389,6 +402,7 @@ type Dependencies struct {
|
|||
Emoji EmojiService
|
||||
Moderation ModerationService
|
||||
Usernames CollectibleUsernamesService
|
||||
ReservedUsernames ReservedUsernamesService
|
||||
Verification VerificationService
|
||||
// BotVerification is the third-party mechanism, wired separately from
|
||||
// Verification: the two never read each other's state.
|
||||
|
|
@ -420,6 +434,7 @@ type Service struct {
|
|||
emoji EmojiService
|
||||
moderation ModerationService
|
||||
usernames CollectibleUsernamesService
|
||||
reservedUsernames ReservedUsernamesService
|
||||
verification VerificationService
|
||||
botVerification BotVerificationService
|
||||
account AccountService
|
||||
|
|
@ -487,6 +502,9 @@ func (s *Service) Configure(deps Dependencies) *Service {
|
|||
if deps.Usernames != nil {
|
||||
s.usernames = deps.Usernames
|
||||
}
|
||||
if deps.ReservedUsernames != nil {
|
||||
s.reservedUsernames = deps.ReservedUsernames
|
||||
}
|
||||
if deps.Verification != nil {
|
||||
s.verification = deps.Verification
|
||||
}
|
||||
|
|
@ -2059,6 +2077,91 @@ func (s *Service) DeleteCollectibleUsername(ctx context.Context, req DeleteColle
|
|||
})
|
||||
}
|
||||
|
||||
// ReserveUsernameRequest / UnreserveUsernameRequest add or remove a blocklist
|
||||
// entry. reservedUsernameFromRequest normalises the name; the reason is a free
|
||||
// operator note.
|
||||
type ReserveUsernameRequest struct {
|
||||
CommandMeta
|
||||
Username string
|
||||
}
|
||||
|
||||
type UnreserveUsernameRequest struct {
|
||||
CommandMeta
|
||||
Username string
|
||||
}
|
||||
|
||||
// ReserveUsername adds a name to the operator blocklist. Journalled and
|
||||
// replay-safe like every other command.
|
||||
func (s *Service) ReserveUsername(ctx context.Context, req ReserveUsernameRequest) (CommandResult, error) {
|
||||
if s == nil || s.reservedUsernames == nil {
|
||||
return CommandResult{}, fmt.Errorf("admin reserved username dependency is not configured")
|
||||
}
|
||||
req.Username = domain.NormalizeUsername(req.Username)
|
||||
if !domain.ValidCollectibleUsername(req.Username) {
|
||||
return CommandResult{}, codedError(CodeUsernameInvalid, domain.ErrUsernameInvalid)
|
||||
}
|
||||
if len(req.Reason) > domain.MaxReservedUsernameReasonLength {
|
||||
return CommandResult{}, fmt.Errorf("reason must be <= %d bytes", domain.MaxReservedUsernameReasonLength)
|
||||
}
|
||||
return s.runCommand(ctx, req.CommandMeta, ActionReserveUsername, 0, domain.Peer{}, req, func() (CommandResult, error) {
|
||||
details := map[string]any{"username": req.Username}
|
||||
if s.usernames != nil {
|
||||
if asset, err := s.usernames.Collectible(ctx, req.Username); err == nil {
|
||||
details["existing_collectible_id"] = strconv.FormatInt(asset.ID, 10)
|
||||
return CommandResult{Details: details}, codedError(CodeUsernameOccupied, domain.ErrUsernameOccupied)
|
||||
}
|
||||
}
|
||||
if req.DryRun {
|
||||
return CommandResult{Message: "username reservation validated", Details: details}, nil
|
||||
}
|
||||
created, err := s.reservedUsernames.ReserveUsername(ctx, req.Username, req.Reason, req.Actor)
|
||||
if err != nil {
|
||||
return CommandResult{Details: details}, err
|
||||
}
|
||||
details["created"] = created
|
||||
message := "username reserved"
|
||||
if !created {
|
||||
message = "username was already reserved"
|
||||
}
|
||||
return CommandResult{Message: message, Details: details}, nil
|
||||
})
|
||||
}
|
||||
|
||||
// UnreserveUsername removes a name from the operator blocklist.
|
||||
func (s *Service) UnreserveUsername(ctx context.Context, req UnreserveUsernameRequest) (CommandResult, error) {
|
||||
if s == nil || s.reservedUsernames == nil {
|
||||
return CommandResult{}, fmt.Errorf("admin reserved username dependency is not configured")
|
||||
}
|
||||
req.Username = domain.NormalizeUsername(req.Username)
|
||||
if strings.TrimSpace(req.Username) == "" {
|
||||
return CommandResult{}, codedError(CodeUsernameInvalid, domain.ErrUsernameInvalid)
|
||||
}
|
||||
return s.runCommand(ctx, req.CommandMeta, ActionUnreserveUsername, 0, domain.Peer{}, req, func() (CommandResult, error) {
|
||||
details := map[string]any{"username": req.Username}
|
||||
if req.DryRun {
|
||||
return CommandResult{Message: "username unreservation validated", Details: details}, nil
|
||||
}
|
||||
removed, err := s.reservedUsernames.UnreserveUsername(ctx, req.Username)
|
||||
if err != nil {
|
||||
return CommandResult{Details: details}, err
|
||||
}
|
||||
details["removed"] = removed
|
||||
message := "username unreserved"
|
||||
if !removed {
|
||||
message = "username was not reserved"
|
||||
}
|
||||
return CommandResult{Message: message, Details: details}, nil
|
||||
})
|
||||
}
|
||||
|
||||
// ReservedUsernames is the admin listing read for the blocklist.
|
||||
func (s *Service) ReservedUsernames(ctx context.Context, filter domain.ReservedUsernameFilter) ([]domain.ReservedUsername, error) {
|
||||
if s == nil || s.reservedUsernames == nil {
|
||||
return nil, fmt.Errorf("reserved username dependency is not configured")
|
||||
}
|
||||
return s.reservedUsernames.ReservedUsernames(ctx, filter)
|
||||
}
|
||||
|
||||
func collectibleOwnerPeer(userID, channelID int64) (domain.Peer, error) {
|
||||
if userID < 0 || channelID < 0 {
|
||||
return domain.Peer{}, fmt.Errorf("owner id must be positive")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue