feat(admin): show reported message content in report detail (#3149)

## Summary

- include the reported event's complete stored content, author, creation
time, and deletion state in the admin report detail response
- resolve the event through a community-scoped join so an event ID
collision cannot cross tenant boundaries
- render the message only on report detail, with an explicit unavailable
state when retention has removed it
- preserve the existing report list contract so message bodies are not
returned during queue browsing

## Security

- the existing admin host/origin authorization runs before the detail
database read; a route test pins that ordering
- the target event is selected using both `events.community_id =
moderation_reports.community_id` and `events.id =
moderation_reports.target_event_id`
- the client supplies only the report UUID; it cannot choose a community
or arbitrary event ID
- soft-deleted content is visible only through this restricted admin
detail route and is labeled deleted
- responses retain the admin API's `no-store`, CSP, `nosniff`, frame
denial, and referrer policy middleware

## Testing

- `pnpm -C admin-web check`
- `pnpm -C admin-web test:e2e` (10 passed)
- `cargo test -p buzz-db` (84 passed, 130 ignored)
- `cargo clippy -p buzz-db -p buzz-relay --all-targets -- -D warnings`
- focused admin authorization tests
- pre-push Rust and desktop/Tauri suites passed

The new Postgres integration test is ignored under the repository
convention and will run when explicitly enabled against migrated
Postgres. Local Postgres and Redis were unavailable, so the full
`buzz-relay --lib` run had 8 existing infrastructure-dependent failures
after 749 tests passed.

---------

Signed-off-by: npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7 <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz>
Signed-off-by: Kalvin Chau <kalvin@block.xyz>
Signed-off-by: npub122y0pqkertljmedu303rl0aqrj3w8pvu43t6jxm6875lzg6f2pwqegc3xc <5288f082d91aff2de5bc8be23fbfa01ca2e3859cac57a91b7a3fa9f12349505c@buzz.block.builderlab.xyz>
Co-authored-by: npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7 <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz>
Co-authored-by: npub122y0pqkertljmedu303rl0aqrj3w8pvu43t6jxm6875lzg6f2pwqegc3xc <5288f082d91aff2de5bc8be23fbfa01ca2e3859cac57a91b7a3fa9f12349505c@buzz.block.builderlab.xyz>
This commit is contained in:
Kalvin C
2026-07-27 12:49:21 -07:00
committed by GitHub
co-authored by npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7 npub122y0pqkertljmedu303rl0aqrj3w8pvu43t6jxm6875lzg6f2pwqegc3xc
parent 174c38e4bd
commit f069a85503
7 changed files with 439 additions and 8 deletions
+36 -2
View File
@@ -7,7 +7,12 @@ import {
useState,
} from "react";
import { ApiFailure, request } from "./api";
import type { FeedbackDetail, FeedbackSummary, Report } from "./types";
import type {
FeedbackDetail,
FeedbackSummary,
Report,
ReportDetail as ReportDetailData,
} from "./types";
import { useResource } from "./useResource";
function usePath() {
@@ -131,7 +136,10 @@ function Reports() {
}
function ReportDetail({ id }: { id: string }) {
const resource = useResource(() => request<Report>(`/reports/${id}`), id);
const resource = useResource(
() => request<ReportDetailData>(`/reports/${id}`),
id,
);
return (
<Page
eyebrow="Moderation"
@@ -164,6 +172,32 @@ function ReportDetail({ id }: { id: string }) {
<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."}
+32
View File
@@ -496,6 +496,38 @@ dd {
margin: 0;
}
.reported-message {
display: grid;
justify-items: start;
gap: 1rem;
border-left: 3px solid #d7d72e;
border-radius: 0 0.8rem 0.8rem 0;
background: #f6f6f1;
padding: 1rem;
}
.reported-message p,
.message-unavailable {
margin: 0;
overflow-wrap: anywhere;
line-height: 1.5;
white-space: pre-wrap;
}
.reported-message-meta {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 0.4rem 0.75rem;
width: 100%;
color: rgb(35 30 30 / 48%);
font-size: 0.78rem;
}
.message-unavailable {
color: rgb(35 30 30 / 60%);
font-style: italic;
}
.sensitive {
border-left: 3px solid #d7d72e;
border-radius: 0 0.8rem 0.8rem 0;
+11
View File
@@ -12,6 +12,17 @@ export interface Report {
createdAt: string;
}
export interface ReportedMessage {
authorPubkey: string;
content: string;
createdAt: string;
deletedAt: string | null;
}
export interface ReportDetail extends Report {
message: ReportedMessage | null;
}
export interface FeedbackSummary {
id: string;
communityId: string;
+66
View File
@@ -58,6 +58,72 @@ test("report rows render the relay response contract", async ({ page }) => {
await expect(page.getByText("Unknown date")).toHaveCount(0);
});
test("event report detail renders the reported message content", async ({
page,
}) => {
const id = "0e6caad8-1e18-4cd7-84fa-7264103f0a08";
await page.route(`**/api/admin/v1/reports/${id}`, (route) =>
route.fulfill({
contentType: "application/json",
body: JSON.stringify({
id,
communityId: "6d474feb-c50a-44e4-a0b5-f30532df49bc",
communityHost: "design.buzz.xyz",
reporterPubkey: "21".repeat(32),
targetKind: "event",
target: "12".repeat(32),
reportType: "spam",
status: "open",
createdAt: "2026-07-17T17:30:00Z",
message: {
authorPubkey: "31".repeat(32),
content:
"This is the complete reported message.\nIt preserves lines.",
createdAt: "2026-07-17T17:25:00Z",
deletedAt: null,
},
}),
}),
);
await page.goto(`/reports/${id}`);
await expect(
page.getByText("This is the complete reported message.", { exact: false }),
).toBeVisible();
await expect(page.getByText("31".repeat(32))).toBeVisible();
await expect(
page.getByText("Message content is unavailable", { exact: false }),
).toHaveCount(0);
});
test("event report detail explains when message content is unavailable", async ({
page,
}) => {
const id = "0e6caad8-1e18-4cd7-84fa-7264103f0a09";
await page.route(`**/api/admin/v1/reports/${id}`, (route) =>
route.fulfill({
contentType: "application/json",
body: JSON.stringify({
id,
communityId: "6d474feb-c50a-44e4-a0b5-f30532df49bc",
communityHost: "design.buzz.xyz",
reporterPubkey: "21".repeat(32),
targetKind: "event",
target: "12".repeat(32),
reportType: "spam",
status: "open",
createdAt: "2026-07-17T17:30:00Z",
message: null,
}),
}),
);
await page.goto(`/reports/${id}`);
await expect(
page.getByText("Message content is unavailable", { exact: false }),
).toBeVisible();
});
test("feedback cards open the complete submission", async ({ page }) => {
const id = "feed0000-0000-4000-8000-000000000001";
const fullBody = `${"Long feedback ".repeat(30)}end of feedback`;
+262 -4
View File
@@ -54,6 +54,31 @@ pub struct AdminReport {
pub created_at: DateTime<Utc>,
}
/// Reported message details available only on the admin report detail read.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AdminReportedMessage {
/// Message author public key.
pub author_pubkey: String,
/// Complete message content.
pub content: String,
/// Timestamp signed into the message event.
pub created_at: DateTime<Utc>,
/// Soft-deletion time, when the message has since been deleted.
pub deleted_at: Option<DateTime<Utc>>,
}
/// Deployment-global moderation report detail.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AdminReportDetail {
/// Report metadata.
#[serde(flatten)]
pub report: AdminReport,
/// Reported message when the report targets a stored event.
pub message: Option<AdminReportedMessage>,
}
/// Deployment-global product feedback with source-community provenance.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
@@ -128,24 +153,54 @@ pub async fn list_reports(
rows.into_iter().map(row_to_report).collect()
}
/// Fetch one report globally by its row id.
pub async fn get_report(pool: &PgPool, report_id: Uuid) -> Result<Option<AdminReport>> {
/// Fetch one report globally by its row id, including its event target content.
pub async fn get_report(pool: &PgPool, report_id: Uuid) -> Result<Option<AdminReportDetail>> {
let row = sqlx::query(
r#"
SELECT r.id, r.community_id, c.host AS community_host,
r.report_event_id, r.reporter_pubkey, r.target_kind,
r.target_event_id, r.target_pubkey, r.target_blob_sha256,
r.channel_id, r.report_type, r.note, r.status, r.resolved_by,
r.resolved_at, r.action_id, r.created_at
r.resolved_at, r.action_id, r.created_at,
target.pubkey AS message_author_pubkey,
target.content AS message_content,
target.created_at AS message_created_at,
target.deleted_at AS message_deleted_at
FROM moderation_reports r
JOIN communities c ON c.id = r.community_id
LEFT JOIN LATERAL (
SELECT e.pubkey, e.content, e.created_at, e.deleted_at
FROM events e
WHERE r.target_kind = 'event'
AND e.community_id = r.community_id
AND e.id = r.target_event_id
ORDER BY e.created_at DESC
LIMIT 1
) target ON TRUE
WHERE r.id = $1
"#,
)
.bind(report_id)
.fetch_optional(pool)
.await?;
row.map(row_to_report).transpose()
row.map(|row| {
let message = row
.try_get::<Option<Vec<u8>>, _>("message_author_pubkey")?
.map(|author_pubkey| -> Result<AdminReportedMessage> {
Ok(AdminReportedMessage {
author_pubkey: hex::encode(author_pubkey),
content: row.try_get("message_content")?,
created_at: row.try_get("message_created_at")?,
deleted_at: row.try_get("message_deleted_at")?,
})
})
.transpose()?;
Ok(AdminReportDetail {
report: row_to_report(row)?,
message,
})
})
.transpose()
}
fn row_to_report(row: sqlx::postgres::PgRow) -> Result<AdminReport> {
@@ -228,3 +283,206 @@ fn row_to_feedback(row: sqlx::postgres::PgRow) -> Result<AdminFeedback> {
received_at: row.try_get("received_at")?,
})
}
#[cfg(test)]
mod tests {
use super::*;
const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz";
async fn setup_pool() -> PgPool {
let database_url = std::env::var("BUZZ_TEST_DATABASE_URL")
.or_else(|_| std::env::var("DATABASE_URL"))
.unwrap_or_else(|_| TEST_DB_URL.to_owned());
PgPool::connect(&database_url)
.await
.expect("connect to test DB")
}
async fn insert_community(pool: &PgPool, label: &str) -> Uuid {
let id = Uuid::new_v4();
sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)")
.bind(id)
.bind(format!("admin-report-{label}-{}.example", id.simple()))
.execute(pool)
.await
.expect("insert community");
id
}
async fn insert_event(
pool: &PgPool,
community_id: Uuid,
event_id: &[u8],
author: &[u8],
content: &str,
deleted_at: Option<DateTime<Utc>>,
) {
sqlx::query(
r#"
INSERT INTO events (
community_id, id, pubkey, created_at, kind, tags, content, sig, deleted_at
) VALUES ($1, $2, $3, $4, 9, '[]'::jsonb, $5, $6, $7)
"#,
)
.bind(community_id)
.bind(event_id)
.bind(author)
.bind(Utc::now())
.bind(content)
.bind(vec![3_u8; 64])
.bind(deleted_at)
.execute(pool)
.await
.expect("insert event");
}
async fn insert_event_report(
pool: &PgPool,
community_id: Uuid,
target_event_id: &[u8],
) -> Uuid {
let id = Uuid::new_v4();
sqlx::query(
r#"
INSERT INTO moderation_reports (
community_id, id, report_event_id, reporter_pubkey,
target_kind, target_event_id, report_type
) VALUES ($1, $2, $3, $4, 'event', $5, 'spam')
"#,
)
.bind(community_id)
.bind(id)
.bind(Uuid::new_v4().as_bytes().repeat(2))
.bind(vec![4_u8; 32])
.bind(target_event_id)
.execute(pool)
.await
.expect("insert report");
id
}
async fn insert_pubkey_report(pool: &PgPool, community_id: Uuid) -> Uuid {
let id = Uuid::new_v4();
sqlx::query(
r#"
INSERT INTO moderation_reports (
community_id, id, report_event_id, reporter_pubkey,
target_kind, target_pubkey, report_type
) VALUES ($1, $2, $3, $4, 'pubkey', $5, 'spam')
"#,
)
.bind(community_id)
.bind(id)
.bind(Uuid::new_v4().as_bytes().repeat(2))
.bind(vec![4_u8; 32])
.bind(vec![7_u8; 32])
.execute(pool)
.await
.expect("insert report");
id
}
async fn delete_report_fixture(pool: &PgPool, community_id: Uuid) {
sqlx::query("DELETE FROM moderation_reports WHERE community_id = $1")
.bind(community_id)
.execute(pool)
.await
.expect("delete report fixture");
sqlx::query("DELETE FROM communities WHERE id = $1")
.bind(community_id)
.execute(pool)
.await
.expect("delete community fixture");
}
#[tokio::test]
#[ignore = "requires Postgres"]
async fn report_detail_reads_only_the_same_community_target_and_includes_deleted_content() {
let pool = setup_pool().await;
let report_community = insert_community(&pool, "reported").await;
let other_community = insert_community(&pool, "other").await;
let event_id = vec![1_u8; 32];
let deleted_at = Utc::now();
insert_event(
&pool,
report_community,
&event_id,
&[5_u8; 32],
"reported message",
Some(deleted_at),
)
.await;
insert_event(
&pool,
other_community,
&event_id,
&[6_u8; 32],
"wrong tenant message",
None,
)
.await;
let report_id = insert_event_report(&pool, report_community, &event_id).await;
let detail = get_report(&pool, report_id)
.await
.expect("query report")
.expect("report exists");
let message = detail.message.expect("reported message exists");
assert_eq!(message.content, "reported message");
assert_eq!(message.author_pubkey, hex::encode([5_u8; 32]));
assert!(message.deleted_at.is_some());
sqlx::query("DELETE FROM moderation_reports WHERE community_id = $1")
.bind(report_community)
.execute(&pool)
.await
.expect("delete report fixture");
sqlx::query("DELETE FROM events WHERE community_id = ANY($1)")
.bind(vec![report_community, other_community])
.execute(&pool)
.await
.expect("delete event fixtures");
sqlx::query("DELETE FROM communities WHERE id = ANY($1)")
.bind(vec![report_community, other_community])
.execute(&pool)
.await
.expect("delete community fixtures");
}
#[tokio::test]
#[ignore = "requires Postgres"]
async fn report_detail_has_no_message_for_non_event_target() {
let pool = setup_pool().await;
let community_id = insert_community(&pool, "pubkey-target").await;
let report_id = insert_pubkey_report(&pool, community_id).await;
let detail = get_report(&pool, report_id)
.await
.expect("query report")
.expect("report exists");
assert_eq!(detail.report.target_kind, "pubkey");
assert!(detail.message.is_none());
delete_report_fixture(&pool, community_id).await;
}
#[tokio::test]
#[ignore = "requires Postgres"]
async fn report_detail_has_no_message_when_event_row_is_missing() {
let pool = setup_pool().await;
let community_id = insert_community(&pool, "missing-event").await;
let missing_event_id = vec![8_u8; 32];
let report_id = insert_event_report(&pool, community_id, &missing_event_id).await;
let detail = get_report(&pool, report_id)
.await
.expect("query report")
.expect("report exists");
assert_eq!(detail.report.target_kind, "event");
assert_eq!(detail.report.target, hex::encode(missing_event_id));
assert!(detail.message.is_none());
delete_report_fixture(&pool, community_id).await;
}
}
+1 -1
View File
@@ -563,7 +563,7 @@ impl Db {
pub async fn admin_get_report(
&self,
id: Uuid,
) -> Result<Option<admin_moderation::AdminReport>> {
) -> Result<Option<admin_moderation::AdminReportDetail>> {
admin_moderation::get_report(&self.pool, id).await
}
+31 -1
View File
@@ -126,7 +126,7 @@ async fn report_detail(
State(state): State<Arc<crate::state::AppState>>,
headers: HeaderMap,
Path(id): Path<Uuid>,
) -> Result<Json<buzz_db::admin_moderation::AdminReport>, ApiError> {
) -> Result<Json<buzz_db::admin_moderation::AdminReportDetail>, ApiError> {
authorize(&state, &headers)?;
state
.db
@@ -374,6 +374,36 @@ mod tests {
const HASH: &str = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789";
#[tokio::test]
async fn report_detail_requires_admin_host_before_database_access() {
let response = router(test_state().await)
.oneshot(
Request::builder()
.uri(format!("/reports/{}", Uuid::nil()))
.header(header::HOST, "community.example")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), axum::http::StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn report_detail_rejects_unknown_report() {
let response = router(test_state().await)
.oneshot(
Request::builder()
.uri(format!("/reports/{}", Uuid::nil()))
.header(header::HOST, "admin.example")
.body(Body::empty())
.expect("request"),
)
.await
.expect("response");
assert_eq!(response.status(), axum::http::StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn feedback_attachment_requires_admin_host_before_database_access() {
let response = router(test_state().await)