mirror of
https://github.com/block/buzz.git
synced 2026-10-27 20:41:57 +00:00
Adds authenticated, role-based moderation to the relay admin API. On
`main` the admin API is read-only and gated only by `Host`/`Origin`
matching; this branch adds NIP-98 authentication, a two-tier
Operator/Moderator principal model, mutation and staffing endpoints, and
NIP-11 auto-discovery so clients never type the admin URL by hand.
## Authentication (`BUZZ_ADMIN_AUTH`)
`BUZZ_ADMIN_AUTH` accepts `nip98` or `disabled`. Leaving it unset
defaults to `nip98` (fail-secure). Configuration fails closed: any other
value aborts startup, while a lingering `BUZZ_ADMIN_TOKEN` is ignored
with a startup warning — token (bearer) authentication is not supported.
`Host`/`Origin` matching is retained in every mode as defense-in-depth.
- **`nip98`** (default) — per-request signed NIP-98 (kind 27235) events,
resolved to an Operator or Moderator principal with per-person
attribution and individual revocability. Read-write per resolved
principal.
- **`disabled`** — no credential; relies entirely on network-layer
controls (reverse proxy, VPN, firewall) and logs a `WARN` on every boot.
Always read-only: `authorize()` resolves no principal, so mutation and
staffing routes always `403`.
## Roles
Buzz has two independent authority axes after this change.
**Relay-level** roles (new here) are deployment-global: they act across
every community on the relay, through the admin API. **Community-level**
roles (pre-existing, unchanged by this PR) are tenant-scoped: they act
inside one community, through signed Nostr moderation commands.
### Relay level (new)
| Role | Description |
|---|---|
| **Operator** | Full control of the deployment's moderation surface:
read all reports, feedback, and attachments across every community;
resolve reports with enforcement (`delete`/`kick`/`ban`/`timeout`) or
decisions (`dismiss`/`escalate`); reopen and cancel; update feedback
status; and manage the Operator/Moderator roster via the staffing
endpoints. |
| **Moderator** | Day-to-day triage: everything an Operator can do
except staffing — cannot view or change the roster. |
How a pubkey acquires a relay role (resolution order; config always
outranks DB):
1. Listed in `RELAY_OPERATOR_PUBKEYS` → **Operator** (source `config`)
2. Equals `RELAY_OWNER_PUBKEY` while `RELAY_OPERATOR_PUBKEYS` is empty →
**Operator** (source `owner_fallback`, a break-glass grant for
self-hosters that deactivates once any operator is configured)
3. Row in the `relay_operators` table → **Operator** or **Moderator**
(source `db`, managed via the staffing endpoints)
4. No match → `403`
### Community level (pre-existing, unchanged)
| Role | Description |
|---|---|
| **Owner** (community) | Full authority within their community: every
moderation action (delete, kick, ban/unban, timeout/untimeout, resolve
reports, view queue) plus member, role, and invite management. No guard
rails. |
| **Admin** (community) | Same community-wide moderation capabilities as
owner, except an admin cannot ban or time out the owner or a fellow
admin — only the owner may action an admin. Manages members and invites;
only the owner grants the admin role. |
| **Member** (community) | Standard participant; no moderation
capability. |
| **Owner / Admin** (channel) | Channel-local authority only: delete
messages and kick users within their own channel. |
| **Member / Guest / Bot** (channel) | No moderation authority. |
There is no community-level Moderator tier in v1; relay-level Moderator
is the only role by that name.
## Escalation scoping
The operator report queue is an escalation backstop, not the community's
day-to-day triage surface (per `VISION_MODERATION`, the severe class is
the platform's to review rather than the community's). Two rules enforce
that:
- **Escalated-by-default listing.** `GET /reports` with no `status`
parameter returns only `escalated` reports. An explicit
`status=<open|resolved|dismissed|escalated>` filter is always honored as
given, and full visibility across every status stays available for
platform-safety and legal review via `scope=all` (which lists reports
regardless of status). `scope` accepts only `all` and is ignored when an
explicit `status` is present.
- **Auto-escalated `illegal` reports.** Member reports whose category is
`illegal` are ingested with `status=escalated` rather than `open`, so
the severe class reaches the operator backstop without waiting for a
community admin to forward it. Every other category still lands `open`.
Auto-escalation only sets the queue status — it records no moderator
decision and stamps no resolver, so an auto-escalated report is
indistinguishable downstream from an admin-escalated one: the reopen
route returns it to `open` on the same terms, keyed only on status,
never on how the report became escalated.
## Principal resolution and NIP-98 admission
`resolve_admin_principal()` returns `AdminPrincipal { pubkey, role,
source }` per the resolution order above; `None` never falls through as
a role. Admission is ordered so the replay guard is a privilege, not a
public surface: signature/URL/method/payload-hash verification first,
roster check second, and only then is the deployment-scoped replay id
atomically consumed — a validly-signing but unrostered key never
allocates a replay slot. Redis failure fails closed.
## Report resolution, recovery, and enforcement provenance
`POST /reports/{id}/resolve` is a crash-safe enforcement state machine:
decision-only outcomes (`dismiss`/`escalate`) are a single
CAS-plus-audit transaction; enforcement
(`delete`/`kick`/`ban`/`timeout`) claims the report
(`open`→`processing`), runs the durable mutation, then finalizes — a
re-drive resumes at the step marker and converges to exactly-one
enforcement, fenced by a lease and an outbox claim token.
Person-directed enforcement on an `event`-kind report derives its target
from the stored event's author (server-owned truth, never the reporter's
`p` tag) via a single `derive_enforcement_target` shared by the HTTP
driver and the recovery worker. If the reported event was purged before
its author could be read, person-directed actions are rejected pre-claim
and the report stays `open`; `delete` needs only the event id and is
exempt.
`GET /reports/{id}` and the resolve response carry an `activeAction`
field surfacing the enforcement that actually executed — a report
dismissed after a reopen still reports the ban that ran. `POST
/reports/{id}/reopen` returns a terminal report to `open` (idempotent on
`requestId`). `POST /reports/{id}/cancel` is the sole recovery path for
a pre-mutation `failed` action, attributed via
`relay_admin_actions.cancelled_by`.
## Feedback
`GET /feedback` and `/feedback/{id}` survive a tenant purge: provenance
columns are severed to `NULL` rather than cascade-deleted, and the
attachment path fails closed to `404` on a severed row. `PATCH
/feedback/{id}` updates lifecycle `status`
(`new`/`reviewed`/`archived`).
## Staffing and probe
`GET/PUT/DELETE /operators/{pubkey}` are Operator-only; mutating a
config-backed pubkey returns `409 Conflict`. `GET /operators` returns
the union of config and DB principals with per-entry `source`. `GET
/probe` reports auth mode, role, source, `canAct`, and `canStaff` for
the desktop console.
## NIP-11 auto-discovery
The NIP-11 relay-information document gains an optional `admin_api`
field carrying the canonical admin origin (`scheme://host[:port]`, no
path), present iff `BUZZ_ADMIN_HOST` is set and omitted otherwise. The
scheme follows the same loopback rule as NIP-98 `u`-tag verification via
a shared `scheme_for_host` helper, so the advertised origin and the
origin the relay verifies against can never diverge.
## Operator API origin decoupling
`RELAY_OPERATOR_API_ORIGIN` is no longer required at boot when
`RELAY_OPERATOR_PUBKEYS` is set — it is used only by the
community-provisioning endpoints, which fail closed at request time
(with a boot-time `WARN`) until it is set. The admin console needs no
origin.
## Admin-web adaptation
The standalone `admin-web` dashboard signs each request as a NIP-98
event via a NIP-07 browser extension, discovers the auth mode with a
single unauthenticated probe (`200` → `disabled`, anything else →
`nip98`, fail-secure), and carries no token entry surface. Playwright
coverage exercises the NIP-98 and CSP paths.
## Security hardening
Three findings from security review are folded in:
- **Append-only roster audit.** `PUT`/`DELETE /operators/{pubkey}`
mutate the deployment-wide root of trust, but the upsert overwrites
`role`/`added_by` in place and the delete removes the only row — so a
grant→revoke sequence left no trace of who was ever granted or by whom.
Each mutation now writes an `relay_operator_audit` row (actor, target,
`grant`/`revoke`, pre-image `prev_role`, `new_role`, timestamp) inside
the same transaction as the mutation. A per-target transaction-scoped
advisory lock serializes concurrent mutations of the same pubkey before
the pre-image read, so the recorded `prev_role` is always the true
predecessor even under a concurrent-grant race. Chronology is keyed on a
`BIGINT GENERATED ALWAYS AS IDENTITY` `seq` column, not the wall clock:
the serializing lock guarantees insertion order and `seq` captures it,
so ordered reads (`ORDER BY seq`) follow the true privilege chain even
across a backward NTP step that a `clock_timestamp()` ordering would
invert. `created_at` (`clock_timestamp()`) is retained as informational
occurrence time only. Append-only by construction — no `UPDATE`/`DELETE`
path and no API surface. A no-op delete writes nothing.
- **`expirationSecs` overflow.** The timeout path built `Utc::now() +
Duration::seconds(secs as i64)` from an attacker-controlled `u64`:
`i64::MAX` panicked the handler, and a wrapped-negative magnitude minted
a *past* expiry that still passed validation. `compute_timeout_until`
now rejects zero, rejects magnitudes above a documented
`MAX_TIMEOUT_SECS` (365 days), and uses checked
`try_seconds`/`checked_add_signed` so no input can panic or produce a
past expiry — over-cap, zero, `i64::MAX`, and wrapping-negative inputs
all return a clean `4xx`.
- **Uppercase-hex config-backed bypass.** Config pubkeys are lowercased
at parse, but the `409` immutability check raw-string-compared the path
param while `decode_hex_pubkey` accepted uppercase — so `PUT
/operators/{UPPERCASE}` skipped the guard and wrote a shadow row for the
same 32 bytes. The validated param is now canonicalized (lowercased)
before the `409` check, DB write, `DELETE`, and response body.
## Migrations
- `0035_relay_operators.sql` — `relay_operators` roster table
(deployment-global), `actor_authority` on `moderation_actions`,
`processing` status plus `active_action_id` on `moderation_reports`,
`status` on `product_feedback`.
- `0036_relay_admin_actions.sql` — enforcement-action table with a
`request_id` idempotency key, a `step_marker` for crash recovery, and a
`cancelled_by` attribution column.
- `0037_relay_admin_action_lease.sql` — lease fencing for the action
worker.
- `0038_relay_admin_outbox_claim_token.sql` — fenced claim token on the
outbox worker.
- `0039_relay_operator_audit.sql` — append-only `relay_operator_audit`
trail for roster mutations (see Security hardening).
`docs/admin/README.md` documents the full principal model, NIP-98 event
requirements, capabilities by role, the startup error matrix, and the
discovery field.
## Production blast radius
A relay without `BUZZ_ADMIN_HOST` is completely unaffected — the admin
surface stays disabled and `BUZZ_ADMIN_AUTH` is ignored; a lingering
`BUZZ_ADMIN_TOKEN` logs a startup warning and must be removed. Where
`BUZZ_ADMIN_HOST` **is** set, unset `BUZZ_ADMIN_AUTH` defaults to
`nip98` (per-person signed auth); `BUZZ_ADMIN_AUTH=disabled` reproduces
`main`'s prior `Host`/`Origin`-only gating but is read-only (mutation
routes `403`). The five migrations add tables and columns without
touching existing data.
---
Related: [block/buzz#4768](https://github.com/block/buzz/pull/4768)
(desktop admin console consuming the `admin_api` field),
[squareup/bb-public#339](https://github.com/squareup/bb-public/pull/339)
(Phase 4 rollout config)
---------
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
1028 lines
31 KiB
TypeScript
1028 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. It may have expired or
|
|
been removed from event storage.
|
|
</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>
|
|
);
|
|
}
|