hyaenidae/server/src/admin.rs

803 lines
24 KiB
Rust
Raw Normal View History

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::future::LocalBoxFuture;
use hyaenidae_accounts::{State as AccountState, User};
2021-01-16 17:49:03 +00:00
use hyaenidae_profiles::store::{ReportKind, Server};
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)))
2021-01-16 17:49:03 +00:00
.service(
web::resource("/server")
.route(web::get().to(to_admin))
.route(web::post().to(update_server)),
)
.service(
web::resource("/reports/{report_id}")
.route(web::get().to(view_report))
.route(web::post().to(close_report)),
)
}
2021-01-16 17:49:03 +00:00
#[derive(Clone, Debug, serde::Deserialize)]
struct ServerForm {
title: String,
description: String,
}
async fn update_server(
_: Admin,
form: web::Form<ServerForm>,
state: web::Data<State>,
) -> Result<HttpResponse, Error> {
use hyaenidae_profiles::apub::actions::UpdateServer;
let ServerForm { title, description } = form.into_inner();
let server_view = ServerView::build(&state).await?;
state
.profiles
.run(UpdateServer::from_text(
server_view.server.id(),
Some(title),
Some(description),
))
.await?;
Ok(to_admin())
}
async fn view_report(
_: Admin,
report: web::Path<Uuid>,
nav_state: NavState,
state: web::Data<State>,
) -> Result<HttpResponse, Error> {
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?;
2021-01-16 17:49:03 +00:00
let report_view = ReportView::new(report, nav_state.dark(), state).await?;
crate::rendered(HttpResponse::Ok(), |cursor| {
2021-01-16 17:49:03 +00:00
crate::templates::admin::report(cursor, &report_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<CloseForm>,
report: web::Path<Uuid>,
account_state: web::Data<AccountState>,
nav_state: NavState,
state: web::Data<State>,
) -> Result<HttpResponse, Error> {
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<AccountState>,
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<Vec<_>, 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<State>,
) -> Result<HttpResponse, Error> {
2021-01-16 17:49:03 +00:00
let server_view = ServerView::build(&state).await?;
let open_reports = ReportsView::new(state).await?;
crate::rendered(HttpResponse::Ok(), |cursor| {
2021-01-16 17:49:03 +00:00
crate::templates::admin::index(cursor, &open_reports, &server_view, &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<Profile>,
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<CommentView<'a>> {
match &self.reported_item {
ReportedItem::Comment { comment, author } => Some(CommentView { comment, author }),
_ => None,
}
}
pub(crate) fn submission<'a>(&'a self) -> Option<SubmissionView<'a>> {
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<State>) -> Result<Self, Error> {
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,
})
}
}
2021-01-16 17:49:03 +00:00
pub struct ServerView {
server: Server,
}
impl ServerView {
pub(crate) fn title_input(&self, dark: bool) -> TextInput {
let mut title = TextInput::new("title");
title.title("Title").placeholder("Server Title").dark(dark);
if let Some(text) = self.server.title() {
title.value(text);
}
title
}
pub(crate) fn description_input(&self, dark: bool) -> TextInput {
let mut description = TextInput::new("description");
description
.title("Description")
.placeholder("Server Description")
.textarea()
.dark(dark);
if let Some(text) = self.server.description() {
description.value(text);
}
description
}
pub(crate) fn update_path(&self) -> &str {
"/admin/server"
}
async fn build(state: &State) -> Result<Self, Error> {
let servers = state.profiles.store.servers.clone();
let self_server = web::block(move || {
let id = servers.get_self()?.req()?;
servers.by_id(id)?.req()
})
.await?;
Ok(ServerView {
server: self_server,
})
}
}
pub struct ReportsView {
reports: Vec<Report>,
profiles: HashMap<Uuid, Profile>,
submissions: HashMap<Uuid, Submission>,
comments: HashMap<Uuid, Comment>,
}
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<crate::profiles::SubmissionView> {
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<SubmissionView<'a>> {
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<CommentView<'a>> {
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<State>) -> Result<Self, Error> {
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<Self, Self::Error>>;
fn from_request(req: &HttpRequest, _: &mut Payload) -> Self::Future {
let user_fut = User::extract(req);
let state_fut = web::Data::<State>::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<Self, sled::Error> {
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<bool, Error> {
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<Utc>) -> String {
format!("/admin/{}/report/{}", admin_id, resolved.to_rfc3339())
}