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
|
|
@ -12,6 +12,7 @@ import (
|
|||
"io/fs"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
|
@ -74,6 +75,7 @@ func (s *server) routes() http.Handler {
|
|||
mux.Handle("GET /api/messages/groups", s.requireAuthAPI(http.HandlerFunc(s.handleGroupMessagesAPI)))
|
||||
mux.Handle("GET /api/messages/groups/detail", s.requireAuthAPI(http.HandlerFunc(s.handleGroupMessageDetailAPI)))
|
||||
mux.Handle("GET /api/collectible-usernames", s.requireAuthAPI(http.HandlerFunc(s.handleCollectibleUsernamesAPI)))
|
||||
mux.Handle("GET /api/reserved-usernames", s.requireAuthAPI(http.HandlerFunc(s.handleReservedUsernamesAPI)))
|
||||
mux.Handle("GET /api/collectible-usernames/{id}", s.requireAuthAPI(http.HandlerFunc(s.handleCollectibleUsernameDetailAPI)))
|
||||
mux.Handle("GET /api/storage/stats", s.requireAuthAPI(http.HandlerFunc(s.handleStorageStatsAPI)))
|
||||
mux.Handle("GET /api/storage/accounts", s.requireAuthAPI(http.HandlerFunc(s.handleStorageAccountsAPI)))
|
||||
|
|
@ -128,6 +130,8 @@ func (s *server) routes() http.Handler {
|
|||
mux.Handle("POST /api/actions/auto-categorize-gif-catalog", s.requireAuthAPI(http.HandlerFunc(s.handleAutoCategorizeGifCatalogAPI)))
|
||||
mux.Handle("POST /api/actions/delete-uncategorized-gifs", s.requireAuthAPI(http.HandlerFunc(s.handleDeleteUncategorizedGifsAPI)))
|
||||
mux.Handle("POST /api/actions/delete-gif-catalog-entry", s.requireAuthAPI(http.HandlerFunc(s.handleDeleteGifCatalogEntryAPI)))
|
||||
mux.Handle("POST /api/actions/reserve-username", s.requireAuthAPI(http.HandlerFunc(s.handleReserveUsernameAPI)))
|
||||
mux.Handle("POST /api/actions/unreserve-username", s.requireAuthAPI(http.HandlerFunc(s.handleUnreserveUsernameAPI)))
|
||||
mux.Handle("POST /api/actions/mint-collectible-username", s.requireAuthAPI(http.HandlerFunc(s.handleMintCollectibleUsernameAPI)))
|
||||
mux.Handle("POST /api/actions/transfer-collectible-username", s.requireAuthAPI(http.HandlerFunc(s.handleTransferCollectibleUsernameAPI)))
|
||||
mux.Handle("POST /api/actions/revoke-collectible-username", s.requireAuthAPI(http.HandlerFunc(s.handleRevokeCollectibleUsernameAPI)))
|
||||
|
|
@ -2239,6 +2243,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) {
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
2
cmd/telesrv-admin/web/dist/index.html
vendored
2
cmd/telesrv-admin/web/dist/index.html
vendored
|
|
@ -23,7 +23,7 @@
|
|||
})();
|
||||
</script>
|
||||
|
||||
<script type="module" crossorigin src="/assets/index-Bt9UBcEE.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-BWYHyok0.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CQKJMNpu.css">
|
||||
</head>
|
||||
<body>
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import type {
|
|||
ChannelListResponse,
|
||||
CollectibleUsernameDetail,
|
||||
CollectibleUsernameListResponse,
|
||||
ReservedUsernameListResponse,
|
||||
CommandResult,
|
||||
GroupMessageDetail,
|
||||
GroupMessageListResponse,
|
||||
|
|
@ -163,6 +164,8 @@ export const api = {
|
|||
request<CollectibleUsernameListResponse>(`/api/collectible-usernames?${params.toString()}`),
|
||||
collectibleUsername: (id: string) =>
|
||||
request<CollectibleUsernameDetail>(`/api/collectible-usernames/${encodeURIComponent(id)}`),
|
||||
reservedUsernames: (params: URLSearchParams) =>
|
||||
request<ReservedUsernameListResponse>(`/api/reserved-usernames?${params.toString()}`),
|
||||
dashboard: () => request<DashboardResponse>("/api/dashboard"),
|
||||
storageStats: () => request<StorageStatsResponse>("/api/storage/stats"),
|
||||
storageAccounts: (params: URLSearchParams) =>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import {
|
||||
AtSign,
|
||||
BadgeCheck,
|
||||
Ban,
|
||||
Bot,
|
||||
ChevronDown,
|
||||
Database,
|
||||
|
|
@ -99,6 +100,7 @@ export function Shell({
|
|||
<NavLink icon={<Stamp size={16} />} href="/bot-verification" route={route} navigate={navigate}>{"Third-party marks"}</NavLink>
|
||||
)}
|
||||
<NavLink icon={<AtSign size={16} />} href="/collectible-usernames" route={route} navigate={navigate}>{"NFT Usernames"}</NavLink>
|
||||
<NavLink icon={<Ban size={16} />} href="/reserved-usernames" route={route} navigate={navigate}>{"Reserved Usernames"}</NavLink>
|
||||
<NavLink icon={<Database size={16} />} href="/storage" route={route} navigate={navigate}>{"Storage"}</NavLink>
|
||||
<NavLink icon={<Sticker size={16} />} href="/stickers" route={route} navigate={navigate}>{"Stickers"}</NavLink>
|
||||
<NavLink icon={<Smile size={16} />} href="/emoji" route={route} navigate={navigate}>{"Emoji"}</NavLink>
|
||||
|
|
|
|||
138
cmd/telesrv-admin/web/src/pages/ReservedUsernamesPage.tsx
Normal file
138
cmd/telesrv-admin/web/src/pages/ReservedUsernamesPage.tsx
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
import { AtSign, Loader2, Plus, RefreshCw, Search, Trash2 } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { api, errorMessage } from "../api";
|
||||
import { ActionButton } from "../components/ActionButton";
|
||||
import { Alert, EmptyRow, Metric, PageFrame, QueryPanel } from "../components/ui";
|
||||
import { formatUnix } from "../lib/format";
|
||||
import type { ReservedUsernameRow } from "../types";
|
||||
|
||||
// Reserved usernames are a plain operator blocklist: a name listed here cannot be
|
||||
// taken as an editable username by any peer and cannot be minted as a
|
||||
// collectible. No owner, no price, no Fragment badge - that is the collectible
|
||||
// tab's job.
|
||||
export function ReservedUsernamesPage() {
|
||||
const [q, setQ] = useState("");
|
||||
const [rows, setRows] = useState<ReservedUsernameRow[]>([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [newName, setNewName] = useState("");
|
||||
|
||||
async function load() {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
const params = new URLSearchParams({ limit: "200" });
|
||||
if (q.trim()) params.set("q", q.trim().replace(/^@/, ""));
|
||||
try {
|
||||
const result = await api.reservedUsernames(params);
|
||||
setRows(result.reserved ?? []);
|
||||
} catch (err) {
|
||||
setError(errorMessage(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, []);
|
||||
|
||||
const cleanNew = newName.trim().replace(/^@/, "");
|
||||
|
||||
return (
|
||||
<PageFrame
|
||||
title={"Reserved usernames"}
|
||||
eyebrow={"Usernames / Blocklist"}
|
||||
actions={
|
||||
<button className="btn icon-text" type="button" onClick={() => load()} disabled={busy}>
|
||||
<RefreshCw size={15} className={busy ? "spin" : ""} /> {"Refresh"}
|
||||
</button>
|
||||
}
|
||||
>
|
||||
{error && <Alert>{error}</Alert>}
|
||||
<div className="metric-row">
|
||||
<Metric label={"Reserved names"} value={String(rows.length)} />
|
||||
</div>
|
||||
|
||||
<QueryPanel>
|
||||
<div className="toolbar">
|
||||
<label className="field-inline">
|
||||
<span>{"Reserve"}</span>
|
||||
<input
|
||||
value={newName}
|
||||
onChange={(event) => setNewName(event.target.value)}
|
||||
placeholder={"support"}
|
||||
/>
|
||||
</label>
|
||||
<ActionButton
|
||||
disabled={cleanNew.length < 5}
|
||||
label={"Reserve username"}
|
||||
icon={<Plus size={15} />}
|
||||
tone="neutral"
|
||||
path="/api/actions/reserve-username"
|
||||
payload={() => ({ username: cleanNew })}
|
||||
onDone={() => {
|
||||
setNewName("");
|
||||
void load();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<form
|
||||
className="toolbar"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void load();
|
||||
}}
|
||||
>
|
||||
<label className="searchbox">
|
||||
<Search size={15} />
|
||||
<input value={q} onChange={(event) => setQ(event.target.value)} placeholder={"Filter by prefix"} />
|
||||
</label>
|
||||
<button className="btn primary icon-text" type="submit" disabled={busy}>
|
||||
{busy ? <Loader2 size={15} className="spin" /> : <Search size={15} />} {"Search"}
|
||||
</button>
|
||||
</form>
|
||||
</QueryPanel>
|
||||
|
||||
<div className="table-wrap">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{"Username"}</th>
|
||||
<th>{"Reason"}</th>
|
||||
<th>{"Reserved by"}</th>
|
||||
<th>{"Reserved (UTC)"}</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<tr key={row.username}>
|
||||
<td>
|
||||
<strong>
|
||||
<AtSign size={13} />
|
||||
{row.username}
|
||||
</strong>
|
||||
</td>
|
||||
<td>{row.reason || "-"}</td>
|
||||
<td>{row.actor || "-"}</td>
|
||||
<td>{formatUnix(row.created_at) || "-"}</td>
|
||||
<td>
|
||||
<ActionButton
|
||||
compact
|
||||
label={"Unreserve"}
|
||||
icon={<Trash2 size={13} />}
|
||||
tone="danger"
|
||||
path="/api/actions/unreserve-username"
|
||||
payload={() => ({ username: row.username })}
|
||||
onDone={() => void load()}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{rows.length === 0 && <EmptyRow colSpan={5} />}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</PageFrame>
|
||||
);
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import { AccountsPage } from "./AccountsPage";
|
|||
import { SharedDevicesPage } from "./SharedDevicesPage";
|
||||
import { CollectibleUsernameDetailPage } from "./CollectibleUsernameDetailPage";
|
||||
import { CollectibleUsernamesPage } from "./CollectibleUsernamesPage";
|
||||
import { ReservedUsernamesPage } from "./ReservedUsernamesPage";
|
||||
import { ChannelDetailPage } from "./ChannelDetailPage";
|
||||
import { ChannelsPage } from "./ChannelsPage";
|
||||
import { BotDetailPage } from "./BotDetailPage";
|
||||
|
|
@ -82,6 +83,9 @@ export function Routes({ route, navigate }: { route: RouteState; navigate: Navig
|
|||
if (route.path === "/collectible-usernames") {
|
||||
return <CollectibleUsernamesPage navigate={navigate} />;
|
||||
}
|
||||
if (route.path === "/reserved-usernames") {
|
||||
return <ReservedUsernamesPage />;
|
||||
}
|
||||
if (route.path === "/storage") {
|
||||
return <StoragePage navigate={navigate} />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ export function routeTitle(pathname: string): string {
|
|||
if (pathname.startsWith("/bot-verification")) return "Third-party verification";
|
||||
if (pathname.startsWith("/verification")) return "Official Verification";
|
||||
if (pathname.startsWith("/collectible-usernames")) return "Collectible Usernames";
|
||||
if (pathname.startsWith("/reserved-usernames")) return "Reserved Usernames";
|
||||
if (pathname.startsWith("/storage")) return "Storage";
|
||||
if (pathname.startsWith("/accounts/shared-devices")) return "Shared Devices";
|
||||
if (pathname.startsWith("/accounts")) return "Accounts";
|
||||
|
|
|
|||
|
|
@ -347,6 +347,17 @@ export type CollectibleUsernameDetail = {
|
|||
transfers: CollectibleUsernameTransferRow[] | null;
|
||||
};
|
||||
|
||||
export type ReservedUsernameRow = {
|
||||
username: string;
|
||||
reason: string;
|
||||
actor: string;
|
||||
created_at: number;
|
||||
};
|
||||
|
||||
export type ReservedUsernameListResponse = {
|
||||
reserved: ReservedUsernameRow[] | null;
|
||||
};
|
||||
|
||||
// Official platform verification. Every int64 the backend tags `,string` stays a
|
||||
// decimal string here: application ids, peer ids and the optimistic-locking
|
||||
// version all outgrow the exact range of a JSON number, and a rounded version
|
||||
|
|
|
|||
|
|
@ -1203,6 +1203,7 @@ func run(logger *zap.Logger) error {
|
|||
// Collectible (NFT) usernames are an optional read model projected at the
|
||||
// protocol edge.
|
||||
collectibleUsernameStore := postgres.NewCollectibleUsernameStore(pool)
|
||||
reservedUsernameStore := postgres.NewReservedUsernameStore(pool)
|
||||
usernamesService := usernamesapp.NewService(
|
||||
usernamesapp.WithRegistryStore(collectibleUsernameStore),
|
||||
usernamesapp.WithCollectibleStore(collectibleUsernameStore),
|
||||
|
|
@ -1390,6 +1391,7 @@ func run(logger *zap.Logger) error {
|
|||
Emoji: filesService,
|
||||
Moderation: moderationService,
|
||||
Usernames: usernamesService,
|
||||
ReservedUsernames: reservedUsernameStore,
|
||||
Verification: verificationService,
|
||||
BotVerification: botVerificationService,
|
||||
Account: accountService,
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
DROP TABLE IF EXISTS public.reserved_usernames;
|
||||
17
deploy/migrations/20260909190000_reserved_usernames.up.sql
Normal file
17
deploy/migrations/20260909190000_reserved_usernames.up.sql
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
-- Operator-maintained username blocklist. A name listed here cannot be taken as
|
||||
-- an editable username by any peer (account.updateUsername, channels.updateUsername,
|
||||
-- @BotFather /setusername, or the admin set-username actions). It is a plain
|
||||
-- blocklist: no owner, no price, no Fragment collectible badge.
|
||||
|
||||
CREATE TABLE public.reserved_usernames (
|
||||
username_lower text PRIMARY KEY CHECK (
|
||||
username_lower <> '' AND username_lower = lower(username_lower)
|
||||
),
|
||||
username text NOT NULL,
|
||||
reason text NOT NULL DEFAULT '' CHECK (octet_length(reason) <= 512),
|
||||
actor text NOT NULL DEFAULT '' CHECK (octet_length(actor) <= 256),
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX reserved_usernames_created_at_idx
|
||||
ON public.reserved_usernames (created_at DESC, username_lower);
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import (
|
|||
|
||||
usernamesapp "telesrv/internal/app/usernames"
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store/memory"
|
||||
)
|
||||
|
||||
// Compile-time proof that the shipped use-case services satisfy the admin ports.
|
||||
|
|
@ -1427,3 +1428,76 @@ func TestDeleteCollectibleUsernameCommand(t *testing.T) {
|
|||
t.Fatalf("delete of invalid name = nil error, want rejection")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReserveAndUnreserveUsername(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
reserved := memory.NewReservedUsernameStore()
|
||||
svc := NewService(Dependencies{
|
||||
Commands: newMemoryCommandRepo(),
|
||||
ReservedUsernames: reserved,
|
||||
Now: fixedNow,
|
||||
})
|
||||
|
||||
dry, err := svc.ReserveUsername(ctx, ReserveUsernameRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "rv-dry", Actor: "ops", Reason: "official handle", DryRun: true},
|
||||
Username: "@Support",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("dry-run reserve: %v", err)
|
||||
}
|
||||
if got, _ := reserved.IsReserved(ctx, "support"); got {
|
||||
t.Fatal("dry-run reserved the name")
|
||||
}
|
||||
if dry.Details["username"] != "Support" {
|
||||
t.Fatalf("dry-run details = %+v", dry.Details)
|
||||
}
|
||||
|
||||
if _, err := svc.ReserveUsername(ctx, ReserveUsernameRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "rv-exec", Actor: "ops", Reason: "official handle"},
|
||||
Username: "support",
|
||||
}); err != nil {
|
||||
t.Fatalf("reserve: %v", err)
|
||||
}
|
||||
if got, _ := reserved.IsReserved(ctx, "support"); !got {
|
||||
t.Fatal("name not reserved after exec")
|
||||
}
|
||||
|
||||
if _, err := svc.UnreserveUsername(ctx, UnreserveUsernameRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "urv-exec", Actor: "ops", Reason: "no longer needed"},
|
||||
Username: "SUPPORT",
|
||||
}); err != nil {
|
||||
t.Fatalf("unreserve: %v", err)
|
||||
}
|
||||
if got, _ := reserved.IsReserved(ctx, "support"); got {
|
||||
t.Fatal("name still reserved after unreserve")
|
||||
}
|
||||
|
||||
if _, err := svc.ReserveUsername(ctx, ReserveUsernameRequest{
|
||||
CommandMeta: CommandMeta{CommandID: "bad", Actor: "ops", Reason: "x"},
|
||||
Username: "ab",
|
||||
}); err == nil {
|
||||
t.Fatal("reserve of a too-short name = nil error, want rejection")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemoryRegistryRefusesReservedName(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
reserved := memory.NewReservedUsernameStore()
|
||||
if _, err := reserved.ReserveUsername(ctx, "support", "", "ops"); err != nil {
|
||||
t.Fatalf("seed reserve: %v", err)
|
||||
}
|
||||
registry := memory.NewCollectibleUsernameStore().WithReservedUsernames(reserved)
|
||||
|
||||
if _, err := registry.SetEditableUsername(ctx, domain.Peer{Type: domain.PeerTypeUser, ID: 1}, "support"); !errors.Is(err, domain.ErrUsernameOccupied) {
|
||||
t.Fatalf("SetEditableUsername(reserved) err = %v, want ErrUsernameOccupied", err)
|
||||
}
|
||||
if _, _, err := registry.MintCollectibleUsername(ctx, domain.MintCollectibleUsernameRequest{
|
||||
Username: "support", Currency: domain.CollectibleCurrencyUSD, Amount: 0, CommandKey: "k1",
|
||||
}); !errors.Is(err, domain.ErrUsernameOccupied) {
|
||||
t.Fatalf("Mint(reserved) err = %v, want ErrUsernameOccupied", err)
|
||||
}
|
||||
// A different name is unaffected.
|
||||
if _, err := registry.SetEditableUsername(ctx, domain.Peer{Type: domain.PeerTypeUser, ID: 1}, "freename"); err != nil {
|
||||
t.Fatalf("SetEditableUsername(free) err = %v", err)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -98,6 +98,9 @@ type Service interface {
|
|||
CollectibleUsernames(ctx context.Context, filter domain.CollectibleUsernameFilter) ([]domain.CollectibleUsername, error)
|
||||
CollectibleUsernameByID(ctx context.Context, id int64) (domain.CollectibleUsername, error)
|
||||
CollectibleUsernameTransfers(ctx context.Context, collectibleID int64, limit int) ([]domain.CollectibleUsernameTransfer, error)
|
||||
ReserveUsername(ctx context.Context, req admin.ReserveUsernameRequest) (admin.CommandResult, error)
|
||||
UnreserveUsername(ctx context.Context, req admin.UnreserveUsernameRequest) (admin.CommandResult, error)
|
||||
ReservedUsernames(ctx context.Context, filter domain.ReservedUsernameFilter) ([]domain.ReservedUsername, error)
|
||||
ClaimVerification(ctx context.Context, req admin.ClaimVerificationRequest) (admin.CommandResult, error)
|
||||
ApproveVerification(ctx context.Context, req admin.ApproveVerificationRequest) (admin.CommandResult, error)
|
||||
RejectVerification(ctx context.Context, req admin.RejectVerificationRequest) (admin.CommandResult, error)
|
||||
|
|
@ -234,6 +237,9 @@ func (s *Server) routes() http.Handler {
|
|||
mux.HandleFunc("POST /v1/collectible-usernames/delete", s.authenticated(s.handleDeleteCollectibleUsername))
|
||||
mux.HandleFunc("GET /v1/collectible-usernames", s.authenticated(s.handleCollectibleUsernames))
|
||||
mux.HandleFunc("GET /v1/collectible-usernames/{id}", s.authenticated(s.handleCollectibleUsername))
|
||||
mux.HandleFunc("POST /v1/reserved-usernames/reserve", s.authenticated(s.handleReserveUsername))
|
||||
mux.HandleFunc("POST /v1/reserved-usernames/unreserve", s.authenticated(s.handleUnreserveUsername))
|
||||
mux.HandleFunc("GET /v1/reserved-usernames", s.authenticated(s.handleReservedUsernames))
|
||||
// Official platform verification. Unlike every route above, these carry a
|
||||
// named permission, so a scoped token can be given the review surface and
|
||||
// nothing else. Revocation additionally requires verification.revoke.
|
||||
|
|
@ -1186,6 +1192,54 @@ func (s *Server) handleDeleteCollectibleUsername(w http.ResponseWriter, r *http.
|
|||
writeCommandResult(w, result, err)
|
||||
}
|
||||
|
||||
func (s *Server) handleReserveUsername(w http.ResponseWriter, r *http.Request) {
|
||||
var req admin.ReserveUsernameRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
result, err := s.svc.ReserveUsername(r.Context(), req)
|
||||
writeCommandResult(w, result, err)
|
||||
}
|
||||
|
||||
func (s *Server) handleUnreserveUsername(w http.ResponseWriter, r *http.Request) {
|
||||
var req admin.UnreserveUsernameRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
result, err := s.svc.UnreserveUsername(r.Context(), req)
|
||||
writeCommandResult(w, result, err)
|
||||
}
|
||||
|
||||
func (s *Server) handleReservedUsernames(w http.ResponseWriter, r *http.Request) {
|
||||
query := r.URL.Query()
|
||||
filter := domain.ReservedUsernameFilter{Query: query.Get("q")}
|
||||
limit, ok := optionalQueryInt(w, query, "limit")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
filter.Limit = limit
|
||||
offset, ok := optionalQueryInt(w, query, "offset")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
filter.Offset = offset
|
||||
items, err := s.svc.ReservedUsernames(r.Context(), filter)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "list failed")
|
||||
return
|
||||
}
|
||||
out := make([]map[string]any, 0, len(items))
|
||||
for _, item := range items {
|
||||
out = append(out, map[string]any{
|
||||
"username": item.Username,
|
||||
"reason": item.Reason,
|
||||
"actor": item.Actor,
|
||||
"created_at": item.CreatedAt.Unix(),
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"reserved": out})
|
||||
}
|
||||
|
||||
func (s *Server) handleCollectibleUsernames(w http.ResponseWriter, r *http.Request) {
|
||||
query := r.URL.Query()
|
||||
filter := domain.CollectibleUsernameFilter{
|
||||
|
|
|
|||
|
|
@ -510,12 +510,30 @@ func (fakeService) ReviewModerationAppeal(context.Context, domain.ModerationDeci
|
|||
|
||||
type captureCollectibleUsernameService struct {
|
||||
fakeService
|
||||
mint admin.MintCollectibleUsernameRequest
|
||||
transfer admin.TransferCollectibleUsernameRequest
|
||||
revoke admin.RevokeCollectibleUsernameRequest
|
||||
del admin.DeleteCollectibleUsernameRequest
|
||||
filter domain.CollectibleUsernameFilter
|
||||
assetID int64
|
||||
mint admin.MintCollectibleUsernameRequest
|
||||
transfer admin.TransferCollectibleUsernameRequest
|
||||
revoke admin.RevokeCollectibleUsernameRequest
|
||||
del admin.DeleteCollectibleUsernameRequest
|
||||
reserve admin.ReserveUsernameRequest
|
||||
unreserve admin.UnreserveUsernameRequest
|
||||
resFilter domain.ReservedUsernameFilter
|
||||
filter domain.CollectibleUsernameFilter
|
||||
assetID int64
|
||||
}
|
||||
|
||||
func (s *captureCollectibleUsernameService) ReserveUsername(_ context.Context, req admin.ReserveUsernameRequest) (admin.CommandResult, error) {
|
||||
s.reserve = req
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
||||
func (s *captureCollectibleUsernameService) UnreserveUsername(_ context.Context, req admin.UnreserveUsernameRequest) (admin.CommandResult, error) {
|
||||
s.unreserve = req
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
||||
func (s *captureCollectibleUsernameService) ReservedUsernames(_ context.Context, filter domain.ReservedUsernameFilter) ([]domain.ReservedUsername, error) {
|
||||
s.resFilter = filter
|
||||
return []domain.ReservedUsername{{Username: "support", Reason: "official", Actor: "ops"}}, nil
|
||||
}
|
||||
|
||||
func (s *captureCollectibleUsernameService) MintCollectibleUsername(_ context.Context, req admin.MintCollectibleUsernameRequest) (admin.CommandResult, error) {
|
||||
|
|
@ -731,3 +749,49 @@ func (fakeService) CollectibleUsernameByID(context.Context, int64) (domain.Colle
|
|||
func (fakeService) CollectibleUsernameTransfers(context.Context, int64, int) ([]domain.CollectibleUsernameTransfer, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (fakeService) ReserveUsername(_ context.Context, req admin.ReserveUsernameRequest) (admin.CommandResult, error) {
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
||||
func (fakeService) UnreserveUsername(_ context.Context, req admin.UnreserveUsernameRequest) (admin.CommandResult, error) {
|
||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||
}
|
||||
|
||||
func (fakeService) ReservedUsernames(context.Context, domain.ReservedUsernameFilter) ([]domain.ReservedUsername, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func TestAdminAPIReservedUsernames(t *testing.T) {
|
||||
svc := &captureCollectibleUsernameService{}
|
||||
srv := &Server{token: "secret", svc: svc}
|
||||
|
||||
reserve := httptest.NewRequest(http.MethodPost, "/v1/reserved-usernames/reserve", strings.NewReader(
|
||||
`{"command_id":"r-1","actor":"ops","reason":"official","username":"support"}`))
|
||||
reserve.Header.Set("Authorization", "Bearer secret")
|
||||
rec := httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, reserve)
|
||||
if rec.Code != http.StatusOK || svc.reserve.Username != "support" || svc.reserve.CommandID != "r-1" {
|
||||
t.Fatalf("reserve status=%d req=%+v", rec.Code, svc.reserve)
|
||||
}
|
||||
|
||||
unreserve := httptest.NewRequest(http.MethodPost, "/v1/reserved-usernames/unreserve", strings.NewReader(
|
||||
`{"command_id":"u-1","actor":"ops","reason":"done","username":"support"}`))
|
||||
unreserve.Header.Set("Authorization", "Bearer secret")
|
||||
rec = httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, unreserve)
|
||||
if rec.Code != http.StatusOK || svc.unreserve.Username != "support" {
|
||||
t.Fatalf("unreserve status=%d req=%+v", rec.Code, svc.unreserve)
|
||||
}
|
||||
|
||||
list := httptest.NewRequest(http.MethodGet, "/v1/reserved-usernames?q=sup&limit=10", nil)
|
||||
list.Header.Set("Authorization", "Bearer secret")
|
||||
rec = httptest.NewRecorder()
|
||||
srv.routes().ServeHTTP(rec, list)
|
||||
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"username":"support"`) {
|
||||
t.Fatalf("list status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if svc.resFilter.Query != "sup" || svc.resFilter.Limit != 10 {
|
||||
t.Fatalf("list filter = %+v", svc.resFilter)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
24
internal/domain/reserved_username.go
Normal file
24
internal/domain/reserved_username.go
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
package domain
|
||||
|
||||
import "time"
|
||||
|
||||
// MaxReservedUsernameReasonLength bounds the operator note on a reservation.
|
||||
const MaxReservedUsernameReasonLength = 512
|
||||
|
||||
// ReservedUsername is one entry in the operator username blocklist. A reserved
|
||||
// name cannot be taken as an editable username by any peer and cannot be minted
|
||||
// as a collectible.
|
||||
type ReservedUsername struct {
|
||||
Username string // display form (original case at reservation time)
|
||||
Reason string
|
||||
Actor string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// ReservedUsernameFilter pages the blocklist. Query matches a username prefix
|
||||
// (case-insensitive); an empty query lists everything.
|
||||
type ReservedUsernameFilter struct {
|
||||
Query string
|
||||
Limit int
|
||||
Offset int
|
||||
}
|
||||
|
|
@ -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