From 3f446a0b16bb732b012b6f54ba695b6b770be96a Mon Sep 17 00:00:00 2001 From: asonix Date: Wed, 13 Jan 2021 22:46:34 -0600 Subject: [PATCH] Add Admin page - Add commandline flag to promote a user to an admin - Add basic reporting feature - Enable reports for comments - TODO: Enable reports for submissions & profiles --- server/Cargo.toml | 1 + server/src/admin.rs | 718 ++++++++++++++++++ server/src/comments.rs | 214 +++++- server/src/error.rs | 28 +- server/src/main.rs | 22 + server/src/nav.rs | 9 +- server/src/profiles/mod.rs | 44 +- server/src/submissions/mod.rs | 27 +- server/templates/admin/comment_box.rs.html | 40 + server/templates/admin/index.rs.html | 69 ++ server/templates/admin/report.rs.html | 59 ++ server/templates/admin/reporter.rs.html | 14 + server/templates/admin/submission_box.rs.html | 56 ++ server/templates/comments/nodes.rs.html | 24 +- server/templates/comments/public.rs.html | 1 + server/templates/comments/report.rs.html | 41 + .../templates/comments/report_success.rs.html | 18 + 17 files changed, 1349 insertions(+), 36 deletions(-) create mode 100644 server/src/admin.rs create mode 100644 server/templates/admin/comment_box.rs.html create mode 100644 server/templates/admin/index.rs.html create mode 100644 server/templates/admin/report.rs.html create mode 100644 server/templates/admin/reporter.rs.html create mode 100644 server/templates/admin/submission_box.rs.html create mode 100644 server/templates/comments/report.rs.html create mode 100644 server/templates/comments/report_success.rs.html diff --git a/server/Cargo.toml b/server/Cargo.toml index b013917..54d4193 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -28,6 +28,7 @@ html-minifier = "3.0.8" rand = "0.7" once_cell = "1.5.2" serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" sled = { version = "0.34.6", features = ["compression"] } structopt = "0.3" thiserror = "1.0" diff --git a/server/src/admin.rs b/server/src/admin.rs new file mode 100644 index 0000000..755ad5a --- /dev/null +++ b/server/src/admin.rs @@ -0,0 +1,718 @@ +use crate::{ + comments::Comment, + error::{Error, OptionExt}, + nav::NavState, + profiles::Profile, + submissions::Submission, + State, +}; +use actix_web::{dev::Payload, web, FromRequest, HttpRequest, HttpResponse, Scope}; +use chrono::{DateTime, Utc}; +use futures_core::future::LocalBoxFuture; +use hyaenidae_accounts::{State as AccountState, User}; +use hyaenidae_profiles::store::ReportKind; +use hyaenidae_toolkit::{Select, TextInput}; +use sled::{Db, Transactional, Tree}; +use std::collections::HashMap; +use uuid::Uuid; + +pub use hyaenidae_profiles::store::Report; + +pub(super) fn scope() -> Scope { + web::scope("/admin") + .service(web::resource("").route(web::get().to(admin_page))) + .service( + web::resource("/reports/{report_id}") + .route(web::get().to(view_report)) + .route(web::post().to(close_report)), + ) +} + +async fn view_report( + _: Admin, + report: web::Path, + nav_state: NavState, + state: web::Data, +) -> Result { + let report_id = report.into_inner(); + let report_store = state.profiles.store.reports.clone(); + let report = web::block(move || Ok(report_store.by_id(report_id)?)).await?; + + let view = ReportView::new(report, nav_state.dark(), state).await?; + + crate::rendered(HttpResponse::Ok(), |cursor| { + crate::templates::admin::report(cursor, &view, &nav_state) + }) +} + +#[derive(Clone, Copy, Debug, serde::Deserialize)] +enum CloseAction { + Delete, + Suspend, + Ignore, +} + +impl std::fmt::Display for CloseAction { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + match self { + CloseAction::Delete => write!(f, "Delete"), + CloseAction::Suspend => write!(f, "Suspend"), + CloseAction::Ignore => write!(f, "Ignore"), + } + } +} + +#[derive(Clone, Debug, serde::Deserialize)] +struct CloseForm { + action: CloseAction, + body: String, +} + +async fn close_report( + admin: Admin, + form: web::Form, + report: web::Path, + account_state: web::Data, + nav_state: NavState, + state: web::Data, +) -> Result { + let report_id = report.into_inner(); + let report_store = state.profiles.store.reports.clone(); + let report = web::block(move || Ok(report_store.by_id(report_id)?)).await?; + + let form = form.into_inner(); + + let error = if form.body.trim().is_empty() { + Some("Reports must be resolved with a resolution message".to_owned()) + } else { + match handle_report(form.action, &report, account_state, &state).await { + Ok(_) => None, + Err(e) => Some(e.to_string()), + } + }; + + let error = if let Some(error) = error { + error + } else { + match resolve_report( + admin.id(), + report.id(), + format!("{}: {}", form.action, form.body), + &state, + ) + .await + { + Ok(_) => return Ok(to_admin()), + Err(e) => e.to_string(), + } + }; + + let mut view = ReportView::new(report, nav_state.dark(), state).await?; + view.input.error_opt(Some(error)); + + crate::rendered(HttpResponse::Ok(), |cursor| { + crate::templates::admin::report(cursor, &view, &nav_state) + }) +} + +async fn handle_report( + form_action: CloseAction, + report: &Report, + account_state: web::Data, + state: &State, +) -> Result<(), Error> { + match form_action { + CloseAction::Ignore => Ok(()), + CloseAction::Suspend => { + let profile_id = match report.kind() { + ReportKind::Profile => report.item(), + ReportKind::Submission => { + let submission_id = report.item(); + let submission_store = state.profiles.store.submissions.clone(); + let submission = web::block(move || Ok(submission_store.by_id(submission_id)?)) + .await? + .req()?; + submission.profile_id() + } + ReportKind::Comment => { + let comment_id = report.item(); + let comment_store = state.profiles.store.comments.clone(); + let comment = web::block(move || Ok(comment_store.by_id(comment_id)?)) + .await? + .req()?; + comment.profile_id() + } + _ => unimplemented!("Profile ID can't be fetched for report kind"), + }; + + let profile_store = state.profiles.store.profiles.clone(); + let profile = web::block(move || Ok(profile_store.by_id(profile_id)?)) + .await? + .req()?; + + use hyaenidae_profiles::apub::actions::SuspendProfile; + if let Some(user_id) = profile.local_owner() { + account_state.suspend(user_id).await?; + + let profile_store = state.profiles.store.profiles.clone(); + let profiles = web::block(move || { + let profiles: Vec<_> = profile_store.for_local(user_id).collect(); + + Ok(profiles) as Result, Error> + }) + .await?; + for profile in profiles { + state.profiles.run(SuspendProfile::from_id(profile)).await?; + } + } else { + state + .profiles + .run(SuspendProfile::from_id(profile_id)) + .await?; + } + + Ok(()) + } + CloseAction::Delete => match report.kind() { + ReportKind::Profile => { + use hyaenidae_profiles::apub::actions::DeleteProfile; + + state + .profiles + .run(DeleteProfile::from_id(report.item())) + .await?; + + Ok(()) + } + ReportKind::Submission => { + use hyaenidae_profiles::apub::actions::DeleteSubmission; + + state + .profiles + .run(DeleteSubmission::from_id(report.item())) + .await?; + + Ok(()) + } + ReportKind::Comment => { + use hyaenidae_profiles::apub::actions::DeleteComment; + + state + .profiles + .run(DeleteComment::from_id(report.item())) + .await?; + + Ok(()) + } + _ => Ok(()), + }, + } +} + +fn to_admin() -> HttpResponse { + crate::redirect("/admin") +} + +async fn admin_page( + _: Admin, + nav_state: NavState, + state: web::Data, +) -> Result { + let open_reports = ReportsView::new(state).await?; + + crate::rendered(HttpResponse::Ok(), |cursor| { + crate::templates::admin::index(cursor, &open_reports, &nav_state) + }) +} + +enum ReportedItem { + Submission { + submission: Submission, + author: Profile, + }, + Profile(Profile), + Comment { + comment: Comment, + author: Profile, + }, +} + +pub struct ReportView { + report: Report, + reported_item: ReportedItem, + author: Option, + select: Select, + input: TextInput, +} + +impl ReportView { + pub(crate) fn id(&self) -> Uuid { + self.report.id() + } + + pub(crate) fn note(&self) -> Option<&str> { + self.report.note() + } + + pub(crate) fn select(&self) -> &Select { + &self.select + } + + pub(crate) fn input(&self) -> &TextInput { + &self.input + } + + pub(crate) fn update_path(&self) -> String { + format!("/admin/reports/{}", self.id()) + } + + pub(crate) fn author(&self) -> Option<&Profile> { + self.author.as_ref() + } + + pub(crate) fn comment<'a>(&'a self) -> Option> { + match &self.reported_item { + ReportedItem::Comment { comment, author } => Some(CommentView { comment, author }), + _ => None, + } + } + + pub(crate) fn submission<'a>(&'a self) -> Option> { + match &self.reported_item { + ReportedItem::Submission { submission, author } => { + Some(SubmissionView { submission, author }) + } + _ => None, + } + } + + pub(crate) fn profile(&self) -> Option<&Profile> { + match &self.reported_item { + ReportedItem::Profile(ref profile) => Some(profile), + _ => None, + } + } + + async fn new(report: Report, dark: bool, state: web::Data) -> Result { + let author = if let Some(author_id) = report.reporter_profile() { + let profile_store = state.profiles.store.profiles.clone(); + let file_store = state.profiles.store.files.clone(); + let author = + web::block(move || Profile::from_stores(author_id, &profile_store, &file_store)) + .await?; + Some(author) + } else { + None + }; + + let reported_item = match report.kind() { + ReportKind::Profile => { + let profile_id = report.item(); + let profile_store = state.profiles.store.profiles.clone(); + let file_store = state.profiles.store.files.clone(); + let profile = web::block(move || { + Profile::from_stores(profile_id, &profile_store, &file_store) + }) + .await?; + ReportedItem::Profile(profile) + } + ReportKind::Submission => { + let submission_id = report.item(); + let submission_store = state.profiles.store.submissions.clone(); + let file_store = state.profiles.store.files.clone(); + let submission = web::block(move || { + Submission::from_stores(submission_id, &submission_store, &file_store) + }) + .await?; + + let profile_id = submission.profile_id(); + let profile_store = state.profiles.store.profiles.clone(); + let file_store = state.profiles.store.files.clone(); + let author = web::block(move || { + Profile::from_stores(profile_id, &profile_store, &file_store) + }) + .await?; + + ReportedItem::Submission { submission, author } + } + ReportKind::Comment => { + let comment_id = report.item(); + let comment_store = state.profiles.store.comments.clone(); + let comment = web::block(move || Ok(comment_store.by_id(comment_id)?)) + .await? + .req()?; + + let profile_id = comment.profile_id(); + let profile_store = state.profiles.store.profiles.clone(); + let file_store = state.profiles.store.files.clone(); + let author = web::block(move || { + Profile::from_stores(profile_id, &profile_store, &file_store) + }) + .await?; + + ReportedItem::Comment { comment, author } + } + _ => None.req()?, + }; + + let mut input = TextInput::new("body"); + input + .textarea() + .title("Resolution Message") + .placeholder("Explain why you're resolving this report") + .dark(dark); + + let mut select = Select::new("action"); + select + .title("Resolution") + .options(&[ + ("Close Report", "Ignore"), + ("Delete Offending Item", "Delete"), + ("Suspend Account", "Suspend"), + ]) + .default_option("Ignore") + .dark(dark); + + Ok(ReportView { + report, + author, + reported_item, + input, + select, + }) + } +} + +pub struct ReportsView { + reports: Vec, + profiles: HashMap, + submissions: HashMap, + comments: HashMap, +} + +pub(crate) struct CommentView<'a> { + pub(crate) author: &'a Profile, + pub(crate) comment: &'a Comment, +} + +impl<'a> CommentView<'a> { + pub(crate) fn view_path(&self) -> String { + crate::comments::comment_path(self.comment) + } + + pub(crate) fn body(&self) -> &str { + self.comment.body() + } + + pub(crate) fn author_name(&self) -> String { + self.author.name() + } + + pub(crate) fn author_path(&self) -> String { + self.author.view_path() + } +} + +pub(crate) struct SubmissionView<'a> { + pub(crate) author: &'a Profile, + pub(crate) submission: &'a Submission, +} + +impl<'a> SubmissionView<'a> { + pub(crate) fn view_path(&self) -> String { + self.submission.view_path() + } + + pub(crate) fn title(&self) -> String { + self.submission.title() + } + + pub(crate) fn author_name(&self) -> String { + self.author.name() + } + + pub(crate) fn author_path(&self) -> String { + self.author.view_path() + } + + pub(crate) fn tiles(&self) -> Vec { + self.submission + .files() + .iter() + .map(|f| { + crate::profiles::SubmissionView::from_parts(&self.submission.inner, self.author, f) + }) + .collect() + } +} + +impl ReportsView { + pub(crate) fn reports(&self) -> &[Report] { + &self.reports + } + + pub(crate) fn view_path(&self, report: &Report) -> String { + format!("/admin/reports/{}", report.id()) + } + + pub(crate) fn reporter_profile<'a>(&'a self, report: &Report) -> Option<&'a Profile> { + let author_id = report.reporter_profile()?; + self.profiles.get(&author_id) + } + + pub(crate) fn profile<'a>(&'a self, report: &Report) -> Option<&'a Profile> { + let profile_id = report.profile()?; + self.profiles.get(&profile_id) + } + + pub(crate) fn submission<'a>(&'a self, report: &Report) -> Option> { + let submission_id = report.submission()?; + let submission = self.submissions.get(&submission_id)?; + let author_id = submission.profile_id(); + let author = self.profiles.get(&author_id)?; + + Some(SubmissionView { author, submission }) + } + + pub(crate) fn comment<'a>(&'a self, report: &Report) -> Option> { + let comment_id = report.comment()?; + let comment = self.comments.get(&comment_id)?; + let author_id = comment.profile_id(); + let author = self.profiles.get(&author_id)?; + + Some(CommentView { author, comment }) + } + + async fn new(state: web::Data) -> Result { + let report_store = state.profiles.store.reports.clone(); + let profile_store = state.profiles.store.profiles.clone(); + let submission_store = state.profiles.store.submissions.clone(); + let comment_store = state.profiles.store.comments.clone(); + let file_store = state.profiles.store.files.clone(); + + let view = web::block(move || { + let mut profiles = HashMap::new(); + let mut submissions = HashMap::new(); + let mut comments = HashMap::new(); + + let reports = report_store + .all() + .filter_map(|id| { + let report = report_store.by_id(id).ok()?; + + if let Some(id) = report.reporter_profile() { + if !profiles.contains_key(&id) { + let profile = + Profile::from_stores(id, &profile_store, &file_store).ok()?; + profiles.insert(profile.id(), profile); + } + } + + match report.kind() { + ReportKind::Profile => { + if !profiles.contains_key(&report.item()) { + let profile = Profile::from_stores( + report.item(), + &profile_store, + &file_store, + ) + .ok()?; + profiles.insert(profile.id(), profile); + } + } + ReportKind::Submission => { + if !submissions.contains_key(&report.item()) { + let submission = Submission::from_stores( + report.item(), + &submission_store, + &file_store, + ) + .ok()?; + if !profiles.contains_key(&submission.profile_id()) { + let profile = Profile::from_stores( + submission.profile_id(), + &profile_store, + &file_store, + ) + .ok()?; + profiles.insert(profile.id(), profile); + } + submissions.insert(submission.id(), submission); + } + } + ReportKind::Comment => { + if !comments.contains_key(&report.item()) { + let comment = comment_store.by_id(report.item()).ok()??; + if !profiles.contains_key(&comment.profile_id()) { + let profile = Profile::from_stores( + comment.profile_id(), + &profile_store, + &file_store, + ) + .ok()?; + profiles.insert(profile.id(), profile); + } + comments.insert(comment.id(), comment); + } + } + _ => (), + } + + Some(report) + }) + .rev() + .collect(); + + Ok(ReportsView { + reports, + profiles, + submissions, + comments, + }) as Result<_, Error> + }) + .await?; + + Ok(view) + } +} + +pub(crate) struct Admin(User); + +impl Admin { + fn id(&self) -> Uuid { + self.0.id() + } +} + +impl FromRequest for Admin { + type Config = (); + type Error = actix_web::Error; + type Future = LocalBoxFuture<'static, Result>; + + fn from_request(req: &HttpRequest, _: &mut Payload) -> Self::Future { + let user_fut = User::extract(req); + let state_fut = web::Data::::extract(&req); + + Box::pin(async move { + let user = user_fut.await?; + let state = state_fut.await?; + + let admin = state.admin.clone(); + let user_id = user.id(); + if web::block(move || admin.is_admin(user_id)).await? { + return Ok(Admin(user)); + } + + Err(Error::Required.into()) + }) + } +} + +#[derive(Clone, Copy, Debug, serde::Deserialize, serde::Serialize)] +enum ActionKind { + ResolveReport, +} + +#[derive(Clone)] +pub(super) struct Store { + admin: Tree, + admin_resolved_actions: Tree, + action_kind: Tree, + action_report: Tree, + action_admin: Tree, +} + +impl Store { + pub(super) fn build(db: &Db) -> Result { + Ok(Store { + admin: db.open_tree("/server/admin")?, + admin_resolved_actions: db.open_tree("/server/admin_resolved_actions")?, + action_kind: db.open_tree("/server/action_kind")?, + action_report: db.open_tree("/server/action_report")?, + action_admin: db.open_tree("/server/action_admin")?, + }) + } + + fn is_admin(&self, user_id: Uuid) -> Result { + self.admin + .get(user_id.as_bytes()) + .map(|opt| opt.is_some()) + .map_err(Error::from) + } + + pub(super) fn make_admin(&self, user_id: Uuid) -> Result<(), Error> { + let now = Utc::now(); + self.admin + .insert(user_id.as_bytes(), now.to_rfc3339().as_bytes())?; + Ok(()) + } + + fn resolve_report(&self, admin_id: Uuid, report_id: Uuid) -> Result<(), Error> { + let resolved = Utc::now(); + + let action_kind = ActionKind::ResolveReport; + let action_kind_vec = serde_json::to_vec(&action_kind)?; + + let mut id; + + while { + id = Uuid::new_v4(); + self.action_kind + .compare_and_swap( + id.as_bytes(), + None as Option<&[u8]>, + Some(action_kind_vec.as_slice()), + )? + .is_err() + } {} + + let res = [ + &self.admin_resolved_actions, + &self.action_report, + &self.action_admin, + ] + .transaction(move |trees| { + let admin_resolved_actions = &trees[0]; + let action_report = &trees[1]; + let action_admin = &trees[2]; + + admin_resolved_actions.insert( + admin_resolved_actions_key(admin_id, resolved).as_bytes(), + id.as_bytes(), + )?; + action_report.insert(id.as_bytes(), report_id.as_bytes())?; + action_admin.insert(id.as_bytes(), admin_id.as_bytes())?; + + Ok(()) + }); + + if let Err(e) = res { + self.action_kind.remove(id.as_bytes())?; + return Err(e.into()); + } + + Ok(()) + } +} + +async fn resolve_report( + admin_id: Uuid, + report_id: Uuid, + resolution: String, + state: &State, +) -> Result<(), Error> { + use hyaenidae_profiles::apub::actions::ResolveReport; + + state + .profiles + .run(ResolveReport::from_resolution(report_id, resolution)) + .await?; + + let admin = state.admin.clone(); + actix_web::web::block(move || admin.resolve_report(admin_id, report_id)).await?; + Ok(()) +} + +fn admin_resolved_actions_key(admin_id: Uuid, resolved: DateTime) -> String { + format!("/admin/{}/report/{}", admin_id, resolved.to_rfc3339()) +} diff --git a/server/src/comments.rs b/server/src/comments.rs index adcd18e..ed45647 100644 --- a/server/src/comments.rs +++ b/server/src/comments.rs @@ -25,7 +25,13 @@ pub(crate) fn scope() -> Scope { web::resource("/reply") .route(web::get().to(route_to_comment_page)) .route(web::post().to(reply)), - ), + ) + .service( + web::resource("/report") + .route(web::get().to(report_page)) + .route(web::post().to(report)), + ) + .route("/report-success", web::get().to(report_success_page)), ) } @@ -144,6 +150,10 @@ fn to_comment_page(comment_id: Uuid) -> HttpResponse { crate::redirect(&format!("/comments/{}", comment_id)) } +fn to_report_success_page(comment_id: Uuid) -> HttpResponse { + crate::redirect(&format!("/comments/{}/report-success", comment_id)) +} + #[derive(Debug)] enum ItemWithAuthorInner { Comment(Comment), @@ -243,10 +253,14 @@ pub(crate) fn update_path(comment: &Comment) -> String { format!("/comments/{}/edit", comment.id()) } -fn comment_path(comment: &Comment) -> String { +pub(crate) fn comment_path(comment: &Comment) -> String { format!("/comments/{}", comment.id()) } +pub(crate) fn report_path(comment: &Comment) -> String { + format!("/comments/{}/report", comment.id()) +} + fn can_view_logged_out(comment: &Comment, state: &State) -> Result { let submission = match state .profiles @@ -484,6 +498,10 @@ async fn edit_page( None => return Ok(crate::to_404()), }; + if comment.deleted() { + return Ok(crate::to_404()); + } + if comment.profile_id() != profile.id() { return Ok(crate::to_404()); } @@ -518,6 +536,10 @@ async fn update_comment( None => return Ok(crate::to_404()), }; + if comment.deleted() { + return Ok(crate::to_404()); + } + if comment.profile_id() != profile.id() { return Ok(crate::to_404()); } @@ -572,6 +594,10 @@ async fn view_comment( None => return Ok(crate::to_404()), }; + if comment.deleted() { + return Ok(crate::to_404()); + } + let view = match prepare_view(comment, profile.as_ref(), &nav_state, &state)? { Some(v) => v, None => return Ok(crate::to_404()), @@ -648,3 +674,187 @@ async fn reply( crate::templates::comments::public(cursor, &view, &nav_state) }) } + +pub struct ReportView { + pub(crate) parent: ItemWithAuthor, + pub(crate) comment: Comment, + pub(crate) author: Profile, + pub(crate) input: TextInput, +} + +impl ReportView { + async fn prepare(comment: Comment, dark: bool, state: &State) -> Result { + let profile_store = state.profiles.store.profiles.clone(); + let file_store = state.profiles.store.files.clone(); + + let author_id = comment.profile_id(); + + let author = + web::block(move || Profile::from_stores(author_id, &profile_store, &file_store)) + .await?; + + let parent = if let Some(comment_id) = comment.comment_id() { + let comment_store = state.profiles.store.comments.clone(); + let comment = web::block(move || Ok(comment_store.by_id(comment_id)?)) + .await? + .req()?; + + let author_id = comment.profile_id(); + let profile_store = state.profiles.store.profiles.clone(); + let file_store = state.profiles.store.files.clone(); + let author = + web::block(move || Profile::from_stores(author_id, &profile_store, &file_store)) + .await?; + + ItemWithAuthor::from_comment(comment, author) + } else { + let submission_id = comment.submission_id(); + let submission_store = state.profiles.store.submissions.clone(); + let file_store = state.profiles.store.files.clone(); + let submission = web::block(move || { + Submission::from_stores(submission_id, &submission_store, &file_store) + }) + .await?; + + let author_id = submission.profile_id(); + let file_store = state.profiles.store.files.clone(); + let profile_store = state.profiles.store.profiles.clone(); + let author = + web::block(move || Profile::from_stores(author_id, &profile_store, &file_store)) + .await?; + + ItemWithAuthor::from_submission(submission, author) + }; + + let mut input = TextInput::new("body"); + input + .title("Report") + .placeholder("Type your report info here") + .textarea() + .dark(dark); + + Ok(ReportView { + parent, + comment, + author, + input, + }) + } + + pub(crate) fn comment_path(&self) -> String { + comment_path(&self.comment) + } + + pub(crate) fn report_path(&self) -> String { + report_path(&self.comment) + } +} + +async fn report_page( + comment_id: web::Path, + profile: Profile, + nav_state: NavState, + state: web::Data, +) -> Result { + let comment = match state + .profiles + .store + .comments + .by_id(comment_id.into_inner())? + { + Some(comment) => comment, + None => return Ok(crate::to_404()), + }; + + if !can_view(&profile, &comment, &state)? { + return Ok(crate::to_404()); + } + + let view = ReportView::prepare(comment, nav_state.dark(), &state).await?; + + crate::rendered(HttpResponse::Ok(), |cursor| { + crate::templates::comments::report(cursor, &view, &nav_state) + }) +} + +#[derive(Clone, Debug, serde::Deserialize)] +struct ReportForm { + body: String, +} + +const MAX_REPORT_LEN: usize = 1000; + +async fn report( + comment_id: web::Path, + form: web::Form, + profile: Profile, + nav_state: NavState, + state: web::Data, +) -> Result { + use hyaenidae_profiles::apub::actions::CreateReport; + + let comment_id = comment_id.into_inner(); + + let comment = match state.profiles.store.comments.by_id(comment_id)? { + Some(comment) => comment, + None => return Ok(crate::to_404()), + }; + + if !can_view(&profile, &comment, &state)? { + return Ok(crate::to_404()); + } + + let form = form.into_inner(); + + let error = if form.body.trim().len() > MAX_REPORT_LEN { + format!("Must be fewer than {} characters", MAX_REPORT_LEN) + } else { + let res = state + .profiles + .run(CreateReport::from_comment( + comment_id, + profile.id(), + Some(form.body), + )) + .await; + + match res { + Ok(_) => return Ok(to_report_success_page(comment_id)), + Err(e) => e.to_string(), + } + }; + + let mut view = ReportView::prepare(comment, nav_state.dark(), &state).await?; + view.input.error_opt(Some(error)); + + crate::rendered(HttpResponse::Ok(), |cursor| { + crate::templates::comments::report(cursor, &view, &nav_state) + }) +} + +async fn report_success_page( + comment_id: web::Path, + profile: Profile, + nav_state: NavState, + state: web::Data, +) -> Result { + let comment = match state + .profiles + .store + .comments + .by_id(comment_id.into_inner())? + { + Some(comment) => comment, + None => return Ok(crate::to_404()), + }; + + if !can_view(&profile, &comment, &state)? { + return Ok(crate::to_404()); + } + + let view = ReportView::prepare(comment, nav_state.dark(), &state).await?; + + crate::rendered(HttpResponse::Ok(), |cursor| { + crate::templates::comments::report_success(cursor, &view, &nav_state) + }) +} diff --git a/server/src/error.rs b/server/src/error.rs index 1c7c063..d4c9c32 100644 --- a/server/src/error.rs +++ b/server/src/error.rs @@ -19,16 +19,29 @@ pub(crate) enum Error { #[error("{0}")] Sled(#[from] sled::Error), + #[error("{0}")] + Transaction(#[from] sled::transaction::TransactionError), + + #[error("{0}")] + Json(#[from] serde_json::Error), + #[error("Required data was not present")] Required, + + #[error("Panic in blocking operation")] + Panic, } impl ResponseError for Error { fn status_code(&self) -> StatusCode { match self { - Error::Render(_) | Error::Accounts(_) | Error::Profiles(_) | Error::Sled(_) => { - StatusCode::INTERNAL_SERVER_ERROR - } + Error::Render(_) + | Error::Accounts(_) + | Error::Profiles(_) + | Error::Sled(_) + | Error::Transaction(_) + | Error::Json(_) + | Error::Panic => StatusCode::INTERNAL_SERVER_ERROR, Error::Required => StatusCode::SEE_OTHER, } } @@ -66,3 +79,12 @@ impl From for Error { Error::Profiles(From::from(e)) } } + +impl From> for Error { + fn from(e: actix_web::error::BlockingError) -> Self { + match e { + actix_web::error::BlockingError::Error(e) => e, + _ => Error::Panic, + } + } +} diff --git a/server/src/main.rs b/server/src/main.rs index 3ef23ac..bb4ce52 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -13,6 +13,7 @@ use structopt::StructOpt; use uuid::Uuid; mod accounts; +mod admin; mod apub; mod comments; mod error; @@ -79,6 +80,19 @@ async fn main() -> anyhow::Result<()> { )?; let accounts_state = hyaenidae_accounts::state(&accounts_config, db.clone())?; + if let Some(user) = config.make_admin { + let user = accounts_state.by_username(user).await?.req()?; + let state = State::new( + spawner.clone(), + config.base_url, + config.pictrs_upstream, + &db.clone(), + )?; + + state.admin.make_admin(user.id())?; + return Ok(()); + } + let config_clone = config.clone(); HttpServer::new(move || { let config = config_clone.clone(); @@ -113,6 +127,7 @@ async fn main() -> anyhow::Result<()> { .service(profiles::scope()) .service(submissions::scope()) .service(comments::scope()) + .service(admin::scope()) .default_service(web::route().to(|| async move { to_404() })) }) .bind(config.bind_address)? @@ -124,6 +139,9 @@ async fn main() -> anyhow::Result<()> { #[derive(Clone, StructOpt)] struct Config { + #[structopt(long, about = "Make the provided user an admin")] + make_admin: Option, + #[structopt( short, long, @@ -188,6 +206,7 @@ impl std::str::FromStr for SecretKey { #[derive(Clone)] struct State { profiles: hyaenidae_profiles::State, + admin: admin::Store, spawn: jobs::Spawn, apub: apub::Apub, images: images::Images, @@ -213,6 +232,8 @@ impl State { let domain = base_url.domain().req()?.to_owned(); + let admin = admin::Store::build(db)?; + Ok(State { profiles: hyaenidae_profiles::State::build( pict_rs_upstream, @@ -222,6 +243,7 @@ impl State { client.clone(), db.clone(), )?, + admin, spawn, apub, images, diff --git a/server/src/nav.rs b/server/src/nav.rs index b7d5d7c..c2a4318 100644 --- a/server/src/nav.rs +++ b/server/src/nav.rs @@ -1,4 +1,4 @@ -use crate::profiles::Profile; +use crate::{admin::Admin, profiles::Profile}; use actix_web::{dev::Payload, web::Query, FromRequest, HttpRequest}; use futures_core::future::LocalBoxFuture; use hyaenidae_accounts::LogoutState; @@ -13,12 +13,14 @@ impl FromRequest for NavState { let profile = Option::::extract(req); let logout = Option::::extract(req); let query = Option::>>::extract(req); + let admin = Option::::extract(req); let path = req.uri().path().to_owned(); Box::pin(async move { let profile = profile.await?; let logout = logout.await?; let query = query.await?; + let admin = admin.await?; let dark = true; let mut nav = vec![]; @@ -46,6 +48,11 @@ impl FromRequest for NavState { nav.push(submission); nav.push(profile); nav.push(account); + if admin.is_some() { + let admin = Button::secondary("Admin"); + admin.href("/admin").dark(dark); + nav.push(admin); + } nav.push(logout); } else { let login = Button::primary_outline("Login"); diff --git a/server/src/profiles/mod.rs b/server/src/profiles/mod.rs index 7b839da..ea86f0d 100644 --- a/server/src/profiles/mod.rs +++ b/server/src/profiles/mod.rs @@ -6,7 +6,7 @@ use crate::{ use actix_session::Session; use actix_web::{web, HttpRequest, HttpResponse, Scope}; use hyaenidae_accounts::User; -use hyaenidae_profiles::store::{File, Submission}; +use hyaenidae_profiles::store::{File, FileStore, ProfileStore, Submission}; use hyaenidae_toolkit::Button; use std::collections::HashMap; use uuid::Uuid; @@ -222,21 +222,31 @@ pub struct Profile { } impl Profile { - pub(crate) fn from_id(profile_id: Uuid, state: &State) -> Result { - let inner = state.profiles.store.profiles.by_id(profile_id)?.req()?; + pub(crate) fn from_stores( + profile_id: Uuid, + profiles: &ProfileStore, + files: &FileStore, + ) -> Result { + let inner = profiles.by_id(profile_id)?.req()?; let banner = match inner.banner() { Some(banner_id) => { - let file = state.profiles.store.files.by_id(banner_id)?.req()?; - let hyaenidae_profiles::store::FileSource::PictRs(file) = file.source(); - Some(file.key().to_owned()) + let file = files.by_id(banner_id)?.req()?; + if let Some(key) = file.pictrs_key() { + Some(key.to_owned()) + } else { + None + } } None => None, }; let icon = match inner.icon() { Some(icon_id) => { - let file = state.profiles.store.files.by_id(icon_id)?.req()?; - let hyaenidae_profiles::store::FileSource::PictRs(file) = file.source(); - Some(file.key().to_owned()) + let file = files.by_id(icon_id)?.req()?; + if let Some(key) = file.pictrs_key() { + Some(key.to_owned()) + } else { + None + } } None => None, }; @@ -248,6 +258,14 @@ impl Profile { }) } + pub(crate) fn from_id(profile_id: Uuid, state: &State) -> Result { + Self::from_stores( + profile_id, + &state.profiles.store.profiles, + &state.profiles.store.files, + ) + } + pub(crate) fn view_path(&self) -> String { format!("/profiles/{}", self.full_handle()) } @@ -621,6 +639,14 @@ impl SubmissionView { self.submission.id() } + pub(crate) fn from_parts(submission: &Submission, poster: &Profile, first_file: &File) -> Self { + SubmissionView { + submission: submission.clone(), + poster: poster.clone(), + first_file: first_file.clone(), + } + } + pub(crate) fn posted_by(&self, profile: Option) -> bool { profile.map(|p| p == self.poster.id()).unwrap_or(false) } diff --git a/server/src/submissions/mod.rs b/server/src/submissions/mod.rs index fcb945c..0af1ad3 100644 --- a/server/src/submissions/mod.rs +++ b/server/src/submissions/mod.rs @@ -6,7 +6,7 @@ use crate::{ }; use actix_web::{web, HttpRequest, HttpResponse, Scope}; use chrono::{DateTime, Utc}; -use hyaenidae_profiles::store::File; +use hyaenidae_profiles::store::{File, FileStore, SubmissionStore}; use hyaenidae_toolkit::{Button, FileInput, TextInput}; use std::collections::HashMap; use uuid::Uuid; @@ -164,7 +164,7 @@ impl SubmissionState { #[derive(Clone, Debug)] pub struct Submission { - inner: hyaenidae_profiles::store::Submission, + pub(crate) inner: hyaenidae_profiles::store::Submission, files: Vec, } @@ -217,21 +217,28 @@ impl Submission { self.inner.published() } - pub(crate) fn from_id(submission_id: Uuid, state: &State) -> Result { - let inner = state - .profiles - .store - .submissions - .by_id(submission_id)? - .req()?; + pub(crate) fn from_stores( + submission_id: Uuid, + submission_store: &SubmissionStore, + file_store: &FileStore, + ) -> Result { + let inner = submission_store.by_id(submission_id)?.req()?; let mut files = vec![]; for file in inner.files() { - files.push(state.profiles.store.files.by_id(*file)?.req()?); + files.push(file_store.by_id(*file)?.req()?); } Ok(Submission { inner, files }) } + + pub(crate) fn from_id(submission_id: Uuid, state: &State) -> Result { + Self::from_stores( + submission_id, + &state.profiles.store.submissions, + &state.profiles.store.files, + ) + } } async fn files_page(_: Profile, nav_state: NavState) -> Result { diff --git a/server/templates/admin/comment_box.rs.html b/server/templates/admin/comment_box.rs.html new file mode 100644 index 0000000..78fc9a8 --- /dev/null +++ b/server/templates/admin/comment_box.rs.html @@ -0,0 +1,40 @@ +@use crate::templates::profiles::icon; +@use crate::{profiles::Profile, comments::Comment}; +@use hyaenidae_toolkit::{templates::link, Link}; +@use hyaenidae_toolkit::templates::icon as tkicon; +@use hyaenidae_toolkit::templates::ago; + +@(comment: &Comment, profile: &Profile, dark: bool) + +
+ @:tkicon(&profile.view_path(), true, dark, { + @if let Some(key) = profile.icon_key() { + @:icon(key, &profile.name()) + } + }) +
+
+
+
+ @if let Some(name) = profile.display_name() { +
+ @:link(&Link::current_tab(&profile.view_path()).plain(true).dark(dark), { + @name + }) +
+ } +
+ @:link(&Link::current_tab(&profile.view_path()).plain(true).dark(dark), { + @profile.full_handle() + }) +
+
+ posted @:ago(comment.published(), dark) +
+
+
+
+
@comment.body()
+
+
+ diff --git a/server/templates/admin/index.rs.html b/server/templates/admin/index.rs.html new file mode 100644 index 0000000..773fb30 --- /dev/null +++ b/server/templates/admin/index.rs.html @@ -0,0 +1,69 @@ +@use crate::admin::ReportsView; +@use crate::nav::NavState; +@use crate::templates::layouts::home; +@use crate::templates::admin::reporter; +@use hyaenidae_toolkit::{templates::button_group, Button}; +@use hyaenidae_toolkit::{templates::{card, card_title, card_body}, Card}; +@use hyaenidae_toolkit::{templates::link, Link}; + +@(view: &ReportsView, nav_state: &NavState) + +@:home("Admin Settings", "Perform admin operations for Hyaenidae", nav_state, {}, { + @:card(Card::full_width().dark(nav_state.dark()), { + @:card_title({ Reports }) + @for report in view.reports() { + @:card_body({ +
+
+ @if let Some(profile) = view.profile(report) { + @:reporter(view, report, nav_state.dark(), { + reported + + @:link(Link::new_tab(&profile.view_path()).plain(true).dark(nav_state.dark()), { + @profile.name() + }) + }) + } + @if let Some(submission) = view.submission(report) { + @:reporter(view, report, nav_state.dark(), { + reported + @:link(Link::new_tab(&submission.author_path()).plain(true).dark(nav_state.dark()), { + @submission.author_name()'s + }) + submission: + + @:link(Link::new_tab(&submission.view_path()).plain(true).dark(nav_state.dark()), { + @submission.title() + }) + }) + } + @if let Some(comment) = view.comment(report) { + @:reporter(view, report, nav_state.dark(), { + reported + @:link(Link::new_tab(&comment.author_path()).plain(true).dark(nav_state.dark()), { + @comment.author_name()'s + }) + comment: + + @:link(Link::new_tab(&comment.view_path()).plain(true).dark(nav_state.dark()), { + @comment.body() + }) + }) + } +
+ @if let Some(note) = report.note() { +
+

Note:

+

@note

+
+ } +
+ @:button_group(&[ + Button::secondary("View").href(&view.view_path(report)).dark(nav_state.dark()), + ]) +
+
+ }) + } + }) +}) diff --git a/server/templates/admin/report.rs.html b/server/templates/admin/report.rs.html new file mode 100644 index 0000000..dad2ae4 --- /dev/null +++ b/server/templates/admin/report.rs.html @@ -0,0 +1,59 @@ +@use crate::admin::ReportView; +@use crate::nav::NavState; +@use crate::templates::admin::{comment_box, submission_box}; +@use crate::templates::layouts::home; +@use crate::templates::profiles::view as view_profile; +@use hyaenidae_toolkit::{templates::button_group, Button}; +@use hyaenidae_toolkit::{templates::{card, card_body, card_title}, Card}; +@use hyaenidae_toolkit::{templates::link, Link}; +@use hyaenidae_toolkit::templates::select; +@use hyaenidae_toolkit::templates::text_input; + +@(view: &ReportView, nav_state: &NavState) + +@:home("Report", &format!("Report {}", view.id()), nav_state, {}, { + @:card(Card::full_width().dark(nav_state.dark()), { + @:card_title({ Reported Item }) + @if let Some(profile) = view.profile() { + @:view_profile(profile, nav_state.dark()) + } + @if let Some(cv) = view.comment() { + @:card_body({ + @:comment_box(cv.comment, cv.author, nav_state.dark()) + }) + } + @if let Some(sv) = view.submission() { + @:submission_box(sv.submission, sv.author, &sv.tiles(), nav_state.dark()) + } + @if let Some(author) = view.author() { + @:card_body({ + Reported by + @:link(&Link::new_tab(&author.view_path()).plain(true).dark(nav_state.dark()), { + @author.name() + }) + }) + } + @if let Some(note) = view.note() { + @:card_body({ +

Report Content

+

@note

+ }) + } + }) + @:card(Card::full_width().dark(nav_state.dark()), { +
+ @:card_title({ Actions }) + @:card_body({ +
+ @:select(view.select()) +
+ @:text_input(view.input()) + }) + @:card_body({ + @:button_group(&[ + &Button::primary("Resolve"), + ]) + }) +
+ }) +}) diff --git a/server/templates/admin/reporter.rs.html b/server/templates/admin/reporter.rs.html new file mode 100644 index 0000000..f05e0b1 --- /dev/null +++ b/server/templates/admin/reporter.rs.html @@ -0,0 +1,14 @@ +@use crate::admin::{Report, ReportsView}; +@use hyaenidae_toolkit::{templates::link, Link}; + +@(view: &ReportsView, report: &Report, dark: bool, body: Content) + +
+ @if let Some(author) = view.reporter_profile(report) { + @:link(Link::new_tab(&author.view_path()).plain(true).dark(dark), { + @author.name() + }) + } + + @:body() +
diff --git a/server/templates/admin/submission_box.rs.html b/server/templates/admin/submission_box.rs.html new file mode 100644 index 0000000..f7bbe40 --- /dev/null +++ b/server/templates/admin/submission_box.rs.html @@ -0,0 +1,56 @@ +@use crate::templates::profiles::{icon, submission_tile}; +@use crate::{profiles::{Profile, SubmissionView}, submissions::Submission}; +@use hyaenidae_toolkit::templates::{card_body, card_section}; +@use hyaenidae_toolkit::{templates::link, Link}; +@use hyaenidae_toolkit::templates::icon as tkicon; +@use hyaenidae_toolkit::templates::ago; + +@(submission: &Submission, profile: &Profile, tiles: &[SubmissionView], dark: bool) + +@:card_body({ +
+ @:tkicon(&profile.view_path(), true, dark, { + @if let Some(key) = profile.icon_key() { + @:icon(key, &profile.name()) + } + }) +
+
+
+
+ @if let Some(name) = profile.display_name() { +
+ @:link(&Link::current_tab(&profile.view_path()).plain(true).dark(dark), { + @name + }) +
+ } +
+ @:link(&Link::current_tab(&profile.view_path()).plain(true).dark(dark), { + @profile.full_handle() + }) +
+ @if let Some(published) = submission.published() { +
+ posted @:ago(published, dark) +
+ } +
+
+
+
+

@submission.title()

+ @if let Some(description) = submission.description() { +

@description

+ } +
+
+
+}) +@:card_section({ +
+ @for tile in tiles { + @:submission_tile(tile, Some(submission.profile_id()), dark) + } +
+}) diff --git a/server/templates/comments/nodes.rs.html b/server/templates/comments/nodes.rs.html index 612c93d..6241674 100644 --- a/server/templates/comments/nodes.rs.html +++ b/server/templates/comments/nodes.rs.html @@ -9,19 +9,21 @@ @:nested_node({ @:profile_box(author, comment, replying_to, dark, { }, { diff --git a/server/templates/comments/public.rs.html b/server/templates/comments/public.rs.html index 281bfae..6f51c9e 100644 --- a/server/templates/comments/public.rs.html +++ b/server/templates/comments/public.rs.html @@ -39,6 +39,7 @@ @:button_group(&[ &Button::primary("Reply").dark(nav_state.dark()), &Button::secondary("Back to Submission").href(&view.submission_path()).dark(nav_state.dark()), + &Button::primary_outline("Report").href(&crate::comments::report_path(comment)).dark(nav_state.dark()), ]) diff --git a/server/templates/comments/report.rs.html b/server/templates/comments/report.rs.html new file mode 100644 index 0000000..f515c65 --- /dev/null +++ b/server/templates/comments/report.rs.html @@ -0,0 +1,41 @@ +@use crate::comments::ReportView; +@use crate::nav::NavState; +@use crate::templates::layouts::home; +@use crate::templates::comments::profile_box; +@use hyaenidae_toolkit::{templates::button_group, Button}; +@use hyaenidae_toolkit::{templates::{card, card_title, card_body}, Card}; +@use hyaenidae_toolkit::templates::text_input; + +@(view: &ReportView, nav_state: &NavState) + +@:home("Report Comment", &format!("Report comment by {}", view.author.name()), nav_state, {}, { + @:card(Card::full_width().dark(nav_state.dark()), { + @:card_title({ Report Comment }) + @:card_body({ +
+
+ @:profile_box(&view.author, &view.comment, &view.parent, nav_state.dark(), {}, { +
+ @view.comment.body() +
+ }) +
+
+ }) + @:card_body({ +
+

Report Comment

+

+ Please include any relevant information for moderators to act on this report. +

+ @:text_input(&view.input) +
+ @:button_group(&[ + Button::primary("Report").dark(nav_state.dark()), + Button::secondary("Back to Comment").href(&view.comment_path()).dark(nav_state.dark()), + ]) +
+
+ }) + }) +}) diff --git a/server/templates/comments/report_success.rs.html b/server/templates/comments/report_success.rs.html new file mode 100644 index 0000000..42caad3 --- /dev/null +++ b/server/templates/comments/report_success.rs.html @@ -0,0 +1,18 @@ +@use crate::comments::ReportView; +@use crate::nav::NavState; +@use crate::templates::layouts::home; +@use hyaenidae_toolkit::{templates::button_group, Button}; +@use hyaenidae_toolkit::{templates::{card, card_title, card_body}, Card}; + +@(view: &ReportView, nav_state: &NavState) + +@:home("Comment Reported", &format!("Reported comment by {}", view.author.name()), nav_state, {}, { + @:card(Card::full_width().dark(nav_state.dark()), { + @:card_title({ Comment Reported }) + @:card_body({ + @:button_group(&[ + Button::secondary("Back to Comment").href(&view.comment_path()).dark(nav_state.dark()), + ]) + }) + }) +})