Skip to content

Commit 1ac95f8

Browse files
Copilotv0l
andcommitted
Implement Admin Reporting UI with backend and frontend
Co-authored-by: v0l <[email protected]>
1 parent 5dea1b0 commit 1ac95f8

File tree

6 files changed

+278
-5
lines changed

6 files changed

+278
-5
lines changed

src/db.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -418,4 +418,13 @@ impl Database {
418418
.fetch_all(&self.pool)
419419
.await
420420
}
421+
422+
/// Delete a report (used for acknowledging)
423+
pub async fn delete_report(&self, report_id: u64) -> Result<(), Error> {
424+
sqlx::query("delete from reports where id = ?")
425+
.bind(report_id)
426+
.execute(&self.pool)
427+
.await?;
428+
Ok(())
429+
}
421430
}

src/routes/admin.rs

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ use rocket::{routes, Responder, Route, State};
88
use sqlx::{Error, QueryBuilder, Row};
99

1010
pub fn admin_routes() -> Vec<Route> {
11-
routes![admin_list_files, admin_get_self, admin_list_reports]
11+
routes![admin_list_files, admin_get_self, admin_list_reports, admin_acknowledge_report]
1212
}
1313

1414
#[derive(Serialize, Default)]
@@ -191,6 +191,29 @@ async fn admin_list_reports(
191191
}
192192
}
193193

