mirror of
https://github.com/v0l/route96.git
synced 2025-06-14 15:46:32 +00:00
[WIP] Reporting (#19)
* Initial plan for issue * Add database migration and report structures 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>
This commit is contained in:
829
Cargo.lock
generated
829
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
28
migrations/20250610135841_reports.sql
Normal file
28
migrations/20250610135841_reports.sql
Normal file
@ -0,0 +1,28 @@
|
||||
-- Create reports table for file reporting functionality
|
||||
create table reports
|
||||
(
|
||||
id integer unsigned not null auto_increment primary key,
|
||||
file_id binary(32) not null,
|
||||
reporter_id integer unsigned not null,
|
||||
event_json text not null,
|
||||
created timestamp default current_timestamp,
|
||||
|
||||
constraint fk_reports_file
|
||||
foreign key (file_id) references uploads (id)
|
||||
on delete cascade
|
||||
on update restrict,
|
||||
|
||||
constraint fk_reports_reporter
|
||||
foreign key (reporter_id) references users (id)
|
||||
on delete cascade
|
||||
on update restrict
|
||||
);
|
||||
|
||||
-- Unique index to prevent duplicate reports from same user for same file
|
||||
create unique index ix_reports_file_reporter on reports (file_id, reporter_id);
|
||||
|
||||
-- Index for efficient lookups by file
|
||||
create index ix_reports_file_id on reports (file_id);
|
||||
|
||||
-- Index for efficient lookups by reporter
|
||||
create index ix_reports_reporter_id on reports (reporter_id);
|
49
src/db.rs
49
src/db.rs
@ -108,6 +108,16 @@ pub struct Payment {
|
||||
pub rate: Option<f32>,
|
||||
}
|
||||
|
||||
#[derive(Clone, FromRow, Serialize)]
|
||||
pub struct Report {
|
||||
pub id: u64,
|
||||
#[serde(with = "hex")]
|
||||
pub file_id: Vec<u8>,
|
||||
pub reporter_id: u64,
|
||||
pub event_json: String,
|
||||
pub created: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Database {
|
||||
pub(crate) pool: sqlx::pool::Pool<sqlx::mysql::MySql>,
|
||||
@ -369,4 +379,43 @@ impl Database {
|
||||
// Check if upload would exceed quota
|
||||
Ok(user_stats.total_size + upload_size <= available_quota)
|
||||
}
|
||||
|
||||
/// Add a new report to the database
|
||||
pub async fn add_report(&self, file_id: &[u8], reporter_id: u64, event_json: &str) -> Result<(), Error> {
|
||||
sqlx::query("insert into reports (file_id, reporter_id, event_json) values (?, ?, ?)")
|
||||
.bind(file_id)
|
||||
.bind(reporter_id)
|
||||
.bind(event_json)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// List reports with pagination for admin view
|
||||
pub async fn list_reports(&self, offset: u32, limit: u32) -> Result<(Vec<Report>, i64), Error> {
|
||||
let reports: Vec<Report> = sqlx::query_as(
|
||||
"select id, file_id, reporter_id, event_json, created from reports 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")
|
||||
.fetch_one(&self.pool)
|
||||
.await?
|
||||
.try_get(0)?;
|
||||
|
||||
Ok((reports, count))
|
||||
}
|
||||
|
||||
/// Get reports for a specific file
|
||||
pub async fn get_file_reports(&self, file_id: &[u8]) -> Result<Vec<Report>, Error> {
|
||||
sqlx::query_as(
|
||||
"select id, file_id, reporter_id, event_json, created from reports where file_id = ? order by created desc"
|
||||
)
|
||||
.bind(file_id)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
@ -1,5 +1,5 @@
|
||||
use crate::auth::nip98::Nip98Auth;
|
||||
use crate::db::{Database, FileUpload, User};
|
||||
use crate::db::{Database, FileUpload, User, Report};
|
||||
use crate::routes::{Nip94Event, PagedResult};
|
||||
use crate::settings::Settings;
|
||||
use rocket::serde::json::Json;
|
||||
@ -8,7 +8,7 @@ use rocket::{routes, Responder, Route, State};
|
||||
use sqlx::{Error, QueryBuilder, Row};
|
||||
|
||||
pub fn admin_routes() -> Vec<Route> {
|
||||
routes![admin_list_files, admin_get_self]
|
||||
routes![admin_list_files, admin_get_self, admin_list_reports]
|
||||
}
|
||||
|
||||
#[derive(Serialize, Default)]
|
||||
@ -161,6 +161,36 @@ async fn admin_list_files(
|
||||
}
|
||||
}
|
||||
|
||||
#[rocket::get("/reports?<page>&<count>")]
|
||||
async fn admin_list_reports(
|
||||
auth: Nip98Auth,
|
||||
page: u32,
|
||||
count: u32,
|
||||
db: &State<Database>,
|
||||
) -> AdminResponse<PagedResult<Report>> {
|
||||
let pubkey_vec = auth.event.pubkey.to_bytes().to_vec();
|
||||
let server_count = count.clamp(1, 5_000);
|
||||
|
||||
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.list_reports(page * server_count, server_count).await {
|
||||
Ok((reports, total_count)) => AdminResponse::success(PagedResult {
|
||||
count: reports.len() as u32,
|
||||
page,
|
||||
total: total_count as u32,
|
||||
files: reports,
|
||||
}),
|
||||
Err(e) => AdminResponse::error(&format!("Could not list reports: {}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
impl Database {
|
||||
pub async fn list_all_files(
|
||||
&self,
|
||||
|
@ -5,7 +5,7 @@ use crate::routes::{delete_file, Nip94Event};
|
||||
use crate::settings::Settings;
|
||||
use log::error;
|
||||
use nostr::prelude::hex;
|
||||
use nostr::{Alphabet, SingleLetterTag, TagKind};
|
||||
use nostr::{Alphabet, SingleLetterTag, TagKind, JsonUtil};
|
||||
use rocket::data::ByteUnit;
|
||||
use rocket::futures::StreamExt;
|
||||
use rocket::http::{Header, Status};
|
||||
@ -63,13 +63,14 @@ pub fn blossom_routes() -> Vec<Route> {
|
||||
upload_head,
|
||||
upload_media,
|
||||
head_media,
|
||||
mirror
|
||||
mirror,
|
||||
report_file
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "media-compression"))]
|
||||
pub fn blossom_routes() -> Vec<Route> {
|
||||
routes![delete_blob, upload, list_files, upload_head, mirror]
|
||||
routes![delete_blob, upload, list_files, upload_head, mirror, report_file]
|
||||
}
|
||||
|
||||
/// Generic holder response, mostly for errors
|
||||
@ -429,3 +430,65 @@ where
|
||||
BlossomResponse::BlobDescriptor(Json(BlobDescriptor::from_upload(settings, &upload)))
|
||||
}
|
||||
}
|
||||
|
||||
#[rocket::put("/report", data = "<data>", format = "json")]
|
||||
async fn report_file(
|
||||
auth: BlossomAuth,
|
||||
db: &State<Database>,
|
||||
settings: &State<Settings>,
|
||||
data: Json<nostr::Event>,
|
||||
) -> BlossomResponse {
|
||||
// Check if the request has the correct method tag
|
||||
if !check_method(&auth.event, "report") {
|
||||
return BlossomResponse::error("Invalid request method tag");
|
||||
}
|
||||
|
||||
// Check whitelist
|
||||
if let Some(e) = check_whitelist(&auth, settings) {
|
||||
return e;
|
||||
}
|
||||
|
||||
// Extract file SHA256 from the "x" tag in the report event
|
||||
let file_sha256 = if let Some(x_tag) = data.tags.iter().find_map(|t| {
|
||||
if t.kind() == TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::X)) {
|
||||
t.content()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}) {
|
||||
match hex::decode(x_tag) {
|
||||
Ok(hash) => hash,
|
||||
Err(_) => return BlossomResponse::error("Invalid file hash in x tag"),
|
||||
}
|
||||
} else {
|
||||
return BlossomResponse::error("Missing file hash in x tag");
|
||||
};
|
||||
|
||||
// Verify the reported file exists
|
||||
match db.get_file(&file_sha256).await {
|
||||
Ok(Some(_)) => {}, // File exists, continue
|
||||
Ok(None) => return BlossomResponse::error("File not found"),
|
||||
Err(e) => return BlossomResponse::error(format!("Failed to check file: {}", e)),
|
||||
}
|
||||
|
||||
// Get or create the reporter user
|
||||
let reporter_id = match db.upsert_user(&auth.event.pubkey.to_bytes().to_vec()).await {
|
||||
Ok(user_id) => user_id,
|
||||
Err(e) => return BlossomResponse::error(format!("Failed to get user: {}", e)),
|
||||
};
|
||||
|
||||
// Store the report (the database will handle duplicate prevention via unique index)
|
||||
match db.add_report(&file_sha256, reporter_id, &data.as_json()).await {
|
||||
Ok(()) => BlossomResponse::Generic(BlossomGenericResponse {
|
||||
status: Status::Ok,
|
||||
message: Some("Report submitted successfully".to_string()),
|
||||
}),
|
||||
Err(e) => {
|
||||
if e.to_string().contains("Duplicate entry") {
|
||||
BlossomResponse::error("You have already reported this file")
|
||||
} else {
|
||||
BlossomResponse::error(format!("Failed to submit report: {}", e))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Reference in New Issue
Block a user