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:
Astra 2026-09-09 19:57:14 +01:00
parent 22846e340f
commit 2bdb1ecf37
21 changed files with 843 additions and 6 deletions

View file

@ -12,6 +12,7 @@ import (
"io/fs"
"mime/multipart"
"net/http"
"net/url"
"path"
"strconv"
"strings"
@ -100,6 +101,7 @@ func (s *server) routes() http.Handler {
mux.Handle("GET /api/messages/groups", s.scopedRoute(permissionMessagesRead, http.HandlerFunc(s.handleGroupMessagesAPI)))
mux.Handle("GET /api/messages/groups/detail", s.scopedRoute(permissionMessagesRead, http.HandlerFunc(s.handleGroupMessageDetailAPI)))
mux.Handle("GET /api/collectible-usernames", s.scopedRoute(permissionUsernamesRead, http.HandlerFunc(s.handleCollectibleUsernamesAPI)))
mux.Handle("GET /api/reserved-usernames", s.scopedRoute(permissionUsernamesRead, http.HandlerFunc(s.handleReservedUsernamesAPI)))
mux.Handle("GET /api/collectible-usernames/{id}", s.scopedRoute(permissionUsernamesRead, http.HandlerFunc(s.handleCollectibleUsernameDetailAPI)))
mux.Handle("GET /api/storage/stats", s.scopedRoute(permissionStorageRead, http.HandlerFunc(s.handleStorageStatsAPI)))
mux.Handle("GET /api/storage/accounts", s.scopedRoute(permissionStorageRead, http.HandlerFunc(s.handleStorageAccountsAPI)))
@ -156,6 +158,8 @@ func (s *server) routes() http.Handler {
mux.Handle("POST /api/actions/delete-uncategorized-gifs", s.scopedRoute(permissionContentManage, http.HandlerFunc(s.handleDeleteUncategorizedGifsAPI)))
mux.Handle("POST /api/actions/storage-manual-purge", s.scopedRoute(permissionStorageManage, http.HandlerFunc(s.handleStorageManualPurgeAPI)))
mux.Handle("POST /api/actions/delete-gif-catalog-entry", s.scopedRoute(permissionContentManage, http.HandlerFunc(s.handleDeleteGifCatalogEntryAPI)))
mux.Handle("POST /api/actions/reserve-username", s.scopedRoute(permissionUsernamesManage, http.HandlerFunc(s.handleReserveUsernameAPI)))
mux.Handle("POST /api/actions/unreserve-username", s.scopedRoute(permissionUsernamesManage, http.HandlerFunc(s.handleUnreserveUsernameAPI)))
mux.Handle("POST /api/actions/mint-collectible-username", s.scopedRoute(permissionUsernamesManage, http.HandlerFunc(s.handleMintCollectibleUsernameAPI)))
mux.Handle("POST /api/actions/transfer-collectible-username", s.scopedRoute(permissionUsernamesManage, http.HandlerFunc(s.handleTransferCollectibleUsernameAPI)))
mux.Handle("POST /api/actions/revoke-collectible-username", s.scopedRoute(permissionUsernamesManage, http.HandlerFunc(s.handleRevokeCollectibleUsernameAPI)))
@ -2438,6 +2442,69 @@ type mintCollectibleUsernameAPIRequest struct {
PurchaseDate flexUnix `json:"purchase_date"`
}
type reserveUsernameAPIRequest struct {
CommandID string `json:"command_id"`
Reason string `json:"reason"`
Confirm bool `json:"confirm"`
Username string `json:"username"`
}
func (s *server) handleReserveUsernameAPI(w http.ResponseWriter, r *http.Request) {
var body reserveUsernameAPIRequest
if !decodeAction(w, r, &body) {
return
}
req := admin.ReserveUsernameRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "reserve-username"),
Username: body.Username,
}
result, err := s.callAdminAPI(r.Context(), "/v1/reserved-usernames/reserve", req)
writeCommandResultAPI(w, result, err)
}
func (s *server) handleUnreserveUsernameAPI(w http.ResponseWriter, r *http.Request) {
var body reserveUsernameAPIRequest
if !decodeAction(w, r, &body) {
return
}
req := admin.UnreserveUsernameRequest{
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "unreserve-username"),
Username: body.Username,
}
result, err := s.callAdminAPI(r.Context(), "/v1/reserved-usernames/unreserve", req)
writeCommandResultAPI(w, result, err)
}
func (s *server) handleReservedUsernamesAPI(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
params := url.Values{}
for _, name := range []string{"q", "limit", "offset"} {
if v := strings.TrimSpace(q.Get(name)); v != "" {
params.Set(name, v)
}
}
apiPath := "/v1/reserved-usernames"
if enc := params.Encode(); enc != "" {
apiPath += "?" + enc
}
req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, s.cfg.AdminAPIURL+apiPath, nil)
if err != nil {
writeAPIError(w, http.StatusInternalServerError, "request build failed")
return
}
req.Header.Set("Authorization", "Bearer "+s.cfg.AdminAPIToken)
resp, err := http.DefaultClient.Do(req)
if err != nil {
writeAPIError(w, http.StatusBadGateway, "admin api unreachable")
return
}
defer resp.Body.Close()
data, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(resp.StatusCode)
_, _ = w.Write(data)
}
func (s *server) handleMintCollectibleUsernameAPI(w http.ResponseWriter, r *http.Request) {
var body mintCollectibleUsernameAPIRequest
if !decodeAction(w, r, &body) {