194+
#[rocket::delete("/reports/<report_id>")]
195+
async fn admin_acknowledge_report(
196+
auth: Nip98Auth,
197+
report_id: u64,
198+
db: &State<Database>,
199+
) -> AdminResponse<()> {
200+
let pubkey_vec = auth.event.pubkey.to_bytes().to_vec();
201+
202+
let user = match db.get_user(&pubkey_vec).await {
203+
Ok(user) => user,
204+
Err(_) => return AdminResponse::error("User not found"),
205+
};
206+
207+
if !user.is_admin {
208+
return AdminResponse::error("User is not an admin");
209+
}
210+
211+
match db.delete_report(report_id).await {
212+
Ok(()) => AdminResponse::success(()),
213+
Err(e) => AdminResponse::error(&format!("Could not acknowledge report: {}", e)),
214+
}
215+
}
216+
194217
impl Database {
195218
pub async fn list_all_files(
196219
&self,

ui_src/src/upload/admin.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,14 @@ export interface AdminSelf {
1212
total_available_quota?: number;
1313
}
1414

15+
export interface Report {
16+
id: number;
17+
file_id: string;
18+
reporter_id: number;
19+
event_json: string;
20+
created: string;
21+
}
22+
1523
export class Route96 {
1624
constructor(
1725
readonly url: string,
@@ -39,6 +47,25 @@ export class Route96 {
3947
};
4048
}
4149

50+
async listReports(page = 0, count = 10) {
51+
const rsp = await this.#req(
52+
`admin/reports?page=${page}&count=${count}`,
53+
"GET",
54+
);
55+
const data = await this.#handleResponse<AdminResponseReportList>(rsp);
56+
return {
57+
...data,
58+
...data.data,
59+
files: data.data.files,
60+
};
61+
}
62+
63+
async acknowledgeReport(reportId: number) {
64+
const rsp = await this.#req(`admin/reports/${reportId}`, "DELETE");
65+
const data = await this.#handleResponse<AdminResponse<void>>(rsp);
66+
return data;
67+
}
68+
4269
async #handleResponse<T extends AdminResponseBase>(rsp: Response) {
4370
if (rsp.ok) {
4471
return (await rsp.json()) as T;
@@ -94,3 +121,10 @@ export type AdminResponseFileList = AdminResponse<{
94121
count: number;
95122
files: Array<NostrEvent>;
96123
}>;
124+
125+
export type AdminResponseReportList = AdminResponse<{
126+
total: number;
127+
page: number;
128+
count: number;
129+
files: Array<Report>;
130+
}>;

ui_src/src/views/reports.tsx

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
import { NostrLink } from "@snort/system";
2+
import classNames from "classnames";
3+
import Profile from "../components/profile";
4+
import { Report } from "../upload/admin";
5+
6+
export default function ReportList({
7+
reports,
8+
pages,
9+
page,
10+
onPage,
11+
onAcknowledge,
12+
onDeleteFile,
13+
}: {
14+
reports: Array<Report>;
15+
pages?: number;
16+
page?: number;
17+
onPage?: (n: number) => void;
18+
onAcknowledge?: (reportId: number) => void;
19+
onDeleteFile?: (fileId: string) => void;
20+
}) {
21+
if (reports.length === 0) {
22+
return <b>No Reports</b>;
23+
}
24+
25+
function pageButtons(page: number, n: number) {
26+
const ret = [];
27+
const start = 0;
28+
29+
for (let x = start; x < n; x++) {
30+
ret.push(
31+
<div
32+
key={x}
33+
onClick={() => onPage?.(x)}
34+
className={classNames(
35+
"bg-neutral-700 hover:bg-neutral-600 min-w-8 text-center cursor-pointer font-bold",
36+
{
37+
"rounded-l-md": x === start,
38+
"rounded-r-md": x + 1 === n,
39+
"bg-neutral-400": page === x,
40+
},
41+
)}
42+
>
43+
{x + 1}
44+
</div>,
45+
);
46+
}
47+
48+
return ret;
49+
}
50+
51+
function getReporterPubkey(eventJson: string): string | null {
52+
try {
53+
const event = JSON.parse(eventJson);
54+
return event.pubkey;
55+
} catch {
56+
return null;
57+
}
58+
}
59+
60+
function getReportReason(eventJson: string): string {
61+
try {
62+
const event = JSON.parse(eventJson);
63+
return event.content || "No reason provided";
64+
} catch {
65+
return "Invalid event data";
66+
}
67+
}
68+
69+
function formatDate(dateString: string): string {
70+
return new Date(dateString).toLocaleString();
71+
}
72+
73+
return (
74+
<>
75+
<table className="w-full border-collapse border border-neutral-500">
76+
<thead>
77+
<tr className="bg-neutral-700">
78+
<th className="border border-neutral-500 py-2 px-4 text-left">Report ID</th>
79+
<th className="border border-neutral-500 py-2 px-4 text-left">File ID</th>
80+
<th className="border border-neutral-500 py-2 px-4 text-left">Reporter</th>
81+
<th className="border border-neutral-500 py-2 px-4 text-left">Reason</th>
82+
<th className="border border-neutral-500 py-2 px-4 text-left">Created</th>
83+
<th className="border border-neutral-500 py-2 px-4 text-left">Actions</th>
84+
</tr>
85+
</thead>
86+
<tbody>
87+
{reports.map((report) => {
88+
const reporterPubkey = getReporterPubkey(report.event_json);
89+
const reason = getReportReason(report.event_json);
90+
91+
return (
92+
<tr key={report.id} className="hover:bg-neutral-700">
93+
<td className="border border-neutral-500 py-2 px-4">{report.id}</td>
94+
<td className="border border-neutral-500 py-2 px-4 font-mono text-sm">
95+
{report.file_id.substring(0, 12)}...
96+
</td>
97+
<td className="border border-neutral-500 py-2 px-4">
98+
{reporterPubkey ? (
99+
<Profile link={NostrLink.publicKey(reporterPubkey)} size={20} />
100+
) : (
101+
"Unknown"
102+
)}
103+
</td>
104+
<td className="border border-neutral-500 py-2 px-4 max-w-xs truncate">
105+
{reason}
106+
</td>
107+
<td className="border border-neutral-500 py-2 px-4">
108+
{formatDate(report.created)}
109+
</td>
110+
<td className="border border-neutral-500 py-2 px-4">
111+
<div className="flex gap-2">
112+
<button
113+
onClick={() => onAcknowledge?.(report.id)}
114+
className="bg-blue-600 hover:bg-blue-700 px-2 py-1 rounded text-sm"
115+
>
116+
Acknowledge
117+
</button>
118+
<button
119+
onClick={() => onDeleteFile?.(report.file_id)}
120+
className="bg-red-600 hover:bg-red-700 px-2 py-1 rounded text-sm"
121+
>
122+
Delete File
123+
</button>
124+
</div>
125+
</td>
126+
</tr>
127+
);
128+
})}
129+
</tbody>
130+
</table>
131+
132+
{pages !== undefined && (
133+
<>
134+
<div className="flex justify-center mt-4">
135+
<div className="flex gap-1">{pageButtons(page ?? 0, pages)}</div>
136+
</div>
137+
</>
138+
)}
139+
</>
140+
);
141+
}

ui_src/src/views/upload.tsx

Lines changed: 69 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,10 @@ import { Blossom } from "../upload/blossom";
66
import useLogin from "../hooks/login";
77
import usePublisher from "../hooks/publisher";
88
import { Nip96, Nip96FileList } from "../upload/nip96";
9-
import { AdminSelf, Route96 } from "../upload/admin";
9+
import { AdminSelf, Route96, Report } from "../upload/admin";
1010
import { FormatBytes } from "../const";
11-
import Report from "../report.json";
11+
import ReportJson from "../report.json";
12+
import ReportList from "./reports";
1213

1314
export default function Upload() {
1415
const [type, setType] = useState<"blossom" | "nip96">("blossom");
@@ -21,14 +22,21 @@ export default function Upload() {
2122
const [results, setResults] = useState<Array<object>>([]);
2223
const [listedFiles, setListedFiles] = useState<Nip96FileList>();
2324
const [adminListedFiles, setAdminListedFiles] = useState<Nip96FileList>();
25+
const [adminListedReports, setAdminListedReports] = useState<{
26+
total: number;
27+
page: number;
28+
count: number;
29+
files: Array<Report>;
30+
}>();
2431
const [listedPage, setListedPage] = useState(0);
2532
const [adminListedPage, setAdminListedPage] = useState(0);
33+
const [adminReportsPage, setAdminReportsPage] = useState(0);
2634
const [mimeFilter, setMimeFilter] = useState<string>();
2735

2836
const login = useLogin();
2937
const pub = usePublisher();
3038

31-
const legacyFiles = Report as Record<string, Array<string>>;
39+
const legacyFiles = ReportJson as Record<string, Array<string>>;
3240
const myLegacyFiles = login ? (legacyFiles[login.pubkey] ?? []) : [];
3341

3442
const url =
@@ -99,6 +107,43 @@ export default function Upload() {
99107
}
100108
}
101109

110+
async function listReports(n: number) {
111+
if (!pub) return;
112+
try {
113+
setError(undefined);
114+
const uploader = new Route96(url, pub);
115+
const result = await uploader.listReports(n, 50);
116+
setAdminListedReports(result);
117+
} catch (e) {
118+
if (e instanceof Error) {
119+
setError(e.message.length > 0 ? e.message : "List reports failed");
120+
} else if (typeof e === "string") {
121+
setError(e);
122+
} else {
123+
setError("List reports failed");
124+
}
125+
}
126+
}
127+
128+
async function acknowledgeReport(reportId: number) {
129+
if (!pub) return;
130+
try {
131+
setError(undefined);
132+
const uploader = new Route96(url, pub);
133+
await uploader.acknowledgeReport(reportId);
134+
// Refresh the reports list
135+
await listReports(adminReportsPage);
136+
} catch (e) {
137+
if (e instanceof Error) {
138+
setError(e.message.length > 0 ? e.message : "Acknowledge failed");
139+
} else if (typeof e === "string") {
140+
setError(e);
141+
} else {
142+
setError("Acknowledge failed");
143+
}
144+
}
145+
}
146+
102147
async function deleteFile(id: string) {
103148
if (!pub) return;
104149
try {
@@ -138,6 +183,10 @@ export default function Upload() {
138183
listAllUploads(adminListedPage);
139184
}, [adminListedPage, mimeFilter]);
140185

186+
useEffect(() => {
187+
listReports(adminReportsPage);
188+
}, [adminReportsPage]);
189+
141190
useEffect(() => {
142191
if (pub && !self) {
143192
const r96 = new Route96(url, pub);
@@ -274,6 +323,23 @@ export default function Upload() {
274323
}}
275324
/>
276325
)}
326+
327+
<hr />
328+
<h3>Admin Reports:</h3>
329+
<Button onClick={() => listReports(0)}>List Reports</Button>
330+
{adminListedReports && (
331+
<ReportList
332+
reports={adminListedReports.files}
333+
pages={Math.ceil(adminListedReports.total / adminListedReports.count)}
334+
page={adminListedReports.page}
335+
onPage={(x) => setAdminReportsPage(x)}
336+
onAcknowledge={acknowledgeReport}
337+
onDeleteFile={async (fileId) => {
338+
await deleteFile(fileId);
339+
await listReports(adminReportsPage);
340+
}}
341+
/>
342+
)}
277343
</>
278344
)}
279345
{error && <b className="text-red-500">{error}</b>}

ui_src/tsconfig.app.tsbuildinfo

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
{"root":["./src/App.tsx","./src/const.ts","./src/login.ts","./src/main.tsx","./src/vite-env.d.ts","./src/components/button.tsx","./src/components/profile.tsx","./src/hooks/login.ts","./src/hooks/publisher.ts","./src/upload/admin.ts","./src/upload/blossom.ts","./src/upload/index.ts","./src/upload/nip96.ts","./src/views/files.tsx","./src/views/header.tsx","./src/views/upload.tsx"],"version":"5.6.2"}
1+
{"root":["./src/App.tsx","./src/const.ts","./src/login.ts","./src/main.tsx","./src/vite-env.d.ts","./src/components/button.tsx","./src/components/profile.tsx","./src/hooks/login.ts","./src/hooks/publisher.ts","./src/upload/admin.ts","./src/upload/blossom.ts","./src/upload/index.ts","./src/upload/nip96.ts","./src/views/files.tsx","./src/views/header.tsx","./src/views/reports.tsx","./src/views/upload.tsx"],"version":"5.6.2"}

0 commit comments

Comments
 (0)