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
This commit is contained in:
asonix 2021-01-13 22:46:34 -06:00
parent 464429747d
commit 3f446a0b16
17 changed files with 1349 additions and 36 deletions

View file

@ -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"

718
server/src/admin.rs Normal file
View file

@ -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<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?;
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<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> {
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<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,
})
}
}
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())
}

View file

@ -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<bool, Error> {
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<Self, Error> {
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<Uuid>,
profile: Profile,
nav_state: NavState,
state: web::Data<State>,
) -> Result<HttpResponse, Error> {
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<Uuid>,
form: web::Form<ReportForm>,
profile: Profile,
nav_state: NavState,
state: web::Data<State>,
) -> Result<HttpResponse, Error> {
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<Uuid>,
profile: Profile,
nav_state: NavState,
state: web::Data<State>,
) -> Result<HttpResponse, Error> {
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)
})
}

View file

@ -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<hyaenidae_profiles::apub::StoreError> for Error {
Error::Profiles(From::from(e))
}
}
impl From<actix_web::error::BlockingError<Error>> for Error {
fn from(e: actix_web::error::BlockingError<Error>) -> Self {
match e {
actix_web::error::BlockingError::Error(e) => e,
_ => Error::Panic,
}
}
}

View file

@ -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<String>,
#[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,

View file

@ -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::<Profile>::extract(req);
let logout = Option::<LogoutState>::extract(req);
let query = Option::<Query<Vec<(String, String)>>>::extract(req);
let admin = Option::<Admin>::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");

View file

@ -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<Self, Error> {
let inner = state.profiles.store.profiles.by_id(profile_id)?.req()?;
pub(crate) fn from_stores(
profile_id: Uuid,
profiles: &ProfileStore,
files: &FileStore,
) -> Result<Self, Error> {
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, Error> {
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<Uuid>) -> bool {
profile.map(|p| p == self.poster.id()).unwrap_or(false)
}

View file

@ -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<File>,
}
@ -217,21 +217,28 @@ impl Submission {
self.inner.published()
}
pub(crate) fn from_id(submission_id: Uuid, state: &State) -> Result<Self, Error> {
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<Self, Error> {
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, Error> {
Self::from_stores(
submission_id,
&state.profiles.store.submissions,
&state.profiles.store.files,
)
}
}
async fn files_page(_: Profile, nav_state: NavState) -> Result<HttpResponse, Error> {

View file

@ -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)
<div class="profile-box">
@:tkicon(&profile.view_path(), true, dark, {
@if let Some(key) = profile.icon_key() {
@:icon(key, &profile.name())
}
})
<div class="profile-box--content">
<div class="profile-box--all-meta">
<div>
<div class="profile-box--meta">
@if let Some(name) = profile.display_name() {
<div class="profile-box--meta--display">
@:link(&Link::current_tab(&profile.view_path()).plain(true).dark(dark), {
@name
})
</div>
}
<div class="profile-box--meta--handle">
@:link(&Link::current_tab(&profile.view_path()).plain(true).dark(dark), {
@profile.full_handle()
})
</div>
<div class="profile-box--meta--date">
posted @:ago(comment.published(), dark)
</div>
</div>
</div>
</div>
<div class="profile-box--body">@comment.body()</div>
</div>
</div>

View file

@ -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({
<div class="report">
<div class="report-head">
@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()
})
})
}
</div>
@if let Some(note) = report.note() {
<div class="report-description text-section">
<h4>Note:</h4>
<p>@note</p>
</div>
}
<div class="button-section report-actions">
@:button_group(&[
Button::secondary("View").href(&view.view_path(report)).dark(nav_state.dark()),
])
</div>
</div>
})
}
})
})

View file

@ -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({
<h4>Report Content</h4>
<p>@note</p>
})
}
})
@:card(Card::full_width().dark(nav_state.dark()), {
<form method="POST" action="@view.update_path()">
@:card_title({ Actions })
@:card_body({
<div class="text-section">
@:select(view.select())
</div>
@:text_input(view.input())
})
@:card_body({
@:button_group(&[
&Button::primary("Resolve"),
])
})
</form>
})
})

View file

@ -0,0 +1,14 @@
@use crate::admin::{Report, ReportsView};
@use hyaenidae_toolkit::{templates::link, Link};
@(view: &ReportsView, report: &Report, dark: bool, body: Content)
<div class="report-author">
@if let Some(author) = view.reporter_profile(report) {
@:link(Link::new_tab(&author.view_path()).plain(true).dark(dark), {
@author.name()
})
}
@:body()
</div>

View file

@ -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({
<div class="profile-box">
@:tkicon(&profile.view_path(), true, dark, {
@if let Some(key) = profile.icon_key() {
@:icon(key, &profile.name())
}
})
<div class="profile-box--content">
<div class="profile-box--all-meta">
<div>
<div class="profile-box--meta">
@if let Some(name) = profile.display_name() {
<div class="profile-box--meta--display">
@:link(&Link::current_tab(&profile.view_path()).plain(true).dark(dark), {
@name
})
</div>
}
<div class="profile-box--meta--handle">
@:link(&Link::current_tab(&profile.view_path()).plain(true).dark(dark), {
@profile.full_handle()
})
</div>
@if let Some(published) = submission.published() {
<div class="profile-box--meta--date">
posted @:ago(published, dark)
</div>
}
</div>
</div>
</div>
<div class="profile-box--body">
<h3>@submission.title()</h3>
@if let Some(description) = submission.description() {
<p>@description</p>
}
</div>
</div>
</div>
})
@:card_section({
<div class="submission-tiles">
@for tile in tiles {
@:submission_tile(tile, Some(submission.profile_id()), dark)
}
</div>
})

View file

@ -9,19 +9,21 @@
@:nested_node({
@:profile_box(author, comment, replying_to, dark, {
<div class="comment-links">
<div>
@:link(&node.item.link(dark), {
@if logged_in {
reply
} else {
view
}
})
</div>
@if let Some(state) = node.edit_link(dark) {
@if !comment.deleted() {
<div>
@:link(&state, { edit })
@:link(&node.item.link(dark), {
@if logged_in {
reply
} else {
view
}
})
</div>
@if let Some(state) = node.edit_link(dark) {
<div>
@:link(&state, { edit })
</div>
}
}
</div>
}, {

View file

@ -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()),
])
</div>
</form>

View file

@ -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({
<div class="comment">
<div class="comment-body">
@:profile_box(&view.author, &view.comment, &view.parent, nav_state.dark(), {}, {
<div class="comment-text">
@view.comment.body()
</div>
})
</div>
</div>
})
@:card_body({
<form method="POST" action="@view.report_path()">
<h3>Report Comment</h3>
<p>
Please include any relevant information for moderators to act on this report.
</p>
@:text_input(&view.input)
<div class="button-section">
@:button_group(&[
Button::primary("Report").dark(nav_state.dark()),
Button::secondary("Back to Comment").href(&view.comment_path()).dark(nav_state.dark()),
])
</div>
</form>
})
})
})

View file

@ -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()),
])
})
})
})