use crate::{ error::{Error, OptionExt}, extensions::SubmissionExt, middleware::UserProfile, nav::NavState, views::OwnedProfileView, State, }; use actix_web::{web, HttpResponse, Scope}; use hyaenidae_profiles::store::{Comment, Profile}; use hyaenidae_toolkit::TextInput; use uuid::Uuid; mod node; pub(crate) use node::{Cache, CommentNode, Item}; pub(crate) fn scope() -> Scope { web::scope("/comments").service( web::scope("/{comment_id}") .route("", web::get().to(view_comment)) .service( web::resource("/edit") .route(web::get().to(edit_page)) .route(web::post().to(update_comment)), ) .service( 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)), ) } 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)) } fn comment_input(dark: bool) -> TextInput { TextInput::new("body") .placeholder("Reply to this comment") .textarea() .dark(dark) } pub struct CommentView { pub(crate) cache: Cache, pub(crate) submission: Uuid, pub(crate) comments: CommentNode, pub(crate) input: TextInput, pub(crate) logged_in: bool, } impl CommentView { fn new( cache: Cache, submission: Uuid, comments: CommentNode, logged_in: bool, dark: bool, ) -> Self { CommentView { cache, submission, comments, input: comment_input(dark), logged_in, } } pub(crate) fn author(&self) -> OwnedProfileView { let profile = self .cache .profiles .get(&self.comments.author_id) .unwrap() .clone(); let icon = profile .icon() .and_then(|i| self.cache.files.get(&i)) .map(|f| f.clone()); let banner = profile .banner() .and_then(|b| self.cache.files.get(&b)) .map(|b| b.clone()); OwnedProfileView { profile, icon, banner, } } pub(crate) fn parent(&self) -> CommentNode { let comment_id = match self.comments.item { Item::Comment(id) => id, _ => unimplemented!(), }; let comment = self.cache.comments.get(&comment_id).unwrap(); if let Some(reply_to_id) = comment.comment_id() { let comment = self.cache.comments.get(&reply_to_id).unwrap(); CommentNode { item: Item::Comment(comment.id()), author_id: comment.profile_id(), is_self: self.comments.author_id == comment.profile_id(), children: vec![], } } else { let submission = self .cache .submissions .get(&comment.submission_id()) .unwrap(); CommentNode { item: Item::Submission(submission.id()), author_id: submission.profile_id(), is_self: self.comments.author_id == submission.profile_id(), children: vec![], } } } pub(crate) fn submission_path(&self) -> String { let submission = self.cache.submissions.get(&self.submission).unwrap(); submission.view_path() } fn value(self, value: &str) -> Self { CommentView { input: self.input.value(value), ..self } } fn error_opt(self, opt: Option) -> Self { CommentView { input: self.input.error_opt(opt), ..self } } } pub(crate) fn reply_path(comment: &Comment) -> String { format!("/comments/{}/reply", comment.id()) } pub(crate) fn update_path(comment: &Comment) -> String { format!("/comments/{}/edit", comment.id()) } 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, store: &hyaenidae_profiles::State, ) -> Result { let submission = match store.store.submissions.by_id(comment.submission_id())? { Some(submission) => submission, None => return Ok(false), }; if submission.is_followers_only() { return Ok(false); } let submissioner = match store.store.profiles.by_id(submission.profile_id())? { Some(s) => s, None => return Ok(false), }; if submissioner.login_required() { return Ok(false); } can_view_comment_logged_out(comment, store) } fn can_view_comment_logged_out( comment: &Comment, store: &hyaenidae_profiles::State, ) -> Result { if can_view_comment_logged_out_no_recurse(comment, store)? { if let Some(reply_to_id) = comment.comment_id() { let new_comment = match store.store.comments.by_id(reply_to_id)? { Some(comment) => comment, None => return Ok(false), }; return can_view_logged_out(&new_comment, store); } return Ok(true); } Ok(false) } fn can_view_comment_logged_out_no_recurse( comment: &Comment, store: &hyaenidae_profiles::State, ) -> Result { let commenter = match store.store.profiles.by_id(comment.profile_id())? { Some(c) => c, None => return Ok(false), }; if commenter.login_required() { return Ok(false); } Ok(true) } fn can_view( profile: &Profile, comment: &Comment, store: &hyaenidae_profiles::State, ) -> Result { let submission = match store.store.submissions.by_id(comment.submission_id())? { Some(s) => s, None => return Ok(false), }; let submissioner_id = submission.profile_id(); let blocking_submissioner = store .store .view .blocks .by_forward(submissioner_id, profile.id())? .is_some(); if blocking_submissioner { return Ok(false); } let blocked_by_submissioner = store .store .view .blocks .by_forward(profile.id(), submissioner_id)? .is_some(); if blocked_by_submissioner { return Ok(false); } if submission.is_followers_only() { let is_submissioner = profile.id() == submissioner_id; if !is_submissioner { let follows_submissioner = store .store .view .follows .by_forward(submissioner_id, profile.id())? .is_some(); if !follows_submissioner { return Ok(false); } } } can_view_comment(profile, comment, store) } fn can_view_comment( profile: &Profile, comment: &Comment, store: &hyaenidae_profiles::State, ) -> Result { if can_view_comment_no_recurse(profile.id(), comment, store)? { if let Some(reply_to_id) = comment.comment_id() { let new_comment = match store.store.comments.by_id(reply_to_id)? { Some(c) => c, None => return Ok(false), }; return can_view_comment(profile, &new_comment, store); } return Ok(true); } Ok(false) } fn can_view_comment_no_recurse( profile_id: Uuid, comment: &Comment, store: &hyaenidae_profiles::State, ) -> Result { let blocking_commenter = store .store .view .blocks .by_forward(comment.profile_id(), profile_id)? .is_some(); if blocking_commenter { return Ok(false); } let blocked_by_commenter = store .store .view .blocks .by_forward(profile_id, comment.profile_id())? .is_some(); if blocked_by_commenter { return Ok(false); } Ok(true) } async fn prepare_view( comment: Comment, profile: Option<&Profile>, nav_state: &NavState, state: &State, ) -> Result, Error> { match profile { Some(profile) if !can_view(&profile, &comment, &state.profiles)? => { return Ok(None); } None if !can_view_logged_out(&comment, &state.profiles)? => { return Ok(None); } _ => (), } let mut cache = Cache::new(); let store = state.profiles.clone(); let submission_id = comment.submission_id(); let reply_to_id = comment.comment_id(); let author_id = comment.profile_id(); let (cache, author) = web::block(move || { let submission = store.store.submissions.by_id(submission_id)?.req()?; let submission_author_id = submission.profile_id(); cache.submissions.insert(submission.id(), submission); if let Some(comment_id) = reply_to_id { let comment = store.store.comments.by_id(comment_id)?.req()?; let author = store.store.profiles.by_id(comment.profile_id())?.req()?; cache.comments.insert(comment.id(), comment); cache.profiles.insert(author.id(), author); } else { let author = store.store.profiles.by_id(submission_author_id)?.req()?; cache.profiles.insert(author.id(), author); } let author = store.store.profiles.by_id(author_id)?.req()?; Ok((cache, author)) as Result<_, Error> }) .await?; let submission = comment.submission_id(); let (node, cache) = match CommentNode::from_root( comment, author, profile.as_ref().map(|p| p.id()), cache, &state, ) .await { Ok(node) => node, _ => return Ok(None), }; let view = CommentView::new(cache, submission, node, profile.is_some(), nav_state.dark()); Ok(Some(view)) } async fn edit_page( comment_id: web::Path, profile: UserProfile, nav_state: NavState, state: web::Data, ) -> Result { let profile = profile.0; let comment = match state .profiles .store .comments .by_id(comment_id.into_inner())? { Some(comment) => 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()); } let body = comment.body_source().unwrap_or(comment.body()).to_owned(); let view = match prepare_view(comment, Some(&profile), &nav_state, &state).await? { Some(v) => v.value(&body), None => return Ok(crate::to_404()), }; crate::rendered(HttpResponse::Ok(), |cursor| { crate::templates::comments::edit(cursor, &view, &nav_state) }) } async fn update_comment( comment_id: web::Path, form: web::Form, profile: UserProfile, nav_state: NavState, state: web::Data, ) -> Result { let profile = profile.0; use hyaenidae_profiles::apub::actions::UpdateComment; let comment = match state .profiles .store .comments .by_id(comment_id.into_inner())? { Some(comment) => 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()); } let form = form.into_inner(); let error = if form.body.trim().is_empty() { "Must be present".to_owned() } else if form.body.len() > MAX_COMMENT_LEN { format!("Must be shorter than {} characters", MAX_COMMENT_LEN) } else { let res = state .profiles .run(UpdateComment::from_text( comment.id(), form.body.trim().to_owned(), )) .await; match res { Ok(_) => return Ok(to_comment_page(comment.id())), Err(e) => e.to_string(), } }; let body = comment.body_source().unwrap_or(comment.body()).to_owned(); let view = match prepare_view(comment, Some(&profile), &nav_state, &state).await? { Some(v) => v.value(&body).error_opt(Some(error)), None => return Ok(crate::to_404()), }; crate::rendered(HttpResponse::BadRequest(), |cursor| { crate::templates::comments::edit(cursor, &view, &nav_state) }) } async fn view_comment( comment_id: web::Path, profile: Option, nav_state: NavState, state: web::Data, ) -> Result { let profile = profile.map(|p| p.0); let comment = match state .profiles .store .comments .by_id(comment_id.into_inner())? { Some(comment) => 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).await? { Some(v) => v, None => return Ok(crate::to_404()), }; crate::rendered(HttpResponse::Ok(), |cursor| { crate::templates::comments::public(cursor, &view, &nav_state) }) } async fn route_to_comment_page(comment_id: web::Path) -> HttpResponse { to_comment_page(comment_id.into_inner()) } #[derive(Clone, Debug, serde::Deserialize)] struct CommentForm { body: String, } const MAX_COMMENT_LEN: usize = 500; async fn reply( form: web::Form, comment_id: web::Path, profile: UserProfile, nav_state: NavState, state: web::Data, ) -> Result { let profile = profile.0; use hyaenidae_profiles::apub::actions::CreateComment; 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.profiles)? { return Ok(crate::to_404()); } let form = form.into_inner(); let error = if form.body.trim().is_empty() { "Must be present".to_owned() } else if form.body.len() > MAX_COMMENT_LEN { format!("Must be shorter than {} characters", MAX_COMMENT_LEN) } else { let res = state .profiles .run(CreateComment::from_text( comment.submission_id(), profile.id(), Some(comment.id()), form.body.trim().to_owned(), )) .await; match res { Ok(_) => return Ok(to_comment_page(comment.id())), Err(e) => e.to_string(), } }; let view = match prepare_view(comment, Some(&profile), &nav_state, &state).await? { Some(v) => v.error_opt(Some(error)), None => return Ok(crate::to_404()), }; crate::rendered(HttpResponse::BadRequest(), |cursor| { crate::templates::comments::public(cursor, &view, &nav_state) }) } pub struct ReportView { pub(crate) cache: Cache, pub(crate) comment: Comment, pub(crate) author: Profile, pub(crate) input: TextInput, } impl ReportView { async fn prepare(comment: Comment, dark: bool, state: &State) -> Result { let store = state.profiles.clone(); let view = web::block(move || { let mut cache = Cache::new(); let author = store.store.profiles.by_id(comment.profile_id())?.req()?; if let Some(file_id) = author.icon() { if !cache.files.contains_key(&file_id) { let file = store.store.files.by_id(file_id)?.req()?; cache.files.insert(file.id(), file); } } if let Some(comment_id) = comment.comment_id() { let parent_comment = store.store.comments.by_id(comment_id)?.req()?; let parent_author = store .store .profiles .by_id(parent_comment.profile_id())? .req()?; cache.comments.insert(parent_comment.id(), parent_comment); cache.profiles.insert(parent_author.id(), parent_author); } else { let parent_submission = store .store .submissions .by_id(comment.submission_id())? .req()?; let parent_author = store .store .profiles .by_id(parent_submission.profile_id())? .req()?; cache .submissions .insert(parent_submission.id(), parent_submission); cache.profiles.insert(parent_author.id(), parent_author); } let input = TextInput::new("body") .title("Report") .placeholder("Type your report info here") .textarea() .dark(dark); Ok(ReportView { cache, comment, author, input, }) as Result<_, Error> }) .await?; Ok(view) } pub(crate) fn author(&self) -> OwnedProfileView { OwnedProfileView { profile: self.author.clone(), icon: self .author .icon() .and_then(|icon| self.cache.files.get(&icon)) .map(|icon| icon.clone()), banner: self .author .banner() .and_then(|banner| self.cache.files.get(&banner)) .map(|banner| banner.clone()), } } pub(crate) fn parent(&self) -> CommentNode { if let Some(reply_to_id) = self.comment.comment_id() { let comment = self.cache.comments.get(&reply_to_id).unwrap(); CommentNode { item: Item::Comment(comment.id()), author_id: comment.profile_id(), is_self: self.comment.profile_id() == comment.profile_id(), children: vec![], } } else { let submission = self .cache .submissions .get(&self.comment.submission_id()) .unwrap(); CommentNode { item: Item::Submission(submission.id()), author_id: submission.profile_id(), is_self: self.comment.profile_id() == submission.profile_id(), children: vec![], } } } pub(crate) fn comment_path(&self) -> String { comment_path(&self.comment) } pub(crate) fn report_path(&self) -> String { report_path(&self.comment) } fn error_opt(self, opt: Option) -> Self { ReportView { input: self.input.error_opt(opt), ..self } } } async fn report_page( comment_id: web::Path, profile: UserProfile, nav_state: NavState, state: web::Data, ) -> Result { let profile = profile.0; 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.profiles)? { return Ok(crate::to_404()); } let view = ReportView::prepare(comment, nav_state.dark(), &state).await?; crate::rendered(HttpResponse::Ok(), |cursor| { crate::templates::comments::report(cursor, &view, &nav_state) }) } #[derive(Clone, Debug, serde::Deserialize)] struct ReportForm { body: String, } const MAX_REPORT_LEN: usize = 1000; async fn report( comment_id: web::Path, form: web::Form, profile: UserProfile, nav_state: NavState, state: web::Data, ) -> Result { let profile = profile.0; 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.profiles)? { 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 view = ReportView::prepare(comment, nav_state.dark(), &state) .await? .error_opt(Some(error)); crate::rendered(HttpResponse::Ok(), |cursor| { crate::templates::comments::report(cursor, &view, &nav_state) }) } async fn report_success_page( comment_id: web::Path, profile: UserProfile, nav_state: NavState, state: web::Data, ) -> Result { let profile = profile.0; 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.profiles)? { 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) }) }