hyaenidae/src/comments/mod.rs
asonix 010dd2952f Server: Expose NSFW toggle, Dark Mode toggle
Ensure all submission view permission logic is the same
2021-02-03 21:09:25 -06:00

828 lines
22 KiB
Rust

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<String>,
input_error: Option<String>,
}
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<String>) -> Self {
self.input_error = opt;
self
}
}
fn can_view_logged_out(
comment: &Comment,
store: &hyaenidae_profiles::State,
) -> Result<bool, Error> {
let submission = match store.store.submissions.by_id(comment.submission_id())? {
Some(submission) => submission,
None => return Ok(false),
};
if crate::pagination::submission::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<bool, Error> {
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<bool, Error> {
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<bool, Error> {
let submission = match store.store.submissions.by_id(comment.submission_id())? {
Some(s) => s,
None => return Ok(false),
};
if crate::pagination::submission::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<bool, Error> {
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<bool, Error> {
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<Option<CommentView>, 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<Uuid>,
profile: UserProfile,
nav_state: NavState,
state: web::Data<State>,
) -> Result<HttpResponse, Error> {
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<Uuid>,
form: web::Form<CommentForm>,
profile: UserProfile,
nav_state: NavState,
state: web::Data<State>,
) -> Result<HttpResponse, Error> {
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<Uuid>,
profile: Option<UserProfile>,
nav_state: NavState,
state: web::Data<State>,
) -> Result<HttpResponse, Error> {
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<Uuid>) -> 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<CommentForm>,
comment_id: web::Path<Uuid>,
profile: UserProfile,
nav_state: NavState,
state: web::Data<State>,
) -> Result<HttpResponse, Error> {
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<String>,
input_error: Option<String>,
}
impl ReportView {
async fn prepare(comment: Comment, state: &State) -> Result<Self, Error> {
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<String>) -> Self {
self.input_error = opt;
self
}
fn value_opt(mut self, opt: Option<String>) -> Self {
self.input_value = opt;
self
}
}
async fn report_page(
loader: ActixLoader,
comment_id: web::Path<Uuid>,
profile: UserProfile,
nav_state: NavState,
state: web::Data<State>,
) -> Result<HttpResponse, Error> {
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<Uuid>,
form: web::Form<ReportForm>,
profile: UserProfile,
nav_state: NavState,
state: web::Data<State>,
) -> Result<HttpResponse, Error> {
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<Uuid>,
profile: UserProfile,
nav_state: NavState,
state: web::Data<State>,
) -> Result<HttpResponse, Error> {
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)
})
}