Skip to content

Commit 176145e

Browse files
Copilotv0l
andcommitted
Add purge user functionality - backend and frontend implementation
Co-authored-by: v0l <[email protected]>
1 parent 6993374 commit 176145e

File tree

7 files changed

+2253
-4129
lines changed

7 files changed

+2253
-4129
lines changed

src/db.rs

Lines changed: 62 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,68 @@ impl Database {
316316

317317
Ok((results, count))
318318
}
319+
320+
pub async fn get_user_file_ids(&self, pubkey: &Vec<u8>) -> Result<Vec<Vec<u8>>, Error> {
321+
let results: Vec<(Vec<u8>,)> = sqlx::query_as(
322+
"select uploads.id from uploads, users, user_uploads \
323+
where users.pubkey = ? \
324+
and users.id = user_uploads.user_id \
325+
and user_uploads.file = uploads.id",
326+
)
327+
.bind(pubkey)
328+
.fetch_all(&self.pool)
329+
.await?;
330+
331+
Ok(results.into_iter().map(|(id,)| id).collect())
332+
}
333+
334+
/// Add a new report to the database
335+
pub async fn add_report(&self, file_id: &[u8], reporter_id: u64, event_json: &str) -> Result<(), Error> {
336+
sqlx::query("insert into reports (file_id, reporter_id, event_json) values (?, ?, ?)")
337+
.bind(file_id)
338+
.bind(reporter_id)
339+
.bind(event_json)
340+
.execute(&self.pool)
341+
.await?;
342+
Ok(())
343+
}
344+
345+
/// List reports with pagination for admin view
346+
pub async fn list_reports(&self, offset: u32, limit: u32) -> Result<(Vec<Report>, i64), Error> {
347+
let reports: Vec<Report> = sqlx::query_as(
348+
"select id, file_id, reporter_id, event_json, created, reviewed from reports where reviewed = false order by created desc limit ? offset ?"
349+
)
350+
.bind(limit)
351+
.bind(offset)
352+
.fetch_all(&self.pool)
353+
.await?;
354+
355+
let count: i64 = sqlx::query("select count(id) from reports where reviewed = false")
356+
.fetch_one(&self.pool)
357+
.await?
358+
.try_get(0)?;
359+
360+
Ok((reports, count))
361+
}
362+
363+
/// Get reports for a specific file
364+
pub async fn get_file_reports(&self, file_id: &[u8]) -> Result<Vec<Report>, Error> {
365+
sqlx::query_as(
366+
"select id, file_id, reporter_id, event_json, created, reviewed from reports where file_id = ? order by created desc"
367+
)
368+
.bind(file_id)
369+
.fetch_all(&self.pool)
370+
.await
371+
}
372+
373+
/// Mark a report as reviewed (used for acknowledging)
374+
pub async fn mark_report_reviewed(&self, report_id: u64) -> Result<(), Error> {
375+
sqlx::query("update reports set reviewed = true where id = ?")
376+
.bind(report_id)
377+
.execute(&self.pool)
378+
.await?;
379+
Ok(())
380+
}
319381
}
320382

321383
#[cfg(feature = "payments")]
@@ -433,52 +495,4 @@ impl Database {
433495
// Check if upload would exceed quota
434496
Ok(user_stats.total_size + upload_size <= available_quota)
435497
}
436-
437-
/// Add a new report to the database
438-
pub async fn add_report(&self, file_id: &[u8], reporter_id: u64, event_json: &str) -> Result<(), Error> {
439-
sqlx::query("insert into reports (file_id, reporter_id, event_json) values (?, ?, ?)")
440-
.bind(file_id)
441-
.bind(reporter_id)
442-
.bind(event_json)
443-
.execute(&self.pool)
444-
.await?;
445-
Ok(())
446-
}
447-
448-
/// List reports with pagination for admin view
449-
pub async fn list_reports(&self, offset: u32, limit: u32) -> Result<(Vec<Report>, i64), Error> {
450-
let reports: Vec<Report> = sqlx::query_as(
451-
"select id, file_id, reporter_id, event_json, created, reviewed from reports where reviewed = false order by created desc limit ? offset ?"
452-
)
453-
.bind(limit)
454-
.bind(offset)
455-
.fetch_all(&self.pool)
456-
.await?;
457-
458-
let count: i64 = sqlx::query("select count(id) from reports where reviewed = false")
459-
.fetch_one(&self.pool)
460-
.await?
461-
.try_get(0)?;
462-
463-
Ok((reports, count))
464-
}
465-
466-
/// Get reports for a specific file
467-
pub async fn get_file_reports(&self, file_id: &[u8]) -> Result<Vec<Report>, Error> {
468-
sqlx::query_as(
469-
"select id, file_id, reporter_id, event_json, created, reviewed from reports where file_id = ? order by created desc"
470-
)
471-
.bind(file_id)
472-
.fetch_all(&self.pool)
473-
.await
474-
}
475-
476-
/// Mark a report as reviewed (used for acknowledging)
477-
pub async fn mark_report_reviewed(&self, report_id: u64) -> Result<(), Error> {
478-
sqlx::query("update reports set reviewed = true where id = ?")
479-
.bind(report_id)
480-
.execute(&self.pool)
481-
.await?;
482-
Ok(())
483-
}
484498
}

