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"
|
"io/fs"
|
||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"path"
|
"path"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"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", s.requireAuthAPI(http.HandlerFunc(s.handleGroupMessagesAPI)))
|
||||||
mux.Handle("GET /api/messages/groups/detail", s.requireAuthAPI(http.HandlerFunc(s.handleGroupMessageDetailAPI)))
|
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/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/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/stats", s.requireAuthAPI(http.HandlerFunc(s.handleStorageStatsAPI)))
|
||||||
mux.Handle("GET /api/storage/accounts", s.requireAuthAPI(http.HandlerFunc(s.handleStorageAccountsAPI)))
|
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/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-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/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/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/transfer-collectible-username", s.requireAuthAPI(http.HandlerFunc(s.handleTransferCollectibleUsernameAPI)))
|
||||||
mux.Handle("POST /api/actions/revoke-collectible-username", s.requireAuthAPI(http.HandlerFunc(s.handleRevokeCollectibleUsernameAPI)))
|
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"`
|
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) {
|
func (s *server) handleMintCollectibleUsernameAPI(w http.ResponseWriter, r *http.Request) {
|
||||||
var body mintCollectibleUsernameAPIRequest
|
var body mintCollectibleUsernameAPIRequest
|
||||||
if !decodeAction(w, r, &body) {
|
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>
|
||||||
|
|
||||||
<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">
|
<link rel="stylesheet" crossorigin href="/assets/index-CQKJMNpu.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ import type {
|
||||||
ChannelListResponse,
|
ChannelListResponse,
|
||||||
CollectibleUsernameDetail,
|
CollectibleUsernameDetail,
|
||||||
CollectibleUsernameListResponse,
|
CollectibleUsernameListResponse,
|
||||||
|
ReservedUsernameListResponse,
|
||||||
CommandResult,
|
CommandResult,
|
||||||
GroupMessageDetail,
|
GroupMessageDetail,
|
||||||
GroupMessageListResponse,
|
GroupMessageListResponse,
|
||||||
|
|
@ -163,6 +164,8 @@ export const api = {
|
||||||
request<CollectibleUsernameListResponse>(`/api/collectible-usernames?${params.toString()}`),
|
request<CollectibleUsernameListResponse>(`/api/collectible-usernames?${params.toString()}`),
|
||||||
collectibleUsername: (id: string) =>
|
collectibleUsername: (id: string) =>
|
||||||
request<CollectibleUsernameDetail>(`/api/collectible-usernames/${encodeURIComponent(id)}`),
|
request<CollectibleUsernameDetail>(`/api/collectible-usernames/${encodeURIComponent(id)}`),
|
||||||
|
reservedUsernames: (params: URLSearchParams) =>
|
||||||
|
request<ReservedUsernameListResponse>(`/api/reserved-usernames?${params.toString()}`),
|
||||||
dashboard: () => request<DashboardResponse>("/api/dashboard"),
|
dashboard: () => request<DashboardResponse>("/api/dashboard"),
|
||||||
storageStats: () => request<StorageStatsResponse>("/api/storage/stats"),
|
storageStats: () => request<StorageStatsResponse>("/api/storage/stats"),
|
||||||
storageAccounts: (params: URLSearchParams) =>
|
storageAccounts: (params: URLSearchParams) =>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import {
|
import {
|
||||||
AtSign,
|
AtSign,
|
||||||
BadgeCheck,
|
BadgeCheck,
|
||||||
|
Ban,
|
||||||
Bot,
|
Bot,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
Database,
|
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={<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={<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={<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={<Sticker size={16} />} href="/stickers" route={route} navigate={navigate}>{"Stickers"}</NavLink>
|
||||||
<NavLink icon={<Smile size={16} />} href="/emoji" route={route} navigate={navigate}>{"Emoji"}</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 { SharedDevicesPage } from "./SharedDevicesPage";
|
||||||
import { CollectibleUsernameDetailPage } from "./CollectibleUsernameDetailPage";
|
import { CollectibleUsernameDetailPage } from "./CollectibleUsernameDetailPage";
|
||||||
import { CollectibleUsernamesPage } from "./CollectibleUsernamesPage";
|
import { CollectibleUsernamesPage } from "./CollectibleUsernamesPage";
|
||||||
|
import { ReservedUsernamesPage } from "./ReservedUsernamesPage";
|
||||||
import { ChannelDetailPage } from "./ChannelDetailPage";
|
import { ChannelDetailPage } from "./ChannelDetailPage";
|
||||||
import { ChannelsPage } from "./ChannelsPage";
|
import { ChannelsPage } from "./ChannelsPage";
|
||||||
import { BotDetailPage } from "./BotDetailPage";
|
import { BotDetailPage } from "./BotDetailPage";
|
||||||
|
|
@ -82,6 +83,9 @@ export function Routes({ route, navigate }: { route: RouteState; navigate: Navig
|
||||||
if (route.path === "/collectible-usernames") {
|
if (route.path === "/collectible-usernames") {
|
||||||
return <CollectibleUsernamesPage navigate={navigate} />;
|
return <CollectibleUsernamesPage navigate={navigate} />;
|
||||||
}
|
}
|
||||||
|
if (route.path === "/reserved-usernames") {
|
||||||
|
return <ReservedUsernamesPage />;
|
||||||
|
}
|
||||||
if (route.path === "/storage") {
|
if (route.path === "/storage") {
|
||||||
return <StoragePage navigate={navigate} />;
|
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("/bot-verification")) return "Third-party verification";
|
||||||
if (pathname.startsWith("/verification")) return "Official Verification";
|
if (pathname.startsWith("/verification")) return "Official Verification";
|
||||||
if (pathname.startsWith("/collectible-usernames")) return "Collectible Usernames";
|
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("/storage")) return "Storage";
|
||||||
if (pathname.startsWith("/accounts/shared-devices")) return "Shared Devices";
|
if (pathname.startsWith("/accounts/shared-devices")) return "Shared Devices";
|
||||||
if (pathname.startsWith("/accounts")) return "Accounts";
|
if (pathname.startsWith("/accounts")) return "Accounts";
|
||||||
|
|
|
||||||
|
|
@ -347,6 +347,17 @@ export type CollectibleUsernameDetail = {
|
||||||
transfers: CollectibleUsernameTransferRow[] | null;
|
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
|
// Official platform verification. Every int64 the backend tags `,string` stays a
|
||||||
// decimal string here: application ids, peer ids and the optimistic-locking
|
// 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
|
// 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
|
// Collectible (NFT) usernames are an optional read model projected at the
|
||||||
// protocol edge.
|
// protocol edge.
|
||||||
collectibleUsernameStore := postgres.NewCollectibleUsernameStore(pool)
|
collectibleUsernameStore := postgres.NewCollectibleUsernameStore(pool)
|
||||||
|
reservedUsernameStore := postgres.NewReservedUsernameStore(pool)
|
||||||
usernamesService := usernamesapp.NewService(
|
usernamesService := usernamesapp.NewService(
|
||||||
usernamesapp.WithRegistryStore(collectibleUsernameStore),
|
usernamesapp.WithRegistryStore(collectibleUsernameStore),
|
||||||
usernamesapp.WithCollectibleStore(collectibleUsernameStore),
|
usernamesapp.WithCollectibleStore(collectibleUsernameStore),
|
||||||
|
|
@ -1390,6 +1391,7 @@ func run(logger *zap.Logger) error {
|
||||||
Emoji: filesService,
|
Emoji: filesService,
|
||||||
Moderation: moderationService,
|
Moderation: moderationService,
|
||||||
Usernames: usernamesService,
|
Usernames: usernamesService,
|
||||||
|
ReservedUsernames: reservedUsernameStore,
|
||||||
Verification: verificationService,
|
Verification: verificationService,
|
||||||
BotVerification: botVerificationService,
|
BotVerification: botVerificationService,
|
||||||
Account: accountService,
|
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"
|
ActionTransferCollectibleUsername = "usernames.collectible.transfer"
|
||||||
ActionRevokeCollectibleUsername = "usernames.collectible.revoke"
|
ActionRevokeCollectibleUsername = "usernames.collectible.revoke"
|
||||||
ActionDeleteCollectibleUsername = "usernames.collectible.delete"
|
ActionDeleteCollectibleUsername = "usernames.collectible.delete"
|
||||||
|
// Operator username blocklist.
|
||||||
|
ActionReserveUsername = "usernames.reserve"
|
||||||
|
ActionUnreserveUsername = "usernames.unreserve"
|
||||||
// Official platform verification review. Claim/approve/reject act on one
|
// Official platform verification review. Claim/approve/reject act on one
|
||||||
// application; revoke acts on a target, because clearing a badge is not a
|
// application; revoke acts on a target, because clearing a badge is not a
|
||||||
// decision on the application that granted it.
|
// 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)
|
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
|
// collectibleUsernameByIDLookup is the optional by-identity read. Stores that
|
||||||
// expose it answer a detail request in one round trip; the keyset fallback in
|
// expose it answer a detail request in one round trip; the keyset fallback in
|
||||||
// CollectibleUsernameByID keeps a service without it correct.
|
// CollectibleUsernameByID keeps a service without it correct.
|
||||||
|
|
@ -389,6 +402,7 @@ type Dependencies struct {
|
||||||
Emoji EmojiService
|
Emoji EmojiService
|
||||||
Moderation ModerationService
|
Moderation ModerationService
|
||||||
Usernames CollectibleUsernamesService
|
Usernames CollectibleUsernamesService
|
||||||
|
ReservedUsernames ReservedUsernamesService
|
||||||
Verification VerificationService
|
Verification VerificationService
|
||||||
// BotVerification is the third-party mechanism, wired separately from
|
// BotVerification is the third-party mechanism, wired separately from
|
||||||
// Verification: the two never read each other's state.
|
// Verification: the two never read each other's state.
|
||||||
|
|
@ -420,6 +434,7 @@ type Service struct {
|
||||||
emoji EmojiService
|
emoji EmojiService
|
||||||
moderation ModerationService
|
moderation ModerationService
|
||||||
usernames CollectibleUsernamesService
|
usernames CollectibleUsernamesService
|
||||||
|
reservedUsernames ReservedUsernamesService
|
||||||
verification VerificationService
|
verification VerificationService
|
||||||
botVerification BotVerificationService
|
botVerification BotVerificationService
|
||||||
account AccountService
|
account AccountService
|
||||||
|
|
@ -487,6 +502,9 @@ func (s *Service) Configure(deps Dependencies) *Service {
|
||||||
if deps.Usernames != nil {
|
if deps.Usernames != nil {
|
||||||
s.usernames = deps.Usernames
|
s.usernames = deps.Usernames
|
||||||
}
|
}
|
||||||
|
if deps.ReservedUsernames != nil {
|
||||||
|
s.reservedUsernames = deps.ReservedUsernames
|
||||||
|
}
|
||||||
if deps.Verification != nil {
|
if deps.Verification != nil {
|
||||||
s.verification = deps.Verification
|
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) {
|
func collectibleOwnerPeer(userID, channelID int64) (domain.Peer, error) {
|
||||||
if userID < 0 || channelID < 0 {
|
if userID < 0 || channelID < 0 {
|
||||||
return domain.Peer{}, fmt.Errorf("owner id must be positive")
|
return domain.Peer{}, fmt.Errorf("owner id must be positive")
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import (
|
||||||
|
|
||||||
usernamesapp "telesrv/internal/app/usernames"
|
usernamesapp "telesrv/internal/app/usernames"
|
||||||
"telesrv/internal/domain"
|
"telesrv/internal/domain"
|
||||||
|
"telesrv/internal/store/memory"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Compile-time proof that the shipped use-case services satisfy the admin ports.
|
// 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")
|
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)
|
CollectibleUsernames(ctx context.Context, filter domain.CollectibleUsernameFilter) ([]domain.CollectibleUsername, error)
|
||||||
CollectibleUsernameByID(ctx context.Context, id int64) (domain.CollectibleUsername, error)
|
CollectibleUsernameByID(ctx context.Context, id int64) (domain.CollectibleUsername, error)
|
||||||
CollectibleUsernameTransfers(ctx context.Context, collectibleID int64, limit int) ([]domain.CollectibleUsernameTransfer, 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)
|
ClaimVerification(ctx context.Context, req admin.ClaimVerificationRequest) (admin.CommandResult, error)
|
||||||
ApproveVerification(ctx context.Context, req admin.ApproveVerificationRequest) (admin.CommandResult, error)
|
ApproveVerification(ctx context.Context, req admin.ApproveVerificationRequest) (admin.CommandResult, error)
|
||||||
RejectVerification(ctx context.Context, req admin.RejectVerificationRequest) (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("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", s.authenticated(s.handleCollectibleUsernames))
|
||||||
mux.HandleFunc("GET /v1/collectible-usernames/{id}", s.authenticated(s.handleCollectibleUsername))
|
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
|
// Official platform verification. Unlike every route above, these carry a
|
||||||
// named permission, so a scoped token can be given the review surface and
|
// named permission, so a scoped token can be given the review surface and
|
||||||
// nothing else. Revocation additionally requires verification.revoke.
|
// nothing else. Revocation additionally requires verification.revoke.
|
||||||
|
|
@ -1186,6 +1192,54 @@ func (s *Server) handleDeleteCollectibleUsername(w http.ResponseWriter, r *http.
|
||||||
writeCommandResult(w, result, err)
|
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) {
|
func (s *Server) handleCollectibleUsernames(w http.ResponseWriter, r *http.Request) {
|
||||||
query := r.URL.Query()
|
query := r.URL.Query()
|
||||||
filter := domain.CollectibleUsernameFilter{
|
filter := domain.CollectibleUsernameFilter{
|
||||||
|
|
|
||||||
|
|
@ -510,12 +510,30 @@ func (fakeService) ReviewModerationAppeal(context.Context, domain.ModerationDeci
|
||||||
|
|
||||||
type captureCollectibleUsernameService struct {
|
type captureCollectibleUsernameService struct {
|
||||||
fakeService
|
fakeService
|
||||||
mint admin.MintCollectibleUsernameRequest
|
mint admin.MintCollectibleUsernameRequest
|
||||||
transfer admin.TransferCollectibleUsernameRequest
|
transfer admin.TransferCollectibleUsernameRequest
|
||||||
revoke admin.RevokeCollectibleUsernameRequest
|
revoke admin.RevokeCollectibleUsernameRequest
|
||||||
del admin.DeleteCollectibleUsernameRequest
|
del admin.DeleteCollectibleUsernameRequest
|
||||||
filter domain.CollectibleUsernameFilter
|
reserve admin.ReserveUsernameRequest
|
||||||
assetID int64
|
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) {
|
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) {
|
func (fakeService) CollectibleUsernameTransfers(context.Context, int64, int) ([]domain.CollectibleUsernameTransfer, error) {
|
||||||
return nil, nil
|
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
|
transfers map[int64][]domain.CollectibleUsernameTransfer
|
||||||
// commands maps a provenance command key onto the asset it touched.
|
// commands maps a provenance command key onto the asset it touched.
|
||||||
commands map[string]int64
|
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
|
// 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
|
return false, domain.ErrUsernameInvalid
|
||||||
}
|
}
|
||||||
key := strings.ToLower(username)
|
key := strings.ToLower(username)
|
||||||
|
if s.nameReservedLocked(key) {
|
||||||
|
return false, domain.ErrUsernameOccupied
|
||||||
|
}
|
||||||
if existing, ok := s.registry[key]; ok {
|
if existing, ok := s.registry[key]; ok {
|
||||||
if existing.peer == peer && existing.row.Editable {
|
if existing.peer == peer && existing.row.Editable {
|
||||||
if existing.row.Username == username {
|
if existing.row.Username == username {
|
||||||
|
|
@ -313,6 +334,9 @@ func (s *CollectibleUsernameStore) MintCollectibleUsername(_ context.Context, re
|
||||||
if _, ok := s.registry[key]; ok {
|
if _, ok := s.registry[key]; ok {
|
||||||
return domain.CollectibleUsername{}, false, domain.ErrUsernameOccupied
|
return domain.CollectibleUsername{}, false, domain.ErrUsernameOccupied
|
||||||
}
|
}
|
||||||
|
if s.nameReservedLocked(key) {
|
||||||
|
return domain.CollectibleUsername{}, false, domain.ErrUsernameOccupied
|
||||||
|
}
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
purchaseDate := req.PurchaseDate
|
purchaseDate := req.PurchaseDate
|
||||||
if purchaseDate.IsZero() {
|
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 {
|
} else if found {
|
||||||
return domain.ErrUsernameOccupied
|
return domain.ErrUsernameOccupied
|
||||||
}
|
}
|
||||||
|
if reserved, err := usernameReservedTx(ctx, tx, usernameLower); err != nil {
|
||||||
|
return err
|
||||||
|
} else if reserved {
|
||||||
|
return domain.ErrUsernameOccupied
|
||||||
|
}
|
||||||
var existing int64
|
var existing int64
|
||||||
switch err := tx.QueryRow(ctx, `
|
switch err := tx.QueryRow(ctx, `
|
||||||
SELECT id FROM collectible_usernames
|
SELECT id FROM collectible_usernames
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,21 @@ func getPeerUsernameOwner(ctx context.Context, db sqlcgen.DBTX, usernameLower st
|
||||||
return owner, true, nil
|
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) {
|
func peerUsernameAvailable(ctx context.Context, db sqlcgen.DBTX, usernameLower, peerType string, peerID int64) (bool, error) {
|
||||||
owner, found, err := getPeerUsernameOwner(ctx, db, usernameLower, false)
|
owner, found, err := getPeerUsernameOwner(ctx, db, usernameLower, false)
|
||||||
if err != nil || !found {
|
if err != nil || !found {
|
||||||
|
|
@ -111,6 +126,11 @@ WHERE peer_type = $1
|
||||||
// otherwise account.updateUsername would silently release a minted asset.
|
// 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 {
|
func replacePeerUsernameTx(ctx context.Context, tx pgx.Tx, peerType string, peerID int64, username, usernameLower string) error {
|
||||||
if usernameLower != "" {
|
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)
|
owner, found, err := getPeerUsernameOwner(ctx, tx, usernameLower, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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