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:
parent
464429747d
commit
3f446a0b16
|
@ -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
718
server/src/admin.rs
Normal 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())
|
||||
}
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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");
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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> {
|
||||
|
|
40
server/templates/admin/comment_box.rs.html
Normal file
40
server/templates/admin/comment_box.rs.html
Normal 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>
|
||||
|
69
server/templates/admin/index.rs.html
Normal file
69
server/templates/admin/index.rs.html
Normal 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>
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
59
server/templates/admin/report.rs.html
Normal file
59
server/templates/admin/report.rs.html
Normal 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>
|
||||
})
|
||||
})
|
14
server/templates/admin/reporter.rs.html
Normal file
14
server/templates/admin/reporter.rs.html
Normal 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>
|
56
server/templates/admin/submission_box.rs.html
Normal file
56
server/templates/admin/submission_box.rs.html
Normal 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>
|
||||
})
|
|
@ -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>
|
||||
}, {
|
||||
|
|
|
@ -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>
|
||||
|
|
41
server/templates/comments/report.rs.html
Normal file
41
server/templates/comments/report.rs.html
Normal 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>
|
||||
})
|
||||
})
|
||||
})
|
18
server/templates/comments/report_success.rs.html
Normal file
18
server/templates/comments/report_success.rs.html
Normal 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()),
|
||||
])
|
||||
})
|
||||
})
|
||||
})
|
Loading…
Reference in a new issue