mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
The in-app Admin Console needs to work on any community the relay
serves, not only the one the desktop is connected to. This adds four
read-only routes under `/api/admin/v1` that the stacked desktop PR
builds on, puts every admin route under one explicit access rule, and
binds every admin request's signature to its real method, which fixes
signed `HEAD` on reads.
## Access rule
| Mode | Moderation reads | Writes | Staffing (`/operators`) |
|---|---|---|---|
| `nip98` (default) | signed, any staff role | signed, role-checked |
signed Operator |
| `disabled` | served to anyone who can reach the relay | `403` | `403`
|
"Moderation reads" means every read: reports, feedback and attachments,
restrictions, and the four community routes below. `nip98` behavior is
unchanged, and the Operator API (`/operator/*`) is untouched.
**Existing `BUZZ_ADMIN_AUTH=disabled` deployments start serving the four
new reads** (community directory, member search, member lookup without
staff status, and stored events by ID) with no config change. The
startup `WARN`, `docs/admin/README.md`, `.env.example`,
`deploy/compose/.env.example` and the `just admin` comment list exactly
what disabled mode exposes, say it should be reachable only by people
allowed to see all moderation data in every community, and say the whole
relay port must be private: the admin API is on the relay's main
listener and its hostname check uses the caller's `Host` header. `just
admin` binds the relay to `127.0.0.1` unless `BUZZ_ADMIN_AUTH` is
exactly `nip98`, so an unset, padded or misspelled value stays local.
In code, `authorize()` returns `AdminAccess`, either `NetworkTrusted`
(disabled mode, no identity) or `Staff(AdminPrincipal)` (`nip98`, any
rostered Operator or Moderator). Each route makes one check: **view**
(`authorize()` succeeding), **act** (`AdminAccess::act()`, must be
`Staff`, then the existing role checks) or **operator**
(`AdminAccess::operator()`, must be an Operator). Act and operator
refuse `NetworkTrusted` with `403 this endpoint requires
BUZZ_ADMIN_AUTH=nip98`; no principal is ever created for an anonymous
caller. `every_route_makes_its_check_in_both_auth_modes` lists every
mounted admin route with its check and asserts the result in both modes,
and `every_route_is_listed` fails when `router()` mounts a route that
list does not name.
## Routes
All four live in `api/admin/reads.rs`; `mod.rs` only registers them.
Their queries reject unknown fields, like `ReportQuery`. The three
`?communityHost=` routes share `CommunityQuery`, which now rejects
unknown fields too, so the existing unban, untimeout and direct-action
routes that use it do the same; their desktop callers send only
`communityHost`. Every admin route that takes `limit` validates it with
the module's shared `limit()` helper, so an out-of-range value is `400
invalid_limit`.
| Route | Returns |
|---|---|
| `GET /communities?q=&cursor=&limit=` | `{items: [{id, host,
icon\|null}], nextCursor\|null}` |
| `GET /members/search?communityHost=&q=&limit=` | `{items: [{pubkey,
displayName\|null, nip05\|null, avatarUrl\|null}]}` |
| `GET /members/{pubkey}?communityHost=` | `{pubkey, profile:
{displayName, nip05, avatarUrl, about}\|null, role:
"owner"\|"admin"\|"member"\|null, banned, mutedUntil\|null, isStaff:
bool\|null}` |
| `GET /events/{id}?communityHost=` | `{id, authorPubkey, kind, content,
createdAt, deletedAt\|null, channelId\|null}` |
- **One community per read.** Community-scoped routes bind
`communityHost` through the tenant binder (`400 unknown_community_host`)
and read only that community's `users`, `relay_members`,
`community_bans` and `events` rows.
- **Directory.** It uses the binder's liveness rule (not archived, not
deleted, `deletion_state = 'active'`), so every listed host can be
opened. `q` is an escaped, case-insensitive host prefix of at most 255
characters (`400 invalid_query`). Results are ordered and paged by
`lower(host)`, which is unique, so the opaque base64url cursor carries
only the last host; a malformed cursor is `400 invalid_cursor`. `limit`
is 1 to 100 with a default of 50.
- **Member search.** It reuses `search_users`, which already escapes,
binds and scopes, with no second escaping layer. `q` is trimmed and must
be 1 to 100 characters (`400 invalid_query`), and `limit` is 1 to 50
(default 20). It searches community profiles, so former members appear.
- **Member lookup.** `isStaff` uses the same roster lookup as the
direct-action staff guard and is returned to signed staff only: it is
`null` in disabled mode, where the roster is never read. A roster lookup
failure is a 500, never "not staff".
- **Event preview.** It reads the event by `(community_id, id)` and
never returns a private kind (see below). A malformed id, an id stored
only in another community and a private kind are all `404
event_not_found`, the same answer as an absent event.
## Private events
Admin reads never reveal events the relay keeps private.
`admin_hidden_kinds()` combines the four private kind sets in
`buzz_core::kind`: `AUTHOR_ONLY_KINDS`, `RESULT_GATED_KINDS`,
`P_GATED_KINDS` and `SHARED_GATED_KINDS`. The whole shared-gated set is
hidden, including events tagged `["shared","true"]`; the shared tag is
never evaluated. Every HTTP response applies it the way it treats a
missing event: event preview is `404 event_not_found`, report detail
returns the report with `message: null` and `targetAuthorPubkey: null`,
and the report list returns a null target author.
Moderators can still act on these events, they just cannot read them.
Report detail is one query with the hidden-kind list as a parameter,
exposed two ways: `admin_get_report` hides private kinds and backs every
HTTP response (GET detail, the resolve response, reopen, cancel and the
reporter-notice worker), while `admin_get_report_for_enforcement` reads
the target unfiltered and is used only by the resolve handler's
enforcement load (the staff-target check and ban, timeout and kick
target derivation) and by the recovery worker for interrupted actions.
Its result is never serialized, so a ban from a report on a private
persona bans the real author while GET still shows nothing.
## Method binding
Axum serves `HEAD` through `GET` handlers, but the read handlers
authorized with a literal `"GET"`, so a correctly signed `HEAD` was
rejected. Admin reads now authorize through `authorize_read` and writes
through `authorize_write`, both of which use the request's real method
and full target, so no handler hardcodes a method string. A database
error behind any admin route is now logged before it becomes a 500.
## Directory query plan
`idx_communities_host` is on `lower(host)` in the default collation, so
the prefix `LIKE` is a filter, not an index condition. The plan walks
the index in order and stops once it has `limit` matches. A common
prefix is cheap (on local Postgres 16 with 21,359 communities, a first
page skipped 2,728 index rows in 3.9 ms), but a prefix with few or no
matches walks every index entry, so its cost grows with the number of
communities. A `COLLATE "C"` index would turn the prefix into an index
range; it is left out because the admin directory is low-traffic and the
cost is small at current scale.
The cursor condition is `lower(host) > COALESCE($2, '')`, with no `OR`,
so it remains an index condition under a generic plan:
```text
-> Bitmap Index Scan on idx_communities_host
Index Cond: (lower((host)::text) > COALESCE($2, ''::text))
```
---------
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <wpfleger@block.xyz>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
Co-authored-by: Hayt <211b96e6a2b7f45fd4047988976c7bbbeeda0c15f3ae7b32eec20834b5a55118@buzz.block.builderlab.xyz>
1027 lines
31 KiB
TypeScript
1027 lines
31 KiB
TypeScript
import {
|
|
type ChangeEvent,
|
|
type ReactNode,
|
|
useCallback,
|
|
useEffect,
|
|
useMemo,
|
|
useState,
|
|
} from "react";
|
|
import {
|
|
type AuthMode,
|
|
ApiFailure,
|
|
mutate,
|
|
probeAuthMode,
|
|
request,
|
|
requestObjectUrl,
|
|
} from "./api";
|
|
import type {
|
|
FeedbackDetail,
|
|
FeedbackStatus,
|
|
FeedbackSummary,
|
|
Report,
|
|
ReportDetail as ReportDetailData,
|
|
} from "./types";
|
|
import { useResource } from "./useResource";
|
|
|
|
function usePath() {
|
|
const [path, setPath] = useState(location.pathname);
|
|
useEffect(() => {
|
|
const update = () => setPath(location.pathname);
|
|
addEventListener("popstate", update);
|
|
return () => removeEventListener("popstate", update);
|
|
}, []);
|
|
const navigate = useCallback((url: string) => {
|
|
history.pushState(null, "", url);
|
|
dispatchEvent(new PopStateEvent("popstate"));
|
|
}, []);
|
|
return { path, navigate };
|
|
}
|
|
|
|
function Link({
|
|
href,
|
|
className,
|
|
activeWhenNested = false,
|
|
children,
|
|
}: {
|
|
href: string;
|
|
className?: string;
|
|
activeWhenNested?: boolean;
|
|
children: ReactNode;
|
|
}) {
|
|
const { path, navigate } = usePath();
|
|
const active =
|
|
path === href || (activeWhenNested && path.startsWith(`${href}/`));
|
|
return (
|
|
<a
|
|
href={href}
|
|
className={className}
|
|
aria-current={active ? "page" : undefined}
|
|
onClick={(event) => {
|
|
if (!event.metaKey && !event.ctrlKey) {
|
|
event.preventDefault();
|
|
navigate(href);
|
|
}
|
|
}}
|
|
>
|
|
{children}
|
|
</a>
|
|
);
|
|
}
|
|
|
|
function StateView<T>({
|
|
resource,
|
|
children,
|
|
}: {
|
|
resource: ReturnType<typeof useResource<T>>;
|
|
children: (data: T) => ReactNode;
|
|
}) {
|
|
if (resource.loading && !resource.data)
|
|
return <div className="state">Loading…</div>;
|
|
if (resource.error && !resource.data) {
|
|
const forbidden =
|
|
resource.error instanceof ApiFailure && resource.error.status === 403;
|
|
return (
|
|
<div className="state error" role="alert">
|
|
<h2>{forbidden ? "Access denied" : "Could not load data"}</h2>
|
|
<p>{resource.error.message}</p>
|
|
<button type="button" onClick={resource.refetch}>
|
|
Retry
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
return resource.data ? children(resource.data) : null;
|
|
}
|
|
|
|
function Reports({ authMode }: { authMode: AuthMode }) {
|
|
const resource = useResource(
|
|
() => request<Report[]>("/reports?status=open&limit=100", authMode),
|
|
"reports",
|
|
);
|
|
return (
|
|
<Page
|
|
eyebrow="Moderation"
|
|
title="Open reports"
|
|
description="Review reports across every Buzz community."
|
|
>
|
|
<StateView resource={resource}>
|
|
{(reports) =>
|
|
reports.length ? (
|
|
<div className="cards">
|
|
{reports.map((report) => (
|
|
<Link
|
|
key={report.id}
|
|
href={`/reports/${report.id}`}
|
|
className="card-link"
|
|
>
|
|
<article className="record-card">
|
|
<span className="record-icon report-icon">
|
|
<ReportIcon />
|
|
</span>
|
|
<div className="record-primary">
|
|
<span className="tag">{report.reportType}</span>
|
|
<strong>{report.communityHost}</strong>
|
|
<code>
|
|
{report.targetKind}: {short(report.target)}
|
|
</code>
|
|
</div>
|
|
<div className="record-date">
|
|
<span>Submitted</span>
|
|
<time>{date(report.createdAt)}</time>
|
|
</div>
|
|
<ArrowIcon />
|
|
</article>
|
|
</Link>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<Empty />
|
|
)
|
|
}
|
|
</StateView>
|
|
</Page>
|
|
);
|
|
}
|
|
|
|
function ReportDetail({ id, authMode }: { id: string; authMode: AuthMode }) {
|
|
const resource = useResource(
|
|
() => request<ReportDetailData>(`/reports/${id}`, authMode),
|
|
id,
|
|
);
|
|
return (
|
|
<Page
|
|
eyebrow="Moderation"
|
|
title="Report detail"
|
|
description="The full report as submitted to the relay."
|
|
back="/reports"
|
|
>
|
|
<StateView resource={resource}>
|
|
{(report) => (
|
|
<article className="detail">
|
|
<div className="detail-heading">
|
|
<span className="record-icon report-icon">
|
|
<ReportIcon />
|
|
</span>
|
|
<div>
|
|
<span className="tag">{report.reportType}</span>
|
|
<h2>{report.communityHost}</h2>
|
|
</div>
|
|
</div>
|
|
<dl>
|
|
<dt>Status</dt>
|
|
<dd>
|
|
<span className="status">{report.status}</span>
|
|
</dd>
|
|
<dt>Reporter</dt>
|
|
<dd>
|
|
<code>{report.reporterPubkey}</code>
|
|
</dd>
|
|
<dt>Target</dt>
|
|
<dd>
|
|
<code>{report.target}</code>
|
|
</dd>
|
|
{report.targetKind === "event" ? (
|
|
<>
|
|
<dt>Message</dt>
|
|
<dd>
|
|
{report.message ? (
|
|
<div className="reported-message">
|
|
{report.message.deletedAt ? (
|
|
<span className="status">Deleted</span>
|
|
) : null}
|
|
<p>{report.message.content}</p>
|
|
<div className="reported-message-meta">
|
|
<span>Author</span>
|
|
<code>{report.message.authorPubkey}</code>
|
|
<span>Created</span>
|
|
<time>{date(report.message.createdAt)}</time>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<p className="message-unavailable">
|
|
Message content is unavailable.
|
|
</p>
|
|
)}
|
|
</dd>
|
|
</>
|
|
) : null}
|
|
<dt>Note</dt>
|
|
<dd className="sensitive">
|
|
{report.note ?? "No note provided."}
|
|
</dd>
|
|
</dl>
|
|
</article>
|
|
)}
|
|
</StateView>
|
|
</Page>
|
|
);
|
|
}
|
|
|
|
function FeedbackList({ authMode }: { authMode: AuthMode }) {
|
|
const resource = useResource(
|
|
() => request<FeedbackSummary[]>("/feedback", authMode),
|
|
"feedback",
|
|
);
|
|
const [query, setQuery] = useState("");
|
|
const [community, setCommunity] = useState("all");
|
|
const [timeRange, setTimeRange] = useState("all");
|
|
const [statusFilter, setStatusFilter] = useState("all");
|
|
// Successful PATCH responses override the server-loaded status so the row
|
|
// reflects the new value without a full refetch. Keyed by feedback id.
|
|
const [overrides, setOverrides] = useState<Record<string, FeedbackStatus>>(
|
|
{},
|
|
);
|
|
|
|
// Mutations require a named principal; disabled mode's server rejects them
|
|
// (probe reports canAct: false), so the write control is hidden there rather
|
|
// than offering an action that can only fail.
|
|
const canWrite = authMode === "nip98";
|
|
|
|
const applyStatus = useCallback((id: string, status: FeedbackStatus) => {
|
|
setOverrides((current) => ({ ...current, [id]: status }));
|
|
}, []);
|
|
|
|
return (
|
|
<Page
|
|
eyebrow="Product"
|
|
title="Feedback"
|
|
description="Recent product feedback from across Buzz."
|
|
>
|
|
<StateView resource={resource}>
|
|
{(items) => {
|
|
if (!items.length) return <Empty />;
|
|
return (
|
|
<FeedbackResults
|
|
items={items}
|
|
query={query}
|
|
community={community}
|
|
timeRange={timeRange}
|
|
statusFilter={statusFilter}
|
|
overrides={overrides}
|
|
>
|
|
{({ communities, filtered }) => (
|
|
<>
|
|
<div className="feedback-filters">
|
|
<label className="search-field">
|
|
<span>Search feedback</span>
|
|
<div>
|
|
<SearchIcon />
|
|
<input
|
|
type="search"
|
|
placeholder="Search feedback"
|
|
value={query}
|
|
onChange={(event) => setQuery(event.target.value)}
|
|
/>
|
|
</div>
|
|
</label>
|
|
<label>
|
|
<span>Community</span>
|
|
<select
|
|
value={community}
|
|
onChange={(event) => setCommunity(event.target.value)}
|
|
>
|
|
<option value="all">All communities</option>
|
|
{communities.map((host) => (
|
|
<option key={host} value={host}>
|
|
{host}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<label>
|
|
<span>Received</span>
|
|
<select
|
|
value={timeRange}
|
|
onChange={(event) => setTimeRange(event.target.value)}
|
|
>
|
|
<option value="all">Any time</option>
|
|
<option value="day">Last 24 hours</option>
|
|
<option value="week">Last 7 days</option>
|
|
<option value="month">Last 30 days</option>
|
|
</select>
|
|
</label>
|
|
<label>
|
|
<span>Status</span>
|
|
<select
|
|
value={statusFilter}
|
|
onChange={(event) =>
|
|
setStatusFilter(event.target.value)
|
|
}
|
|
>
|
|
<option value="all">Any status</option>
|
|
<option value="new">New</option>
|
|
<option value="reviewed">Reviewed</option>
|
|
<option value="archived">Archived</option>
|
|
</select>
|
|
</label>
|
|
</div>
|
|
<p className="result-count" aria-live="polite">
|
|
{filtered.length} of {items.length} submissions
|
|
</p>
|
|
{filtered.length ? (
|
|
<div className="cards">
|
|
{filtered.map((item) => (
|
|
<article
|
|
key={item.id}
|
|
className="record-card feedback-card feedback-record"
|
|
>
|
|
<Link
|
|
href={`/feedback/${item.id}`}
|
|
className="feedback-main-link"
|
|
>
|
|
<span className="record-icon feedback-icon">
|
|
<CategoryIcon category={item.category} />
|
|
</span>
|
|
<div className="record-primary">
|
|
<CategoryTag category={item.category} />
|
|
<strong>{item.bodySummary}</strong>
|
|
<span className="record-provenance">
|
|
<Provenance host={item.communityHost} />
|
|
<code>{short(item.submitterPubkey)}</code>
|
|
</span>
|
|
</div>
|
|
</Link>
|
|
<FeedbackStatusControl
|
|
id={item.id}
|
|
status={overrides[item.id] ?? item.status}
|
|
authMode={authMode}
|
|
canWrite={canWrite}
|
|
onApplied={applyStatus}
|
|
/>
|
|
<div className="record-date">
|
|
<span>Received</span>
|
|
<time>{date(item.receivedAt)}</time>
|
|
</div>
|
|
<Link
|
|
href={`/feedback/${item.id}`}
|
|
className="record-open-link"
|
|
>
|
|
<span className="visually-hidden">
|
|
Open feedback from {hostLabel(item.communityHost)}
|
|
</span>
|
|
<ArrowIcon />
|
|
</Link>
|
|
</article>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className="state">No matching feedback.</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</FeedbackResults>
|
|
);
|
|
}}
|
|
</StateView>
|
|
</Page>
|
|
);
|
|
}
|
|
|
|
const STATUS_LABELS: Record<FeedbackStatus, string> = {
|
|
new: "New",
|
|
reviewed: "Reviewed",
|
|
archived: "Archived",
|
|
};
|
|
|
|
const STATUS_OPTIONS: FeedbackStatus[] = ["new", "reviewed", "archived"];
|
|
|
|
/// The per-row lifecycle control. In writable mode it PATCHes the relay and
|
|
/// adopts the status from the PATCH response (never optimistically — a failed
|
|
/// write leaves the prior status and surfaces an error). In read-only mode it
|
|
/// shows the authoritative status as a static badge, since disabled-mode
|
|
/// mutations are rejected server-side.
|
|
function FeedbackStatusControl({
|
|
id,
|
|
status,
|
|
authMode,
|
|
canWrite,
|
|
onApplied,
|
|
}: {
|
|
id: string;
|
|
status: FeedbackStatus;
|
|
authMode: AuthMode;
|
|
canWrite: boolean;
|
|
onApplied: (id: string, status: FeedbackStatus) => void;
|
|
}) {
|
|
const [pending, setPending] = useState(false);
|
|
const [error, setError] = useState(false);
|
|
|
|
if (!canWrite) {
|
|
return (
|
|
<span className="feedback-status">
|
|
<span className={`status status-${status}`}>
|
|
{STATUS_LABELS[status]}
|
|
</span>
|
|
</span>
|
|
);
|
|
}
|
|
|
|
const onChange = async (event: ChangeEvent<HTMLSelectElement>) => {
|
|
const next = event.target.value as FeedbackStatus;
|
|
setPending(true);
|
|
setError(false);
|
|
try {
|
|
const updated = await mutate<{ status: FeedbackStatus }>(
|
|
`/feedback/${encodeURIComponent(id)}`,
|
|
"PATCH",
|
|
{ status: next },
|
|
authMode,
|
|
);
|
|
onApplied(id, updated.status);
|
|
} catch {
|
|
setError(true);
|
|
} finally {
|
|
setPending(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<label className="feedback-status">
|
|
<span className="visually-hidden">Status</span>
|
|
<select value={status} onChange={onChange} disabled={pending}>
|
|
{STATUS_OPTIONS.map((option) => (
|
|
<option key={option} value={option}>
|
|
{STATUS_LABELS[option]}
|
|
</option>
|
|
))}
|
|
</select>
|
|
{error ? (
|
|
<span className="status-error" role="alert">
|
|
Update failed
|
|
</span>
|
|
) : null}
|
|
</label>
|
|
);
|
|
}
|
|
|
|
/// Feedback whose source community was purged carries no host. Render an
|
|
/// explicit provenance-unavailable marker rather than an empty slot.
|
|
function Provenance({ host }: { host: string | null }) {
|
|
if (host) return host;
|
|
return <span className="provenance-unavailable">Community unavailable</span>;
|
|
}
|
|
|
|
function hostLabel(host: string | null): string {
|
|
return host ?? "an unavailable community";
|
|
}
|
|
|
|
function FeedbackResults({
|
|
items,
|
|
query,
|
|
community,
|
|
timeRange,
|
|
statusFilter,
|
|
overrides,
|
|
children,
|
|
}: {
|
|
items: FeedbackSummary[];
|
|
query: string;
|
|
community: string;
|
|
timeRange: string;
|
|
statusFilter: string;
|
|
overrides: Record<string, FeedbackStatus>;
|
|
children: (results: {
|
|
communities: string[];
|
|
filtered: FeedbackSummary[];
|
|
}) => ReactNode;
|
|
}) {
|
|
const results = useMemo(() => {
|
|
const communities = [...new Set(items.map((item) => item.communityHost))]
|
|
.filter((host): host is string => Boolean(host))
|
|
.sort((left, right) => left.localeCompare(right));
|
|
const normalizedQuery = query.trim().toLocaleLowerCase();
|
|
const after = timeRangeStart(timeRange);
|
|
const filtered = items.filter((item) => {
|
|
const status = overrides[item.id] ?? item.status;
|
|
if (community !== "all" && item.communityHost !== community) return false;
|
|
if (statusFilter !== "all" && status !== statusFilter) return false;
|
|
if (after !== undefined) {
|
|
const receivedAt = new Date(item.receivedAt).valueOf();
|
|
if (Number.isNaN(receivedAt) || receivedAt < after) return false;
|
|
}
|
|
if (!normalizedQuery) return true;
|
|
return [
|
|
item.bodySummary,
|
|
item.communityHost ?? "",
|
|
item.category ?? "uncategorized",
|
|
item.submitterPubkey,
|
|
].some((value) => value.toLocaleLowerCase().includes(normalizedQuery));
|
|
});
|
|
return { communities, filtered };
|
|
}, [items, query, community, timeRange, statusFilter, overrides]);
|
|
return children(results);
|
|
}
|
|
|
|
function FeedbackDetailView({
|
|
id,
|
|
authMode,
|
|
}: {
|
|
id: string;
|
|
authMode: AuthMode;
|
|
}) {
|
|
const resource = useResource(
|
|
() => request<FeedbackDetail>(`/feedback/${id}`, authMode),
|
|
id,
|
|
);
|
|
return (
|
|
<Page
|
|
eyebrow="Product"
|
|
title="Feedback detail"
|
|
description="The complete feedback submission and its source."
|
|
back="/feedback"
|
|
backLabel="Back to feedback"
|
|
>
|
|
<StateView resource={resource}>
|
|
{(feedback) => {
|
|
// Without an authoritative host we cannot validate attachment URLs or
|
|
// derive their fetch paths safely, so we render none and mark the
|
|
// provenance unavailable rather than guessing an origin.
|
|
const attachments = feedback.communityHost
|
|
? feedbackAttachments(
|
|
feedback.id,
|
|
feedback.tags,
|
|
feedback.communityHost,
|
|
)
|
|
: [];
|
|
const body = stripAttachmentMarkdown(feedback.body, attachments);
|
|
return (
|
|
<article className="detail">
|
|
<div className="detail-heading">
|
|
<span className="record-icon feedback-icon">
|
|
<CategoryIcon category={feedback.category} />
|
|
</span>
|
|
<div>
|
|
<CategoryTag category={feedback.category} />
|
|
<h2>
|
|
<Provenance host={feedback.communityHost} />
|
|
</h2>
|
|
</div>
|
|
</div>
|
|
<dl>
|
|
<dt>Feedback</dt>
|
|
<dd className="sensitive feedback-body">{body}</dd>
|
|
{attachments.length ? (
|
|
<>
|
|
<dt>Attachments</dt>
|
|
<dd className="attachments">
|
|
{attachments.map((attachment) => (
|
|
<Attachment
|
|
key={`${attachment.hash}-${attachment.path}`}
|
|
attachment={attachment}
|
|
authMode={authMode}
|
|
/>
|
|
))}
|
|
</dd>
|
|
</>
|
|
) : null}
|
|
<dt>Submitted by</dt>
|
|
<dd>
|
|
<code>{feedback.submitterPubkey}</code>
|
|
</dd>
|
|
<dt>Event</dt>
|
|
<dd>
|
|
<code>{feedback.eventId}</code>
|
|
</dd>
|
|
<dt>Created</dt>
|
|
<dd>{date(feedback.eventCreatedAt)}</dd>
|
|
<dt>Received</dt>
|
|
<dd>{date(feedback.receivedAt)}</dd>
|
|
</dl>
|
|
</article>
|
|
);
|
|
}}
|
|
</StateView>
|
|
</Page>
|
|
);
|
|
}
|
|
|
|
interface FeedbackAttachment {
|
|
path: string;
|
|
sourceUrl: string;
|
|
mimeType: string;
|
|
hash: string;
|
|
size?: number;
|
|
dimensions?: string;
|
|
filename?: string;
|
|
}
|
|
|
|
function timeRangeStart(range: string) {
|
|
const durations: Record<string, number> = {
|
|
day: 24 * 60 * 60 * 1000,
|
|
week: 7 * 24 * 60 * 60 * 1000,
|
|
month: 30 * 24 * 60 * 60 * 1000,
|
|
};
|
|
const duration = durations[range];
|
|
return duration ? Date.now() - duration : undefined;
|
|
}
|
|
|
|
function feedbackAttachments(
|
|
feedbackId: string,
|
|
tags: string[][],
|
|
communityHost: string,
|
|
): FeedbackAttachment[] {
|
|
return tags.flatMap((tag) => {
|
|
if (tag[0] !== "imeta") return [];
|
|
const values = new Map<string, string>();
|
|
for (const entry of tag.slice(1)) {
|
|
const separator = entry.indexOf(" ");
|
|
if (separator > 0) {
|
|
values.set(entry.slice(0, separator), entry.slice(separator + 1));
|
|
}
|
|
}
|
|
const url = values.get("url");
|
|
const mimeType = values.get("m");
|
|
const hash = values.get("x");
|
|
const safeUrl = url && safeAttachmentUrl(url, communityHost);
|
|
if (!safeUrl || !mimeType || !hash) return [];
|
|
const parsedSize = Number(values.get("size"));
|
|
return [
|
|
{
|
|
path: `/feedback/${encodeURIComponent(feedbackId)}/attachments/${hash}`,
|
|
sourceUrl: safeUrl,
|
|
mimeType,
|
|
hash,
|
|
size:
|
|
Number.isFinite(parsedSize) && parsedSize > 0
|
|
? parsedSize
|
|
: undefined,
|
|
dimensions: values.get("dim"),
|
|
filename: values.get("filename"),
|
|
},
|
|
];
|
|
});
|
|
}
|
|
|
|
function safeAttachmentUrl(value: string, communityHost: string) {
|
|
try {
|
|
const url = new URL(value, `${location.protocol}//${communityHost}`);
|
|
return ["http:", "https:"].includes(url.protocol) &&
|
|
url.host.toLocaleLowerCase() === communityHost.toLocaleLowerCase() &&
|
|
url.pathname.startsWith("/media/")
|
|
? url.href
|
|
: undefined;
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
function stripAttachmentMarkdown(
|
|
body: string,
|
|
attachments?: FeedbackAttachment[],
|
|
) {
|
|
const knownUrls = attachments
|
|
? new Set(attachments.map((attachment) => attachment.sourceUrl))
|
|
: undefined;
|
|
return body
|
|
.replace(/!?\[[^\]]*\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g, (match, url) => {
|
|
const isMedia = knownUrls ? knownUrls.has(url) : url.includes("/media/");
|
|
return isMedia ? "" : match;
|
|
})
|
|
.replace(/\n{3,}/g, "\n\n")
|
|
.trim();
|
|
}
|
|
|
|
function Attachment({
|
|
attachment,
|
|
authMode,
|
|
}: {
|
|
attachment: FeedbackAttachment;
|
|
authMode: AuthMode;
|
|
}) {
|
|
const [objectUrl, setObjectUrl] = useState<string>();
|
|
const [objectType, setObjectType] = useState<string>();
|
|
const [failed, setFailed] = useState(false);
|
|
const path = attachment.path;
|
|
const name =
|
|
attachment.filename ?? `attachment-${attachment.hash.slice(0, 8)}`;
|
|
const metadata = [
|
|
attachment.mimeType,
|
|
attachment.dimensions,
|
|
attachment.size ? formatBytes(attachment.size) : undefined,
|
|
]
|
|
.filter(Boolean)
|
|
.join(" · ");
|
|
|
|
useEffect(() => {
|
|
// The API requires an Authorization header, so the bytes are fetched here
|
|
// and handed to the DOM as an object URL revoked on replacement/unmount.
|
|
let url: string | undefined;
|
|
let active = true;
|
|
setObjectUrl(undefined);
|
|
setObjectType(undefined);
|
|
setFailed(false);
|
|
requestObjectUrl(path, authMode)
|
|
.then((created) => {
|
|
if (!active) {
|
|
URL.revokeObjectURL(created.url);
|
|
return;
|
|
}
|
|
url = created.url;
|
|
setObjectUrl(created.url);
|
|
setObjectType(created.type);
|
|
})
|
|
.catch(() => {
|
|
if (active) setFailed(true);
|
|
});
|
|
return () => {
|
|
active = false;
|
|
if (url) URL.revokeObjectURL(url);
|
|
};
|
|
}, [path, authMode]);
|
|
|
|
const detail = failed ? "Could not load attachment" : metadata;
|
|
|
|
// Inline rendering is gated on the server-VERIFIED blob type, never the
|
|
// reporter-supplied `attachment.mimeType`: the relay only labels sniffed
|
|
// passive raster images as `image/*` and forces everything else to
|
|
// `application/octet-stream`, so a hostile payload can never render inline.
|
|
if (objectUrl && objectType?.startsWith("image/")) {
|
|
return (
|
|
<figure className="image-attachment">
|
|
<a href={objectUrl} target="_blank" rel="noreferrer">
|
|
<img src={objectUrl} alt={name} />
|
|
</a>
|
|
<figcaption>
|
|
<span>{name}</span>
|
|
<small>{detail}</small>
|
|
</figcaption>
|
|
</figure>
|
|
);
|
|
}
|
|
|
|
const label = (
|
|
<>
|
|
<FileIcon />
|
|
<span>
|
|
<strong>{name}</strong>
|
|
<small>{detail}</small>
|
|
</span>
|
|
</>
|
|
);
|
|
|
|
// Until the bytes are fetched there is nothing a link could point at: the
|
|
// API path itself would open unauthenticated in a new tab.
|
|
if (!objectUrl) return <div className="file-attachment">{label}</div>;
|
|
|
|
// Non-image (or not-yet-typed) payloads are download-only. The object URL
|
|
// already wraps `application/octet-stream` bytes, and the `download`
|
|
// attribute with no `target="_blank"` means clicking saves the file rather
|
|
// than navigating to a typed document on the admin origin.
|
|
return (
|
|
<a className="file-attachment" href={objectUrl} download={name}>
|
|
{label}
|
|
<ArrowIcon />
|
|
</a>
|
|
);
|
|
}
|
|
|
|
function formatBytes(bytes: number) {
|
|
if (bytes < 1024) return `${bytes} B`;
|
|
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
|
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
}
|
|
|
|
function Page({
|
|
eyebrow,
|
|
title,
|
|
description,
|
|
back,
|
|
backLabel,
|
|
children,
|
|
}: {
|
|
eyebrow: string;
|
|
title: string;
|
|
description: string;
|
|
back?: string;
|
|
backLabel?: string;
|
|
children: ReactNode;
|
|
}) {
|
|
return (
|
|
<section>
|
|
<header className="page-title">
|
|
{back ? (
|
|
<Link href={back} className="back-link">
|
|
<ArrowIcon /> {backLabel ?? "Back to reports"}
|
|
</Link>
|
|
) : null}
|
|
<p>{eyebrow}</p>
|
|
<h1>{title}</h1>
|
|
<span>{description}</span>
|
|
</header>
|
|
{children}
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function Empty() {
|
|
return <div className="state">No records.</div>;
|
|
}
|
|
|
|
function short(value: string) {
|
|
return value.length > 20 ? `${value.slice(0, 10)}…${value.slice(-8)}` : value;
|
|
}
|
|
|
|
function date(value: string) {
|
|
const parsed = new Date(value);
|
|
return Number.isNaN(parsed.valueOf())
|
|
? "Unknown date"
|
|
: parsed.toLocaleString();
|
|
}
|
|
|
|
function BuzzMark() {
|
|
return (
|
|
<svg viewBox="0 0 466 309" aria-hidden="true">
|
|
<path d="M91.7 62.8a91.7 91.7 0 0 0 0 183.4H128V62.8H91.7Zm282.6 0H338v183.4h36.3a91.7 91.7 0 1 0 0-183.4Z" />
|
|
<path
|
|
fillRule="evenodd"
|
|
d="M162 0h142a34 34 0 0 1 34 34v241a34 34 0 0 1-34 34H162a34 34 0 0 1-34-34V34a34 34 0 0 1 34-34Zm31.3 57.4a27 27 0 1 0 0 54 27 27 0 0 0 0-54Zm82.7 0a27 27 0 1 0 0 54 27 27 0 0 0 0-54Zm-109.7 99.8h136.9v38.3H166.3v-38.3Zm.6 77.9h136.2v37.6H166.9v-37.6Z"
|
|
clipRule="evenodd"
|
|
/>
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function ReportIcon() {
|
|
return (
|
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
|
<path d="M12 3 4.5 6v5.2c0 4.7 3.2 8.8 7.5 9.8 4.3-1 7.5-5.1 7.5-9.8V6L12 3Z" />
|
|
<path d="M12 7.5v5M12 16.5h.01" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function FeedbackIcon() {
|
|
return (
|
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
|
<path d="M5 5.5h14v10H9l-4 3v-13Z" />
|
|
<path d="M8.5 9h7M8.5 12h4.5" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function CategoryTag({ category }: { category?: string }) {
|
|
const labels: Record<string, string> = {
|
|
bug: "Bug",
|
|
praise: "Praise",
|
|
"needs-work": "Needs work",
|
|
};
|
|
return (
|
|
<span className="tag">
|
|
<CategoryIcon category={category} />
|
|
{category ? (labels[category] ?? category) : "Uncategorized"}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
function CategoryIcon({ category }: { category?: string }) {
|
|
if (category === "bug") return <BugIcon />;
|
|
if (category === "praise") return <ThumbsUpIcon />;
|
|
if (category === "needs-work") return <WrenchIcon />;
|
|
return <FeedbackIcon />;
|
|
}
|
|
|
|
function BugIcon() {
|
|
return (
|
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
|
<path d="M12 20v-9" />
|
|
<path d="M14 7a4 4 0 0 1 4 4v3a6 6 0 0 1-12 0v-3a4 4 0 0 1 4-4z" />
|
|
<path d="M14.12 3.88 16 2M21 21a4 4 0 0 0-3.81-4M21 5a4 4 0 0 1-3.55 3.97M22 13h-4M3 21a4 4 0 0 1 3.81-4M3 5a4 4 0 0 0 3.55 3.97M6 13H2M8 2l1.88 1.88M9 7.13V6a3 3 0 1 1 6 0v1.13" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function ThumbsUpIcon() {
|
|
return (
|
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
|
<path d="M15 5.88 14 10h5.83a2 2 0 0 1 1.92 2.56l-2.33 8A2 2 0 0 1 17.5 22H4a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2h2.76a2 2 0 0 0 1.79-1.11L12 2a3.13 3.13 0 0 1 3 3.88Z" />
|
|
<path d="M7 10v12" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function WrenchIcon() {
|
|
return (
|
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
|
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.106-3.105c.32-.322.863-.22.983.218a6 6 0 0 1-8.259 7.057l-7.91 7.91a1 1 0 0 1-2.999-3l7.91-7.91a6 6 0 0 1 7.057-8.259c.438.12.54.662.219.984z" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function SearchIcon() {
|
|
return (
|
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
|
<circle cx="11" cy="11" r="6.5" />
|
|
<path d="m16 16 4 4" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function FileIcon() {
|
|
return (
|
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
|
<path d="M6 3h8l4 4v14H6V3Z" />
|
|
<path d="M14 3v5h4M9 13h6M9 17h4" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function ArrowIcon() {
|
|
return (
|
|
<svg className="arrow-icon" viewBox="0 0 24 24" aria-hidden="true">
|
|
<path d="m9 18 6-6-6-6" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
/// Shown in nip98 mode when no NIP-07 extension is available. Instructs the
|
|
/// operator to install nos2x or Alby before continuing.
|
|
function Nip07Screen() {
|
|
return (
|
|
<div className="app">
|
|
<div className="state auth-prompt">
|
|
<h2>Nostr extension required</h2>
|
|
<p>
|
|
This relay uses NIP-98 HTTP Auth. Install a NIP-07 browser extension
|
|
such as{" "}
|
|
<a
|
|
href="https://github.com/fiatjaf/nos2x"
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
>
|
|
nos2x
|
|
</a>{" "}
|
|
or{" "}
|
|
<a href="https://getalby.com" target="_blank" rel="noreferrer">
|
|
Alby
|
|
</a>
|
|
, then reload this page. Your Nostr key will be used to sign each
|
|
request.
|
|
</p>
|
|
<button type="button" onClick={() => location.reload()}>
|
|
Reload
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function App() {
|
|
const { path } = usePath();
|
|
|
|
// Probe the relay once to discover the auth mode. `null` means the probe is
|
|
// still in flight. Once resolved, the mode is stable for the session.
|
|
const [authMode, setAuthMode] = useState<AuthMode | null>(null);
|
|
useEffect(() => {
|
|
let active = true;
|
|
probeAuthMode().then((mode) => {
|
|
if (active) setAuthMode(mode);
|
|
});
|
|
return () => {
|
|
active = false;
|
|
};
|
|
}, []);
|
|
|
|
const report = path.match(/^\/reports\/([^/]+)$/);
|
|
const feedback = path.match(/^\/feedback\/([^/]+)$/);
|
|
|
|
// Probe still in flight — render nothing to avoid a visible flash.
|
|
if (authMode === null) return null;
|
|
|
|
// NIP-98 mode: require a NIP-07 extension.
|
|
if (authMode === "nip98" && !(window as Window & { nostr?: unknown }).nostr)
|
|
return <Nip07Screen />;
|
|
|
|
const content = report ? (
|
|
<ReportDetail id={report[1]} authMode={authMode} />
|
|
) : feedback ? (
|
|
<FeedbackDetailView id={feedback[1]} authMode={authMode} />
|
|
) : path === "/feedback" ? (
|
|
<FeedbackList authMode={authMode} />
|
|
) : (
|
|
<Reports authMode={authMode} />
|
|
);
|
|
return (
|
|
<div className="app">
|
|
<header className="app-header">
|
|
<Link href="/reports" className="brand">
|
|
<span className="brand-mark">
|
|
<BuzzMark />
|
|
</span>
|
|
<span>
|
|
Buzz <b>Admin</b>
|
|
</span>
|
|
</Link>
|
|
<nav>
|
|
<Link href="/reports" className="nav-link" activeWhenNested>
|
|
<ReportIcon /> Reports
|
|
</Link>
|
|
<Link href="/feedback" className="nav-link" activeWhenNested>
|
|
<FeedbackIcon /> Feedback
|
|
</Link>
|
|
</nav>
|
|
</header>
|
|
<main>{content}</main>
|
|
</div>
|
|
);
|
|
}
|