src/routes/admin.rs

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
use crate::auth::nip98::Nip98Auth;
22
use crate::db::{Database, FileUpload, Report, User};
3+
use crate::filesystem::FileStore;
34
use crate::routes::{Nip94Event, PagedResult};
45
use crate::settings::Settings;
56
use rocket::serde::json::Json;
@@ -14,6 +15,7 @@ pub fn admin_routes() -> Vec<Route> {
1415
admin_list_reports,
1516
admin_acknowledge_report,
1617
admin_get_user_info,
18+
admin_purge_user,
1719
]
1820
}
1921

@@ -351,6 +353,72 @@ async fn admin_get_user_info(
351353
})
352354
}
353355

356+
#[rocket::delete("/user/<user_pubkey>/purge")]
357+
async fn admin_purge_user(
358+
auth: Nip98Auth,
359+
user_pubkey: &str,
360+
db: &State<Database>,
361+
fs: &State<crate::filesystem::FileStore>,
362+
) -> AdminResponse<()> {
363+
let pubkey_vec = auth.event.pubkey.to_bytes().to_vec();
364+
365+
// Check if the requesting user is an admin
366+
let admin_user = match db.get_user(&pubkey_vec).await {
367+
Ok(user) => user,
368+
Err(_) => return AdminResponse::error("User not found"),
369+
};
370+
371+
if !admin_user.is_admin {
372+
return AdminResponse::error("User is not an admin");
373+
}
374+
375+
// Parse target user pubkey
376+
let target_pubkey = match hex::decode(user_pubkey) {
377+
Ok(pk) => pk,
378+
Err(_) => return AdminResponse::error("Invalid pubkey format"),
379+
};
380+
381+
// Get all file IDs for the target user
382+
let file_ids = match db.get_user_file_ids(&target_pubkey).await {
383+
Ok(ids) => ids,
384+
Err(e) => return AdminResponse::error(&format!("Failed to get user files: {}", e)),
385+
};
386+
387+
let mut deleted_count = 0;
388+
let mut failed_count = 0;
389+
390+
// Delete each file
391+
for file_id in file_ids {
392+
// Delete file ownership records
393+
if let Err(e) = db.delete_all_file_owner(&file_id).await {
394+
log::warn!("Failed to delete file ownership for file {}: {}", hex::encode(&file_id), e);
395+
failed_count += 1;
396+
continue;
397+
}
398+
399+
// Delete file record from database
400+
if let Err(e) = db.delete_file(&file_id).await {
401+
log::warn!("Failed to delete file record for file {}: {}", hex::encode(&file_id), e);
402+
failed_count += 1;
403+
continue;
404+
}
405+
406+
// Delete physical file
407+
if let Err(e) = tokio::fs::remove_file(fs.get(&file_id)).await {
408+
log::warn!("Failed to delete physical file {}: {}", hex::encode(&file_id), e);
409+
// Don't increment failed_count here as the DB record is already deleted
410+
}
411+
412+
deleted_count += 1;
413+
}
414+
415+
if failed_count > 0 {
416+
AdminResponse::error(&format!("Partially completed: {} files deleted, {} failed", deleted_count, failed_count))
417+
} else {
418+
AdminResponse::success(())
419+
}
420+
}
421+
354422
impl Database {
355423
pub async fn list_all_files(
356424
&self,

ui_src/src/upload/admin.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,12 @@ export class Route96 {
115115
return data;
116116
}
117117

118+
async purgeUser(pubkey: string) {
119+
const rsp = await this.#req(`admin/user/${pubkey}/purge`, "DELETE");
120+
const data = await this.#handleResponse<AdminResponse<void>>(rsp);
121+
return data;
122+
}
123+
118124
async getPaymentInfo() {
119125
const rsp = await this.#req("payment", "GET");
120126
if (rsp.ok) {

ui_src/src/views/user-scope.tsx

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export default function UserScope() {
1414
const [error, setError] = useState<string>();
1515
const [loading, setLoading] = useState(true);
1616
const [filesPage, setFilesPage] = useState(0);
17+
const [purging, setPurging] = useState(false);
1718

1819
const login = useLogin();
1920
const pub = usePublisher();
@@ -54,6 +55,44 @@ export default function UserScope() {
5455
}
5556
}, [pub, self?.is_admin, pubkey, filesPage, url]);
5657

58+
async function handlePurgeUser() {
59+
if (!pub || !pubkey) return;
60+
61+
const confirmed = window.confirm(
62+
`Are you sure you want to delete ALL files for this user?\n\nThis action cannot be undone and will permanently delete:\n- ${userInfo?.file_count || 0} files\n- ${FormatBytes(userInfo?.total_size || 0, 2)} of storage\n\nType "DELETE" to confirm.`
63+
);
64+
65+
if (!confirmed) return;
66+
67+
const confirmText = window.prompt(
68+
'Please type "DELETE" to confirm this destructive action:'
69+
);
70+
71+
if (confirmText !== "DELETE") {
72+
alert("Confirmation text did not match. Operation cancelled.");
73+
return;
74+
}
75+
76+
setPurging(true);
77+
setError(undefined);
78+
79+
try {
80+
const r96 = new Route96(url, pub);
81+
await r96.purgeUser(pubkey);
82+
83+
// Refresh user info to show updated counts
84+
const response = await r96.getUserInfo(pubkey, filesPage, 50);
85+
setUserInfo(response.data);
86+
87+
alert("User account purged successfully. All files have been deleted.");
88+
} catch (e) {
89+
const message = e instanceof Error ? e.message : "Failed to purge user account";
90+
setError(message);
91+
} finally {
92+
setPurging(false);
93+
}
94+
}
95+
5796
if (loading) {
5897
return (
5998
<div className="flex justify-center items-center h-64">
@@ -161,6 +200,28 @@ export default function UserScope() {
161200
</div>
162201
)}
163202
</div>
203+
204+
{/* Danger Zone */}
205+
<div className="mt-6 pt-6 border-t border-neutral-600">
206+
<h4 className="text-lg font-semibold mb-4 text-red-400">Danger Zone</h4>
207+
<div className="bg-red-900/20 border border-red-700/50 rounded-lg p-4">
208+
<div className="flex items-center justify-between">
209+
<div>
210+
<h5 className="text-sm font-medium text-red-300">Purge User Account</h5>
211+
<p className="text-sm text-red-400/80 mt-1">
212+
Permanently delete all {userInfo.file_count} files for this user ({FormatBytes(userInfo.total_size, 2)})
213+
</p>
214+
</div>
215+
<button
216+
onClick={handlePurgeUser}
217+
disabled={purging || userInfo.file_count === 0}
218+
className="bg-red-600 hover:bg-red-500 disabled:bg-red-800 disabled:text-red-400 text-white px-4 py-2 rounded font-medium text-sm transition-colors"
219+
>
220+
{purging ? "Purging..." : "Purge Account"}
221+
</button>
222+
</div>
223+
</div>
224+
</div>
164225
</div>
165226
</div>
166227

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/mirror-suggestions.tsx","./src/components/payment.tsx","./src/components/profile.tsx","./src/components/progress-bar.tsx","./src/hooks/login.ts","./src/hooks/publisher.ts","./src/hooks/use-blossom-servers.ts","./src/upload/admin.ts","./src/upload/blossom.ts","./src/upload/index.ts","./src/upload/nip96.ts","./src/upload/progress.ts","./src/views/admin.tsx","./src/views/files.tsx","./src/views/header.tsx","./src/views/reports.tsx","./src/views/upload.tsx","./src/views/user-scope.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/mirror-suggestions.tsx","./src/components/payment.tsx","./src/components/profile.tsx","./src/components/progress-bar.tsx","./src/hooks/login.ts","./src/hooks/publisher.ts","./src/hooks/use-blossom-servers.ts","./src/upload/admin.ts","./src/upload/blossom.ts","./src/upload/index.ts","./src/upload/nip96.ts","./src/upload/progress.ts","./src/views/admin.tsx","./src/views/files.tsx","./src/views/header.tsx","./src/views/reports.tsx","./src/views/upload.tsx","./src/views/user-scope.tsx"],"version":"5.8.3"}

ui_src/tsconfig.node.tsbuildinfo

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
{"root":["./vite.config.ts"],"version":"5.6.2"}
1+
{"root":["./vite.config.ts"],"version":"5.8.3"}

0 commit comments

Comments
 (0)