From d3711ff52cfd39b86c4d2f88b086d4463a5a3c73 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Jun 2025 16:34:09 +0100 Subject: [PATCH] Implement Admin Reporting UI with backend and frontend support (#21) * Initial plan for issue * Implement Admin Reporting UI with backend and frontend Co-authored-by: v0l <1172179+v0l@users.noreply.github.com> * Implement reviewed flag for reports instead of deletion and revert upload.tsx changes Co-authored-by: v0l <1172179+v0l@users.noreply.github.com> * Remove legacy files migration logic from upload UI Co-authored-by: v0l <1172179+v0l@users.noreply.github.com> * Delete ui_src/package-lock.json * Delete ui_src/yarn.lock * Restore yarn.lock file to original state Co-authored-by: v0l <1172179+v0l@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: v0l <1172179+v0l@users.noreply.github.com> Co-authored-by: Kieran --- .../20250610140000_add_reviewed_flag.sql | 5 + src/db.rs | 16 +- src/routes/admin.rs | 25 +++- ui_src/src/upload/admin.ts | 35 +++++ ui_src/src/views/reports.tsx | 141 ++++++++++++++++++ ui_src/src/views/upload.tsx | 41 ----- ui_src/tsconfig.app.tsbuildinfo | 2 +- 7 files changed, 219 insertions(+), 46 deletions(-) create mode 100644 migrations/20250610140000_add_reviewed_flag.sql create mode 100644 ui_src/src/views/reports.tsx diff --git a/migrations/20250610140000_add_reviewed_flag.sql b/migrations/20250610140000_add_reviewed_flag.sql new file mode 100644 index 0000000..c12acaa --- /dev/null +++ b/migrations/20250610140000_add_reviewed_flag.sql @@ -0,0 +1,5 @@ +-- Add reviewed flag to reports table +alter table reports add column reviewed boolean not null default false; + +-- Index for efficient filtering of non-reviewed reports +create index ix_reports_reviewed on reports (reviewed); \ No newline at end of file diff --git a/src/db.rs b/src/db.rs index 342ce1d..367ad17 100644 --- a/src/db.rs +++ b/src/db.rs @@ -116,6 +116,7 @@ pub struct Report { pub reporter_id: u64, pub event_json: String, pub created: DateTime, + pub reviewed: bool, } #[derive(Clone)] @@ -394,14 +395,14 @@ impl Database { /// List reports with pagination for admin view pub async fn list_reports(&self, offset: u32, limit: u32) -> Result<(Vec, i64), Error> { let reports: Vec = sqlx::query_as( - "select id, file_id, reporter_id, event_json, created from reports order by created desc limit ? offset ?" + "select id, file_id, reporter_id, event_json, created, reviewed from reports where reviewed = false order by created desc limit ? offset ?" ) .bind(limit) .bind(offset) .fetch_all(&self.pool) .await?; - let count: i64 = sqlx::query("select count(id) from reports") + let count: i64 = sqlx::query("select count(id) from reports where reviewed = false") .fetch_one(&self.pool) .await? .try_get(0)?; @@ -412,10 +413,19 @@ impl Database { /// Get reports for a specific file pub async fn get_file_reports(&self, file_id: &[u8]) -> Result, Error> { sqlx::query_as( - "select id, file_id, reporter_id, event_json, created from reports where file_id = ? order by created desc" + "select id, file_id, reporter_id, event_json, created, reviewed from reports where file_id = ? order by created desc" ) .bind(file_id) .fetch_all(&self.pool) .await } + + /// Mark a report as reviewed (used for acknowledging) + pub async fn mark_report_reviewed(&self, report_id: u64) -> Result<(), Error> { + sqlx::query("update reports set reviewed = true where id = ?") + .bind(report_id) + .execute(&self.pool) + .await?; + Ok(()) + } } diff --git a/src/routes/admin.rs b/src/routes/admin.rs index 18e91d9..e80b465 100644 --- a/src/routes/admin.rs +++ b/src/routes/admin.rs @@ -8,7 +8,7 @@ use rocket::{routes, Responder, Route, State}; use sqlx::{Error, QueryBuilder, Row}; pub fn admin_routes() -> Vec { - routes![admin_list_files, admin_get_self, admin_list_reports] + routes![admin_list_files, admin_get_self, admin_list_reports, admin_acknowledge_report] } #[derive(Serialize, Default)] @@ -191,6 +191,29 @@ async fn admin_list_reports( } } +#[rocket::delete("/reports/")] +async fn admin_acknowledge_report( + auth: Nip98Auth, + report_id: u64, + db: &State, +) -> AdminResponse<()> { + let pubkey_vec = auth.event.pubkey.to_bytes().to_vec(); + + let user = match db.get_user(&pubkey_vec).await { + Ok(user) => user, + Err(_) => return AdminResponse::error("User not found"), + }; + + if !user.is_admin { + return AdminResponse::error("User is not an admin"); + } + + match db.mark_report_reviewed(report_id).await { + Ok(()) => AdminResponse::success(()), + Err(e) => AdminResponse::error(&format!("Could not acknowledge report: {}", e)), + } +} + impl Database { pub async fn list_all_files( &self, diff --git a/ui_src/src/upload/admin.ts b/ui_src/src/upload/admin.ts index 53009cf..5040355 100644 --- a/ui_src/src/upload/admin.ts +++ b/ui_src/src/upload/admin.ts @@ -12,6 +12,15 @@ export interface AdminSelf { total_available_quota?: number; } +export interface Report { + id: number; + file_id: string; + reporter_id: number; + event_json: string; + created: string; + reviewed: boolean; +} + export class Route96 { constructor( readonly url: string, @@ -39,6 +48,25 @@ export class Route96 { }; } + async listReports(page = 0, count = 10) { + const rsp = await this.#req( + `admin/reports?page=${page}&count=${count}`, + "GET", + ); + const data = await this.#handleResponse(rsp); + return { + ...data, + ...data.data, + files: data.data.files, + }; + } + + async acknowledgeReport(reportId: number) { + const rsp = await this.#req(`admin/reports/${reportId}`, "DELETE"); + const data = await this.#handleResponse>(rsp); + return data; + } + async #handleResponse(rsp: Response) { if (rsp.ok) { return (await rsp.json()) as T; @@ -94,3 +122,10 @@ export type AdminResponseFileList = AdminResponse<{ count: number; files: Array; }>; + +export type AdminResponseReportList = AdminResponse<{ + total: number; + page: number; + count: number; + files: Array; +}>; diff --git a/ui_src/src/views/reports.tsx b/ui_src/src/views/reports.tsx new file mode 100644 index 0000000..22ee65c --- /dev/null +++ b/ui_src/src/views/reports.tsx @@ -0,0 +1,141 @@ +import { NostrLink } from "@snort/system"; +import classNames from "classnames"; +import Profile from "../components/profile"; +import { Report } from "../upload/admin"; + +export default function ReportList({ + reports, + pages, + page, + onPage, + onAcknowledge, + onDeleteFile, +}: { + reports: Array; + pages?: number; + page?: number; + onPage?: (n: number) => void; + onAcknowledge?: (reportId: number) => void; + onDeleteFile?: (fileId: string) => void; +}) { + if (reports.length === 0) { + return No Reports; + } + + function pageButtons(page: number, n: number) { + const ret = []; + const start = 0; + + for (let x = start; x < n; x++) { + ret.push( +
onPage?.(x)} + className={classNames( + "bg-neutral-700 hover:bg-neutral-600 min-w-8 text-center cursor-pointer font-bold", + { + "rounded-l-md": x === start, + "rounded-r-md": x + 1 === n, + "bg-neutral-400": page === x, + }, + )} + > + {x + 1} +
, + ); + } + + return ret; + } + + function getReporterPubkey(eventJson: string): string | null { + try { + const event = JSON.parse(eventJson); + return event.pubkey; + } catch { + return null; + } + } + + function getReportReason(eventJson: string): string { + try { + const event = JSON.parse(eventJson); + return event.content || "No reason provided"; + } catch { + return "Invalid event data"; + } + } + + function formatDate(dateString: string): string { + return new Date(dateString).toLocaleString(); + } + + return ( + <> + + + + + + + + + + + + + {reports.map((report) => { + const reporterPubkey = getReporterPubkey(report.event_json); + const reason = getReportReason(report.event_json); + + return ( + + + + + + + + + ); + })} + +
Report IDFile IDReporterReasonCreatedActions
{report.id} + {report.file_id.substring(0, 12)}... + + {reporterPubkey ? ( + + ) : ( + "Unknown" + )} + + {reason} + + {formatDate(report.created)} + +
+ + +
+
+ + {pages !== undefined && ( + <> +
+
{pageButtons(page ?? 0, pages)}
+
+ + )} + + ); +} \ No newline at end of file diff --git a/ui_src/src/views/upload.tsx b/ui_src/src/views/upload.tsx index b95c5ca..c874cbd 100644 --- a/ui_src/src/views/upload.tsx +++ b/ui_src/src/views/upload.tsx @@ -8,16 +8,13 @@ import usePublisher from "../hooks/publisher"; import { Nip96, Nip96FileList } from "../upload/nip96"; import { AdminSelf, Route96 } from "../upload/admin"; import { FormatBytes } from "../const"; -import Report from "../report.json"; export default function Upload() { const [type, setType] = useState<"blossom" | "nip96">("blossom"); const [noCompress, setNoCompress] = useState(false); - const [showLegacy, setShowLegacy] = useState(false); const [toUpload, setToUpload] = useState(); const [self, setSelf] = useState(); const [error, setError] = useState(); - const [bulkPrgress, setBulkProgress] = useState(); const [results, setResults] = useState>([]); const [listedFiles, setListedFiles] = useState(); const [adminListedFiles, setAdminListedFiles] = useState(); @@ -28,9 +25,6 @@ export default function Upload() { const login = useLogin(); const pub = usePublisher(); - const legacyFiles = Report as Record>; - const myLegacyFiles = login ? (legacyFiles[login.pubkey] ?? []) : []; - const url = import.meta.env.VITE_API_URL || `${location.protocol}//${location.host}`; async function doUpload() { @@ -116,20 +110,6 @@ export default function Upload() { } } - async function migrateLegacy() { - if (!pub) return; - const uploader = new Blossom(url, pub); - let ctr = 0; - for (const f of myLegacyFiles) { - try { - await uploader.mirror(`https://void.cat/d/${f}`); - } catch (e) { - console.error(e); - } - setBulkProgress(ctr++ / myLegacyFiles.length); - } - } - useEffect(() => { listUploads(listedPage); }, [listedPage]); @@ -211,27 +191,6 @@ export default function Upload() { )} - {login && myLegacyFiles.length > 0 && ( -
- You have {myLegacyFiles.length.toLocaleString()} files which can be - migrated from void.cat -
- - -
- {bulkPrgress !== undefined && } -
- )} - {showLegacy && ( - ({ - id: f, - url: `https://void.cat/d/${f}`, - }))} - /> - )} {listedFiles && (