use crate::{ error::{Error, OptionExt}, extensions::SubmissionExt, middleware::UserProfile, nav::NavState, views::OwnedProfileView, ActixLoader, State, }; use actix_web::{web, HttpResponse, Scope}; use hyaenidae_profiles::store::{Comment, Profile}; use hyaenidae_toolkit::TextInput; use i18n_embed_fl::fl; 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)) } pub struct CommentView { pub(crate) cache: Cache, pub(crate) submission: Uuid, pub(crate) comments: CommentNode, pub(crate) logged_in: bool, input_value: Option, input_error: Option, } impl CommentView { fn new(cache: Cache, submission: Uuid, comments: CommentNode, logged_in: bool) -> Self { CommentView { cache, submission, comments, logged_in, input_value: None, input_error: None, } } 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() } pub(crate) fn input(&self, loader: &ActixLoader) -> TextInput { let input = TextInput::new("body") .placeholder(&fl!(loader, "reply-placeholder")) .textarea() .error_opt(self.input_error.clone()); if let Some(value) = &self.input_value { input.value(value) } else { input } } fn value(mut self, value: &str) -> Self { self.input_value = Some(value.to_owned()); self } fn error_opt(mut self, opt: Option) -> Self { self.input_error = opt; self } } 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 crate::submissions::pagination::can_view( None, &submission, &store.store, &mut Default::default(), true, false, ) .is_none() { 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, can_view_sensitive: bool, 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), }; if crate::submissions::pagination::can_view( Some(profile.id()), &submission, &store.store, &mut Default::default(), true, can_view_sensitive, ) .is_none() { 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>, state: &State, ) -> Result, Error> { let can_view_sensitive = if let Some(profile) = profile { state.settings.for_profile(profile.id()).await?.sensitive } else { false }; match profile { Some(profile) if !can_view(&profile, can_view_sensitive, &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()); Ok(Some(view)) } async fn edit_page( loader: ActixLoader, 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), &state).await? { Some(v) => v.value(&body), None => return Ok(crate::to_404()), }; crate::rendered(HttpResponse::Ok(), |cursor| { crate::templates::comments::edit(cursor, &loader, &view, &nav_state) }) } async fn update_comment( loader: ActixLoader, 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), &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, &loader, &view, &nav_state) }) } async fn view_comment( loader: ActixLoader, 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(), &state).await? { Some(v) => v, None => return Ok(crate::to_404()), }; crate::rendered(HttpResponse::Ok(), |cursor| { crate::templates::comments::public(cursor, &loader, &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( loader: ActixLoader, 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()), }; let can_view_sensitive = state.settings.for_profile(profile.id()).await?.sensitive; if !can_view(&profile, can_view_sensitive, &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), &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, &loader, &view, &nav_state) }) } pub struct ReportView { pub(crate) cache: Cache, pub(crate) comment: Comment, pub(crate) author: Profile, input_value: Option, input_error: Option, } impl ReportView { async fn prepare(comment: Comment, 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); } Ok(ReportView { cache, comment, author, input_value: None, input_error: None, }) 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 input(&self, loader: &ActixLoader) -> TextInput { let input = TextInput::new("body") .title(&fl!(loader, "report-input")) .placeholder(&fl!(loader, "report-placeholder")) .textarea() .error_opt(self.input_error.clone()); if let Some(value) = &self.input_value { input.value(value) } else { input } } fn error_opt(mut self, opt: Option) -> Self { self.input_error = opt; self } fn value_opt(mut self, opt: Option) -> Self { self.input_value = opt; self } } async fn report_page( loader: ActixLoader, 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()), }; let can_view_sensitive = state.settings.for_profile(profile.id()).await?.sensitive; if !can_view(&profile, can_view_sensitive, &comment, &state.profiles)? { return Ok(crate::to_404()); } let view = ReportView::prepare(comment, &state).await?; crate::rendered(HttpResponse::Ok(), |cursor| { crate::templates::comments::report(cursor, &loader, &view, &nav_state) }) } #[derive(Clone, Debug, serde::Deserialize)] struct ReportForm { body: String, } const MAX_REPORT_LEN: usize = 1000; async fn report( loader: ActixLoader, 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()), }; let can_view_sensitive = state.settings.for_profile(profile.id()).await?.sensitive; if !can_view(&profile, can_view_sensitive, &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.clone()), )) .await; match res { Ok(_) => return Ok(to_report_success_page(comment_id)), Err(e) => e.to_string(), } }; let view = ReportView::prepare(comment, &state) .await? .error_opt(Some(error)) .value_opt(Some(form.body)); crate::rendered(HttpResponse::Ok(), |cursor| { crate::templates::comments::report(cursor, &loader, &view, &nav_state) }) } async fn report_success_page( loader: ActixLoader, 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()), }; let can_view_sensitive = state.settings.for_profile(profile.id()).await?.sensitive; if !can_view(&profile, can_view_sensitive, &comment, &state.profiles)? { return Ok(crate::to_404()); } let view = ReportView::prepare(comment, &state).await?; crate::rendered(HttpResponse::Ok(), |cursor| { crate::templates::comments::report_success(cursor, &loader, &view, &nav_state) }) }