hyaenidae/src/admin/mod.rs

1854 lines
56 KiB
Rust

use crate::{
error::{Error, OptionExt},
extensions::{CommentExt, ProfileExt, SubmissionExt},
nav::NavState,
pagination::{Page, PageNum, PageSource, SearchPage},
views::{OwnedProfileView, OwnedSubmissionView},
ActixLoader, State,
};
use actix_web::{client::Client, dev::Payload, web, FromRequest, HttpRequest, HttpResponse, Scope};
use actix_webfinger::Webfinger;
use chrono::{DateTime, Utc};
use futures::future::LocalBoxFuture;
use hyaenidae_accounts::{State as AccountState, User};
use hyaenidae_profiles::store::{Comment, File, Profile, ReportKind, Server, Submission};
use hyaenidae_toolkit::{Button, Select, TextInput};
use i18n_embed_fl::fl;
use sled::{Db, Transactional, Tree};
use std::collections::HashMap;
use url::Url;
use uuid::Uuid;
pub use hyaenidae_profiles::store::Report;
mod pagination;
use pagination::{
BlockedPager, ClosedPager, FederatedPager, InboundPager, KnownPager, OpenPager, OutboundPager,
ReportPager, ServerPager,
};
pub(super) fn scope() -> Scope {
web::scope("/admin")
.service(web::resource("").route(web::get().to(admin_page)))
.service(
web::resource("/server")
.route(web::get().to(to_admin))
.route(web::post().to(update_server)),
)
.service(
web::resource("/discover")
.route(web::get().to(to_admin))
.route(web::post().to(discover_server)),
)
.service(
web::scope("/nodes/{node_id}")
.service(
web::resource("/block")
.route(web::get().to(to_admin))
.route(web::post().to(block_server)),
)
.service(
web::resource("/unblock")
.route(web::get().to(to_admin))
.route(web::post().to(unblock_server)),
)
.service(
web::resource("/follow")
.route(web::get().to(to_admin))
.route(web::post().to(follow_server)),
)
.service(
web::resource("/accept")
.route(web::get().to(to_admin))
.route(web::post().to(accept_follow)),
)
.service(
web::resource("/reject")
.route(web::get().to(to_admin))
.route(web::post().to(reject_follow)),
)
.service(
web::resource("/cancel")
.route(web::get().to(to_admin))
.route(web::post().to(cancel_request)),
)
.service(
web::resource("/defederate")
.route(web::get().to(to_admin))
.route(web::post().to(defederate)),
),
)
.service(
web::resource("/reports/{report_id}")
.route(web::get().to(view_report))
.route(web::post().to(close_report)),
)
}
#[derive(Clone, Debug, serde::Deserialize)]
struct DiscoverForm {
url: String,
}
async fn discover_server(
loader: ActixLoader,
_: Admin,
form: web::Form<DiscoverForm>,
query: web::Query<HashMap<String, String>>,
nav_state: NavState,
client: web::Data<Client>,
state: web::Data<State>,
) -> Result<HttpResponse, Error> {
let DiscoverForm { url } = form.into_inner();
let url2 = url.clone();
let state2 = state.clone();
let fallible = || async move {
let url: Url = url.parse()?;
let domain = url.domain().req()?.to_owned();
let host = url.host().req()?;
let host = if let Some(port) = url.port() {
format!("{}:{}", host, port)
} else {
host.to_string()
};
let actor_handle = format!("{}@{}", domain, domain);
let https = url.scheme() == "https";
let wf = Webfinger::fetch(&client, &actor_handle, &host, https).await?;
let activitypub = wf.activitypub().req()?;
let href = activitypub.href.as_ref().req()?.parse()?;
state.spawn.download_apub_anonymous(href);
Ok(()) as Result<(), Error>
};
let query = query.into_inner();
if let Err(e) = (fallible)().await {
let mut federation_view = FederationView::build(query.clone(), &state2).await?;
federation_view
.discover_error(e.to_string())
.discover_value(url2);
let server_view = ServerView::build(&state2).await?;
let reports_vew = ReportsView::new(query, state2).await?;
return crate::rendered(HttpResponse::Ok(), |cursor| {
crate::templates::admin::index(
cursor,
&loader,
&reports_vew,
&server_view,
&federation_view,
&nav_state,
)
});
}
Ok(to_admin())
}
async fn block_server(
_: Admin,
server_id: web::Path<Uuid>,
state: web::Data<State>,
) -> Result<HttpResponse, Error> {
use hyaenidae_profiles::apub::actions::CreateServerBlock;
let server_id = server_id.into_inner();
let fallible = || async move {
let servers = state.profiles.store.servers.clone();
let self_server = web::block(move || servers.get_self()?.req()).await?;
state
.profiles
.run(CreateServerBlock::from_servers(server_id, self_server))
.await?;
Ok(()) as Result<(), Error>
};
if let Err(e) = (fallible)().await {
log::error!("Failed to process server block: {}", e);
}
Ok(to_admin())
}
async fn unblock_server(
_: Admin,
server_id: web::Path<Uuid>,
state: web::Data<State>,
) -> Result<HttpResponse, Error> {
use hyaenidae_profiles::apub::actions::DeleteServerBlock;
let server_id = server_id.into_inner();
let fallible = || async move {
let servers = state.profiles.store.servers.clone();
let self_server = web::block(move || servers.get_self()?.req()).await?;
let blocks = state.profiles.store.view.server_blocks.clone();
let block_id = web::block(move || blocks.by_forward(server_id, self_server)?.req()).await?;
state
.profiles
.run(DeleteServerBlock::from_id(block_id))
.await?;
Ok(()) as Result<(), Error>
};
if let Err(e) = (fallible)().await {
log::error!("Failed to process server unblock: {}", e);
}
Ok(to_admin())
}
async fn follow_server(
_: Admin,
server_id: web::Path<Uuid>,
state: web::Data<State>,
) -> Result<HttpResponse, Error> {
use hyaenidae_profiles::apub::actions::CreateFederationRequest;
let server_id = server_id.into_inner();
let fallible = || async move {
let servers = state.profiles.store.servers.clone();
let self_server = web::block(move || servers.get_self()?.req()).await?;
state
.profiles
.run(CreateFederationRequest::from_servers(
server_id,
self_server,
))
.await?;
Ok(()) as Result<(), Error>
};
if let Err(e) = (fallible)().await {
log::error!("Failed to process server follow request: {}", e);
}
Ok(to_admin())
}
async fn accept_follow(
_: Admin,
server_id: web::Path<Uuid>,
state: web::Data<State>,
) -> Result<HttpResponse, Error> {
use hyaenidae_profiles::apub::actions::AcceptFederationRequest;
let server_id = server_id.into_inner();
let fallible = || async move {
let servers = state.profiles.store.servers.clone();
let self_server = web::block(move || servers.get_self()?.req()).await?;
let federation_requests = state.profiles.store.view.server_follow_requests.clone();
let freq_id = web::block(move || {
federation_requests
.by_forward(self_server, server_id)?
.req()
})
.await?;
state
.profiles
.run(AcceptFederationRequest::from_id(freq_id))
.await?;
Ok(()) as Result<(), Error>
};
if let Err(e) = (fallible)().await {
log::error!("Failed to process server accept federation: {}", e);
}
Ok(to_admin())
}
async fn reject_follow(
_: Admin,
server_id: web::Path<Uuid>,
state: web::Data<State>,
) -> Result<HttpResponse, Error> {
use hyaenidae_profiles::apub::actions::RejectFederationRequest;
let server_id = server_id.into_inner();
let fallible = || async move {
let servers = state.profiles.store.servers.clone();
let self_server = web::block(move || servers.get_self()?.req()).await?;
let federation_requests = state.profiles.store.view.server_follow_requests.clone();
let freq_id = web::block(move || {
federation_requests
.by_forward(self_server, server_id)?
.req()
})
.await?;
state
.profiles
.run(RejectFederationRequest::from_id(freq_id))
.await?;
Ok(()) as Result<(), Error>
};
if let Err(e) = (fallible)().await {
log::error!("Failed to process server reject federation: {}", e);
}
Ok(to_admin())
}
async fn cancel_request(
_: Admin,
server_id: web::Path<Uuid>,
state: web::Data<State>,
) -> Result<HttpResponse, Error> {
use hyaenidae_profiles::apub::actions::UndoFederationRequest;
let server_id = server_id.into_inner();
let fallible = || async move {
let servers = state.profiles.store.servers.clone();
let self_server = web::block(move || servers.get_self()?.req()).await?;
let federation_requests = state.profiles.store.view.server_follow_requests.clone();
let freq_id = web::block(move || {
federation_requests
.by_forward(server_id, self_server)?
.req()
})
.await?;
state
.profiles
.run(UndoFederationRequest::from_id(freq_id))
.await?;
Ok(()) as Result<(), Error>
};
if let Err(e) = (fallible)().await {
log::error!("Failed to process cancel federation request: {}", e);
}
Ok(to_admin())
}
async fn defederate(
_: Admin,
server_id: web::Path<Uuid>,
state: web::Data<State>,
) -> Result<HttpResponse, Error> {
use hyaenidae_profiles::apub::actions::{UndoAcceptFederation, UndoFederation};
let server_id = server_id.into_inner();
let fallible = || async move {
let servers = state.profiles.store.servers.clone();
let self_server = web::block(move || servers.get_self()?.req()).await?;
let federations = state.profiles.store.view.server_follows.clone();
let outbound_follow_id =
web::block(move || Ok(federations.by_forward(server_id, self_server)?)).await?;
let federations = state.profiles.store.view.server_follows.clone();
let inbound_follow_id =
web::block(move || Ok(federations.by_forward(self_server, server_id)?)).await?;
if let Some(follow_id) = outbound_follow_id {
state
.profiles
.run(UndoFederation::from_id(follow_id))
.await?;
}
if let Some(follow_id) = inbound_follow_id {
state
.profiles
.run(UndoAcceptFederation::from_id(follow_id))
.await?;
}
Ok(()) as Result<(), Error>
};
if let Err(e) = (fallible)().await {
log::error!("Failed to process server defederate: {}", e);
}
Ok(to_admin())
}
pub struct FederationView {
servers: HashMap<Uuid, Server>,
blocked: SearchPage,
federated: SearchPage,
inbound_requests: SearchPage,
outbound_requests: SearchPage,
known: SearchPage,
discover_value: Option<String>,
discover_error: Option<String>,
query: HashMap<String, String>,
}
pub(crate) struct BlockView<'a> {
pub(crate) server: &'a Server,
}
impl<'a> BlockView<'a> {
pub(crate) fn unblock(&self, loader: &ActixLoader) -> Button {
Button::secondary(&fl!(loader, "admin-federation-unblock")).form(&self.unblock_path())
}
fn unblock_path(&self) -> String {
format!("/admin/nodes/{}/unblock", self.server.id())
}
}
pub(crate) struct FederatedView<'a> {
pub(crate) server: &'a Server,
}
impl<'a> FederatedView<'a> {
pub(crate) fn defederate(&self, loader: &ActixLoader) -> Button {
Button::secondary(&fl!(loader, "admin-federation-defederate")).form(&self.defederate_path())
}
pub(crate) fn block(&self, loader: &ActixLoader) -> Button {
Button::secondary(&fl!(loader, "admin-federation-block")).form(&self.block_path())
}
fn defederate_path(&self) -> String {
format!("/admin/nodes/{}/defederate", self.server.id())
}
fn block_path(&self) -> String {
format!("/admin/nodes/{}/block", self.server.id())
}
}
pub(crate) struct InboundRequestView<'a> {
pub(crate) server: &'a Server,
}
impl<'a> InboundRequestView<'a> {
pub(crate) fn accept(&self, loader: &ActixLoader) -> Button {
Button::secondary(&fl!(loader, "admin-federation-accept")).form(&self.accept_path())
}
pub(crate) fn reject(&self, loader: &ActixLoader) -> Button {
Button::secondary(&fl!(loader, "admin-federation-reject")).form(&self.reject_path())
}
pub(crate) fn block(&self, loader: &ActixLoader) -> Button {
Button::secondary(&fl!(loader, "admin-federation-block")).form(&self.block_path())
}
fn accept_path(&self) -> String {
format!("/admin/nodes/{}/accept", self.server.id())
}
fn reject_path(&self) -> String {
format!("/admin/nodes/{}/reject", self.server.id())
}
fn block_path(&self) -> String {
format!("/admin/nodes/{}/block", self.server.id())
}
}
pub(crate) struct OutboundRequestView<'a> {
pub(crate) server: &'a Server,
}
impl<'a> OutboundRequestView<'a> {
pub(crate) fn cancel(&self, loader: &ActixLoader) -> Button {
Button::secondary(&fl!(loader, "admin-federation-cancel")).form(&self.cancel_path())
}
pub(crate) fn block(&self, loader: &ActixLoader) -> Button {
Button::secondary(&fl!(loader, "admin-federation-block")).form(&self.block_path())
}
fn cancel_path(&self) -> String {
format!("/admin/nodes/{}/cancel", self.server.id())
}
fn block_path(&self) -> String {
format!("/admin/nodes/{}/block", self.server.id())
}
}
pub(crate) struct KnownView<'a> {
pub(crate) server: &'a Server,
}
impl<'a> KnownView<'a> {
pub(crate) fn federate(&self, loader: &ActixLoader) -> Button {
Button::secondary(&fl!(loader, "admin-federation-federate")).form(&self.federate_path())
}
pub(crate) fn block(&self, loader: &ActixLoader) -> Button {
Button::secondary(&fl!(loader, "admin-federation-block")).form(&self.block_path())
}
fn federate_path(&self) -> String {
format!("/admin/nodes/{}/follow", self.server.id())
}
fn block_path(&self) -> String {
format!("/admin/nodes/{}/block", self.server.id())
}
}
impl FederationView {
fn discover_error(&mut self, error: String) -> &mut Self {
self.discover_error = Some(error);
self
}
fn discover_value(&mut self, value: String) -> &mut Self {
self.discover_value = Some(value);
self
}
pub(crate) fn discover_input(&self, loader: &ActixLoader) -> TextInput {
let input = TextInput::new("url")
.title(&fl!(loader, "admin-discover-input"))
.placeholder(&fl!(loader, "admin-discover-placeholder"))
.error_opt(self.discover_error.clone());
if let Some(v) = &self.discover_value {
input.value(v)
} else {
input
}
}
pub(crate) fn discover_path(&self) -> &'static str {
"/admin/discover"
}
pub(crate) fn blocked<'a>(&'a self) -> impl Iterator<Item = BlockView<'a>> + 'a {
self.blocked
.items
.iter()
.filter_map(move |id| self.servers.get(id))
.map(|server| BlockView { server })
}
pub(crate) fn has_blocked_nav(&self) -> bool {
self.blocked.next.is_some() || self.blocked.prev.is_some()
}
fn blocked_next(&self, loader: &ActixLoader) -> Option<Button> {
if let Some(next) = self.blocked.next {
let mut query = self.query.clone();
query.insert("blocked_page".to_owned(), next.to_string());
let href = if let Ok(query) = serde_urlencoded::to_string(query) {
format!("/admin?{}", query)
} else {
"/admin".to_owned()
};
Some(Button::secondary(&fl!(loader, "server-next-page")).href(&href))
} else {
None
}
}
fn blocked_previous(&self, loader: &ActixLoader) -> Option<Button> {
if let Some(prev) = self.blocked.prev {
let mut query = self.query.clone();
query.insert("blocked_page".to_owned(), prev.to_string());
let href = if let Ok(query) = serde_urlencoded::to_string(query) {
format!("/admin?{}", query)
} else {
"/admin".to_owned()
};
Some(Button::secondary(&fl!(loader, "server-previous-page")).href(&href))
} else {
None
}
}
pub(crate) fn blocked_buttons(&self, loader: &ActixLoader) -> Vec<Button> {
self.blocked_previous(loader)
.into_iter()
.chain(self.blocked_next(loader))
.collect()
}
pub(crate) fn known<'a>(&'a self) -> impl Iterator<Item = KnownView<'a>> + 'a {
self.known
.items
.iter()
.filter_map(move |id| self.servers.get(id))
.map(|server| KnownView { server })
}
pub(crate) fn has_known_nav(&self) -> bool {
self.known.next.is_some() || self.known.prev.is_some()
}
fn known_next(&self, loader: &ActixLoader) -> Option<Button> {
if let Some(next) = self.known.next {
let mut query = self.query.clone();
query.insert("known_page".to_owned(), next.to_string());
let href = if let Ok(query) = serde_urlencoded::to_string(query) {
format!("/admin?{}", query)
} else {
"/admin".to_owned()
};
Some(Button::secondary(&fl!(loader, "server-next-page")).href(&href))
} else {
None
}
}
fn known_previous(&self, loader: &ActixLoader) -> Option<Button> {
if let Some(prev) = self.known.prev {
let mut query = self.query.clone();
query.insert("known_page".to_owned(), prev.to_string());
let href = if let Ok(query) = serde_urlencoded::to_string(query) {
format!("/admin?{}", query)
} else {
"/admin".to_owned()
};
Some(Button::secondary(&fl!(loader, "server-previous-page")).href(&href))
} else {
None
}
}
pub(crate) fn known_buttons(&self, loader: &ActixLoader) -> Vec<Button> {
self.known_previous(loader)
.into_iter()
.chain(self.known_next(loader))
.collect()
}
pub(crate) fn federated<'a>(&'a self) -> impl Iterator<Item = FederatedView<'a>> + 'a {
self.federated
.items
.iter()
.filter_map(move |id| self.servers.get(id))
.map(|server| FederatedView { server })
}
pub(crate) fn has_federated_nav(&self) -> bool {
self.federated.next.is_some() || self.federated.prev.is_some()
}
fn federated_next(&self, loader: &ActixLoader) -> Option<Button> {
if let Some(next) = self.federated.next {
let mut query = self.query.clone();
query.insert("federated_page".to_owned(), next.to_string());
let href = if let Ok(query) = serde_urlencoded::to_string(query) {
format!("/admin?{}", query)
} else {
"/admin".to_owned()
};
Some(Button::secondary(&fl!(loader, "server-next-page")).href(&href))
} else {
None
}
}
fn federated_previous(&self, loader: &ActixLoader) -> Option<Button> {
if let Some(prev) = self.federated.prev {
let mut query = self.query.clone();
query.insert("federated_page".to_owned(), prev.to_string());
let href = if let Ok(query) = serde_urlencoded::to_string(query) {
format!("/admin?{}", query)
} else {
"/admin".to_owned()
};
Some(Button::secondary(&fl!(loader, "server-previous-page")).href(&href))
} else {
None
}
}
pub(crate) fn federated_buttons(&self, loader: &ActixLoader) -> Vec<Button> {
self.federated_previous(loader)
.into_iter()
.chain(self.federated_next(loader))
.collect()
}
pub(crate) fn inbound<'a>(&'a self) -> impl Iterator<Item = InboundRequestView<'a>> + 'a {
self.inbound_requests
.items
.iter()
.filter_map(move |id| self.servers.get(id))
.map(|server| InboundRequestView { server })
}
pub(crate) fn has_inbound_nav(&self) -> bool {
self.inbound_requests.next.is_some() || self.inbound_requests.prev.is_some()
}
fn inbound_next(&self, loader: &ActixLoader) -> Option<Button> {
if let Some(next) = self.inbound_requests.next {
let mut query = self.query.clone();
query.insert("inbound_page".to_owned(), next.to_string());
let href = if let Ok(query) = serde_urlencoded::to_string(query) {
format!("/admin?{}", query)
} else {
"/admin".to_owned()
};
Some(Button::secondary(&fl!(loader, "server-next-page")).href(&href))
} else {
None
}
}
fn inbound_previous(&self, loader: &ActixLoader) -> Option<Button> {
if let Some(prev) = self.inbound_requests.prev {
let mut query = self.query.clone();
query.insert("inbound_page".to_owned(), prev.to_string());
let href = if let Ok(query) = serde_urlencoded::to_string(query) {
format!("/admin?{}", query)
} else {
"/admin".to_owned()
};
Some(Button::secondary(&fl!(loader, "server-previous-page")).href(&href))
} else {
None
}
}
pub(crate) fn inbound_buttons(&self, loader: &ActixLoader) -> Vec<Button> {
self.inbound_previous(loader)
.into_iter()
.chain(self.inbound_next(loader))
.collect()
}
pub(crate) fn outbound<'a>(&'a self) -> impl Iterator<Item = OutboundRequestView<'a>> + 'a {
self.outbound_requests
.items
.iter()
.filter_map(move |id| self.servers.get(id))
.map(|server| OutboundRequestView { server })
}
pub(crate) fn has_outbound_nav(&self) -> bool {
self.outbound_requests.next.is_some() || self.outbound_requests.prev.is_some()
}
fn outbound_next(&self, loader: &ActixLoader) -> Option<Button> {
if let Some(next) = self.outbound_requests.next {
let mut query = self.query.clone();
query.insert("outbound_page".to_owned(), next.to_string());
let href = if let Ok(query) = serde_urlencoded::to_string(query) {
format!("/admin?{}", query)
} else {
"/admin".to_owned()
};
Some(Button::secondary(&fl!(loader, "server-next-page")).href(&href))
} else {
None
}
}
fn outbound_previous(&self, loader: &ActixLoader) -> Option<Button> {
if let Some(prev) = self.outbound_requests.prev {
let mut query = self.query.clone();
query.insert("outbound_page".to_owned(), prev.to_string());
let href = if let Ok(query) = serde_urlencoded::to_string(query) {
format!("/admin?{}", query)
} else {
"/admin".to_owned()
};
Some(Button::secondary(&fl!(loader, "server-previous-page")).href(&href))
} else {
None
}
}
pub(crate) fn outbound_buttons(&self, loader: &ActixLoader) -> Vec<Button> {
self.outbound_previous(loader)
.into_iter()
.chain(self.outbound_next(loader))
.collect()
}
async fn build(query: HashMap<String, String>, state: &State) -> Result<Self, Error> {
let profiles = state.profiles.clone();
let view = web::block(move || {
let mut servers = HashMap::new();
let self_id = profiles.store.servers.get_self()?.req()?;
let self_server = profiles.store.servers.by_id(self_id)?.req()?;
servers.insert(self_server.id(), self_server);
let federated = SearchPage::from_pagination(
FederatedPager(ServerPager {
self_id,
store: &profiles.store,
servers: &mut servers,
}),
10,
"".to_owned(),
page_num(&query, "federated_page"),
);
let inbound_requests = SearchPage::from_pagination(
InboundPager(ServerPager {
self_id,
store: &profiles.store,
servers: &mut servers,
}),
10,
"".to_owned(),
page_num(&query, "inbound_page"),
);
let outbound_requests = SearchPage::from_pagination(
OutboundPager(ServerPager {
self_id,
store: &profiles.store,
servers: &mut servers,
}),
10,
"".to_owned(),
page_num(&query, "outbound_page"),
);
let blocked = SearchPage::from_pagination(
BlockedPager(ServerPager {
self_id,
store: &profiles.store,
servers: &mut servers,
}),
10,
"".to_owned(),
page_num(&query, "blocked_page"),
);
let known = SearchPage::from_pagination(
KnownPager(ServerPager {
self_id,
store: &profiles.store,
servers: &mut servers,
}),
10,
"".to_owned(),
page_num(&query, "known_page"),
);
Ok(FederationView {
servers,
blocked,
federated,
inbound_requests,
outbound_requests,
known,
discover_value: None,
discover_error: None,
query,
})
})
.await?;
Ok(view)
}
}
fn page_source(query: &HashMap<String, String>, prefix: &str) -> Option<PageSource> {
query
.get(&format!("{}_min", prefix))
.and_then(|min_str| Some(PageSource::NewerThan(min_str.parse().ok()?)))
.or_else(|| {
query
.get(&format!("{}_max", prefix))
.and_then(|max_str| Some(PageSource::OlderThan(max_str.parse().ok()?)))
})
}
fn page_num(query: &HashMap<String, String>, name: &str) -> Option<PageNum> {
query.get(name).and_then(|page_str| {
Some(PageNum {
page: page_str.parse().ok()?,
})
})
}
#[derive(Clone, Debug, serde::Deserialize)]
struct ConfigForm {
title: String,
description: String,
}
async fn update_server(
_: Admin,
form: web::Form<ConfigForm>,
state: web::Data<State>,
) -> Result<HttpResponse, Error> {
use hyaenidae_profiles::apub::actions::UpdateServer;
let ConfigForm { 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(
loader: ActixLoader,
_: 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 || report_store.by_id(report_id)?.req()).await?;
let report_view = ReportView::new(report, state).await?;
crate::rendered(HttpResponse::Ok(), |cursor| {
crate::templates::admin::report(cursor, &loader, &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(
loader: ActixLoader,
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 || report_store.by_id(report_id)?.req()).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 view = ReportView::new(report, state)
.await?
.error_opt(Some(error))
.value(form.body);
crate::rendered(HttpResponse::Ok(), |cursor| {
crate::templates::admin::report(cursor, &loader, &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(
loader: ActixLoader,
query: web::Query<HashMap<String, String>>,
_: Admin,
nav_state: NavState,
state: web::Data<State>,
) -> Result<HttpResponse, Error> {
let query = query.into_inner();
let federation_view = FederationView::build(query.clone(), &state).await?;
let server_view = ServerView::build(&state).await?;
let reports_vew = ReportsView::new(query, state).await?;
crate::rendered(HttpResponse::Ok(), |cursor| {
crate::templates::admin::index(
cursor,
&loader,
&reports_vew,
&server_view,
&federation_view,
&nav_state,
)
})
}
enum ReportedItem {
Submission {
submission: Submission,
author: Profile,
},
Profile(Profile),
Comment {
comment: Comment,
author: Profile,
},
}
pub struct ReportView {
files: HashMap<Uuid, File>,
report: Report,
reported_item: ReportedItem,
author: Option<Profile>,
input_value: Option<String>,
input_error: Option<String>,
}
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, loader: &ActixLoader) -> Select {
Select::new("action")
.title(&fl!(loader, "admin-report-resolve-select"))
.options(&[
(&fl!(loader, "admin-report-ignore"), "Ignore"),
(&fl!(loader, "admin-report-delete"), "Delete"),
(&fl!(loader, "admin-report-suspend"), "Suspend"),
])
.default_option("Ignore")
}
pub(crate) fn input(&self, loader: &ActixLoader) -> TextInput {
let input = TextInput::new("body")
.textarea()
.title(&fl!(loader, "admin-report-resolve-input"))
.placeholder(&fl!(loader, "admin-report-resolve-placeholder"))
.error_opt(self.input_error.clone());
if let Some(value) = &self.input_value {
input.value(value)
} else {
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,
files: &self.files,
}),
_ => None,
}
}
pub(crate) fn submission<'a>(&'a self) -> Option<SubmissionView<'a>> {
match &self.reported_item {
ReportedItem::Submission { submission, author } => Some(SubmissionView {
submission,
author,
files: &self.files,
}),
_ => None,
}
}
pub(crate) fn profile(&self) -> Option<OwnedProfileView> {
match &self.reported_item {
ReportedItem::Profile(ref profile) => Some(OwnedProfileView {
profile: profile.clone(),
icon: profile
.icon()
.and_then(|i| self.files.get(&i))
.map(|i| i.clone()),
banner: profile
.banner()
.and_then(|b| self.files.get(&b))
.map(|b| b.clone()),
}),
_ => None,
}
}
fn error_opt(mut self, opt: Option<String>) -> Self {
self.input_error = opt;
self
}
fn value(mut self, value: String) -> Self {
self.input_value = Some(value);
self
}
async fn new(report: Report, state: web::Data<State>) -> Result<Self, Error> {
let mut files: HashMap<Uuid, File> = HashMap::new();
let author = if let Some(author_id) = report.reporter_profile() {
let store = state.profiles.clone();
let author = web::block(move || store.store.profiles.by_id(author_id)?.req()).await?;
let mut file_ids = vec![];
file_ids.extend(author.icon());
file_ids.extend(author.banner());
let store = state.profiles.clone();
files = web::block(move || {
for file_id in file_ids {
if !files.contains_key(&file_id) {
let file = store.store.files.by_id(file_id)?.req()?;
files.insert(file.id(), file);
}
}
Ok(files) as Result<_, Error>
})
.await?;
Some(author)
} else {
None
};
let reported_item = match report.kind() {
ReportKind::Profile => {
let profile_id = report.item();
let store = state.profiles.clone();
let profile =
web::block(move || store.store.profiles.by_id(profile_id)?.req()).await?;
let store = state.profiles.clone();
let mut file_ids = vec![];
file_ids.extend(profile.icon());
file_ids.extend(profile.banner());
files = web::block(move || {
for file_id in file_ids {
if !files.contains_key(&file_id) {
let file = store.store.files.by_id(file_id)?.req()?;
files.insert(file.id(), file);
}
}
Ok(files) as Result<_, Error>
})
.await?;
ReportedItem::Profile(profile)
}
ReportKind::Submission => {
let submission_id = report.item();
let store = state.profiles.clone();
let submission =
web::block(move || store.store.submissions.by_id(submission_id)?.req()).await?;
let profile_id = submission.profile_id();
let store = state.profiles.clone();
let author =
web::block(move || store.store.profiles.by_id(profile_id)?.req()).await?;
let store = state.profiles.clone();
let mut file_ids: Vec<Uuid> = submission.files().iter().copied().collect();
file_ids.extend(author.icon());
file_ids.extend(author.banner());
files = web::block(move || {
for file_id in file_ids {
if !files.contains_key(&file_id) {
let file = store.store.files.by_id(file_id)?.req()?;
files.insert(file.id(), file);
}
}
Ok(files) as Result<_, Error>
})
.await?;
ReportedItem::Submission { submission, author }
}
ReportKind::Comment => {
let comment_id = report.item();
let store = state.profiles.clone();
let comment =
web::block(move || store.store.comments.by_id(comment_id)?.req()).await?;
let profile_id = comment.profile_id();
let store = state.profiles.clone();
let author =
web::block(move || store.store.profiles.by_id(profile_id)?.req()).await?;
let store = state.profiles.clone();
let mut file_ids = vec![];
file_ids.extend(author.icon());
file_ids.extend(author.banner());
files = web::block(move || {
for file_id in file_ids {
if !files.contains_key(&file_id) {
let file = store.store.files.by_id(file_id)?.req()?;
files.insert(file.id(), file);
}
}
Ok(files) as Result<_, Error>
})
.await?;
ReportedItem::Comment { comment, author }
}
_ => None.req()?,
};
Ok(ReportView {
files,
report,
author,
reported_item,
input_error: None,
input_value: None,
})
}
}
pub struct ServerView {
server: Server,
}
impl ServerView {
pub(crate) fn title_input(&self, loader: &ActixLoader) -> TextInput {
let title = TextInput::new("title")
.title(&fl!(loader, "server-info-title-input"))
.placeholder(&fl!(loader, "server-info-title-placeholder"));
if let Some(text) = self.server.title() {
title.value(text)
} else {
title
}
}
pub(crate) fn description_input(&self, loader: &ActixLoader) -> TextInput {
let description = TextInput::new("description")
.title(&fl!(loader, "server-info-description-input"))
.placeholder(&fl!(loader, "server-info-description-placeholder"))
.textarea();
if let Some(text) = self.server.description() {
description.value(text)
} else {
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 {
query: HashMap<String, String>,
open_reports: Page,
closed_reports: Page,
reports: HashMap<Uuid, Report>,
profiles: HashMap<Uuid, Profile>,
submissions: HashMap<Uuid, Submission>,
comments: HashMap<Uuid, Comment>,
files: HashMap<Uuid, File>,
}
pub(crate) struct CommentView<'a> {
pub(crate) author: &'a Profile,
pub(crate) comment: &'a Comment,
pub(crate) files: &'a HashMap<Uuid, File>,
}
impl<'a> CommentView<'a> {
pub(crate) fn view_path(&self) -> String {
self.comment.view_path()
}
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) fn author(&self) -> OwnedProfileView {
OwnedProfileView {
profile: self.author.clone(),
icon: self
.author
.icon()
.and_then(|i| self.files.get(&i))
.map(|i| i.clone()),
banner: None,
}
}
}
pub(crate) struct SubmissionView<'a> {
pub(crate) author: &'a Profile,
pub(crate) submission: &'a Submission,
pub(crate) files: &'a HashMap<Uuid, File>,
}
impl<'a> SubmissionView<'a> {
pub(crate) fn view_path(&self) -> String {
self.submission.view_path()
}
pub(crate) fn title(&self) -> String {
self.submission.title_text()
}
pub(crate) fn author_name(&self) -> String {
self.author.name()
}
pub(crate) fn author_path(&self) -> String {
self.author.view_path()
}
pub(crate) fn author(&self) -> OwnedProfileView {
OwnedProfileView {
profile: self.author.clone(),
icon: self
.author
.icon()
.and_then(|i| self.files.get(&i))
.map(|i| i.clone()),
banner: None,
}
}
pub(crate) fn submission(&self) -> OwnedSubmissionView {
OwnedSubmissionView {
submission: self.submission.clone(),
files: self
.submission
.files()
.iter()
.filter_map(|file_id| self.files.get(file_id))
.cloned()
.collect(),
current_file: None,
}
}
}
impl ReportsView {
pub(crate) fn open_reports<'a>(&'a self) -> impl Iterator<Item = &'a Report> + 'a {
self.open_reports
.items
.iter()
.filter_map(move |report_id| self.reports.get(report_id))
}
pub(crate) fn has_open_reports_nav(&self) -> bool {
self.open_reports.prev.is_some() || self.open_reports.next.is_some()
}
pub(crate) fn open_reports_nav(&self, loader: &ActixLoader) -> Vec<Button> {
let mut btns = vec![];
if let Some(prev) = self.open_reports.prev {
let mut query = self.query.clone();
query.insert("open_min".to_owned(), prev.to_string());
query.remove("open_max");
let href = if let Ok(query) = serde_urlencoded::to_string(query) {
format!("/admin?{}", query)
} else {
"/admin".to_owned()
};
btns.push(Button::secondary(&fl!(loader, "admin-reports-prev")).href(&href));
}
if let Some(next) = self.open_reports.next {
let mut query = self.query.clone();
query.insert("open_max".to_owned(), next.to_string());
query.remove("open_min");
let href = if let Ok(query) = serde_urlencoded::to_string(query) {
format!("/admin?{}", query)
} else {
"/admin".to_owned()
};
btns.push(Button::secondary(&fl!(loader, "admin-reports-next")).href(&href));
}
btns
}
pub(crate) fn closed_reports<'a>(&'a self) -> impl Iterator<Item = &'a Report> + 'a {
self.closed_reports
.items
.iter()
.filter_map(move |report_id| self.reports.get(report_id))
}
pub(crate) fn has_closed_reports_nav(&self) -> bool {
self.closed_reports.prev.is_some() || self.closed_reports.next.is_some()
}
pub(crate) fn closed_reports_nav(&self, loader: &ActixLoader) -> Vec<Button> {
let mut btns = vec![];
if let Some(prev) = self.closed_reports.prev {
let mut query = self.query.clone();
query.insert("closed_min".to_owned(), prev.to_string());
query.remove("closed_max");
let href = if let Ok(query) = serde_urlencoded::to_string(query) {
format!("/admin?{}", query)
} else {
"/admin".to_owned()
};
btns.push(Button::secondary(&fl!(loader, "admin-reports-prev")).href(&href));
}
if let Some(next) = self.closed_reports.next {
let mut query = self.query.clone();
query.insert("closed_max".to_owned(), next.to_string());
query.remove("closed_min");
let href = if let Ok(query) = serde_urlencoded::to_string(query) {
format!("/admin?{}", query)
} else {
"/admin".to_owned()
};
btns.push(Button::secondary(&fl!(loader, "admin-reports-next")).href(&href));
}
btns
}
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,
files: &self.files,
})
}
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,
files: &self.files,
})
}
async fn new(query: HashMap<String, String>, state: web::Data<State>) -> Result<Self, Error> {
let store = state.profiles.clone();
let view = web::block(move || {
let mut reports = HashMap::new();
let mut profiles = HashMap::new();
let mut submissions = HashMap::new();
let mut comments = HashMap::new();
let mut files = HashMap::new();
let open_reports = Page::from_pagination(
OpenPager(ReportPager {
store: &store.store,
reports: &mut reports,
profiles: &mut profiles,
submissions: &mut submissions,
comments: &mut comments,
files: &mut files,
}),
10,
page_source(&query, "open"),
);
let closed_reports = Page::from_pagination(
ClosedPager(ReportPager {
store: &store.store,
reports: &mut reports,
profiles: &mut profiles,
submissions: &mut submissions,
comments: &mut comments,
files: &mut files,
}),
10,
page_source(&query, "closed"),
);
Ok(ReportsView {
query,
open_reports,
closed_reports,
reports,
profiles,
submissions,
comments,
files,
}) 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())
}