hyaenidae/server/src/comments.rs

730 lines
20 KiB
Rust

use crate::{
error::{Error, OptionExt},
nav::NavState,
profiles::Profile,
submissions::Submission,
State,
};
use actix_web::{web, HttpResponse, Scope};
use hyaenidae_toolkit::{Link, TextInput};
use std::collections::HashMap;
use uuid::Uuid;
pub use hyaenidae_profiles::store::Comment;
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)),
),
)
}
#[derive(Debug)]
pub struct CommentNode {
pub(crate) item: ItemWithAuthor,
pub(crate) is_self: bool,
pub(crate) children: Vec<CommentNode>,
}
impl CommentNode {
pub(crate) fn has_children(&self) -> bool {
!self.children.is_empty()
}
pub(crate) fn edit_link(&self, dark: bool) -> Option<Link> {
if self.is_self {
if let Some((comment, _)) = self.item.comment() {
let mut link = Link::current_tab(&format!("/comments/{}/edit", comment.id()));
link.plain(true).dark(dark);
return Some(link);
}
}
None
}
pub(crate) fn from_submission(
submission_id: Uuid,
viewer: Option<Uuid>,
state: &State,
) -> Result<Self, Error> {
let submission = Submission::from_id(submission_id, state)?;
let author = Profile::from_id(submission.profile_id(), state)?;
let items = state
.profiles
.store
.comments
.for_submission(submission_id)
.filter_map(|comment_id| state.profiles.store.comments.by_id(comment_id).ok()?)
.rev();
Ok(into_nodes(submission, author, viewer, items, state))
}
pub(crate) fn from_root(
comment: Comment,
author: Profile,
self_profile: Option<Uuid>,
state: &State,
) -> Option<Self> {
if let Some(self_id) = self_profile {
if !can_view_comment_no_recurse(self_id, &comment, state).unwrap_or(false) {
return None;
}
} else {
if !can_view_comment_logged_out_no_recurse(&comment, state).unwrap_or(false) {
return None;
}
}
let children = state
.profiles
.store
.comments
.replies_for(comment.id())
.filter_map(|comment_id| state.profiles.store.comments.by_id(comment_id).ok()?)
.filter_map(|comment| {
Some((Profile::from_id(comment.profile_id(), state).ok()?, comment))
})
.filter_map(|(profile, comment)| {
CommentNode::from_root(comment, profile, self_profile, state)
})
.collect();
let is_self = self_profile.map(|id| id == author.id()).unwrap_or(false);
Some(CommentNode {
item: ItemWithAuthor {
parent: ItemWithAuthorInner::Comment(comment),
author,
},
is_self,
children,
})
}
fn insert(
&mut self,
mut indexes: Vec<usize>,
comment: Comment,
author: Profile,
is_self: bool,
) -> Option<Vec<usize>> {
let comment_id = comment.id();
if let Some(index) = indexes.pop() {
if let Some(node) = self.children.get_mut(index) {
if let Some(mut v) = node.insert(indexes, comment, author, is_self) {
v.push(index);
return Some(v);
} else {
log::warn!("Failed to insert comment {}", comment_id);
}
} else {
match &self.item.parent {
ItemWithAuthorInner::Comment(ref c) => log::error!(
"Error inserting {}: Failed to get child {} of comment {}",
comment_id,
index,
c.id(),
),
ItemWithAuthorInner::Submission(ref s) => log::error!(
"Error inserting {}: Failed to get child {} of submission {}",
comment_id,
index,
s.id()
),
}
}
} else {
let index = self.children.len();
self.children.push(CommentNode {
item: ItemWithAuthor {
parent: ItemWithAuthorInner::Comment(comment),
author,
},
is_self,
children: vec![],
});
return Some(vec![index]);
}
None
}
}
// Assumes comments are in-order by publish date
fn into_nodes(
submission: Submission,
author: Profile,
viewer: Option<Uuid>,
items: impl IntoIterator<Item = Comment>,
state: &State,
) -> CommentNode {
let is_self = viewer.map(|id| id == author.id()).unwrap_or(false);
let mut hashmap: HashMap<Uuid, Vec<usize>> = HashMap::new();
let mut node = CommentNode {
item: ItemWithAuthor {
parent: ItemWithAuthorInner::Submission(submission),
author,
},
is_self,
children: vec![],
};
for comment in items {
if let Some(viewer) = viewer {
if !can_view_comment_no_recurse(viewer, &comment, state).unwrap_or(false) {
continue;
}
} else {
if !can_view_comment_logged_out_no_recurse(&comment, state).unwrap_or(false) {
continue;
}
}
let comment_id = comment.id();
if let Ok(author) = Profile::from_id(comment.profile_id(), state) {
let is_self = viewer.map(|id| id == author.id()).unwrap_or(false);
if let Some(reply_to) = comment.comment_id() {
if let Some(indexes) = hashmap.get(&reply_to) {
if let Some(indexes) = node.insert(indexes.clone(), comment, author, is_self) {
hashmap.insert(comment_id, indexes);
} else {
log::warn!("Failed to insert nested comment {}", comment_id);
}
} else {
log::warn!("Reply To {} doesn't exist", reply_to);
}
} else {
if let Some(indexes) = node.insert(vec![], comment, author, is_self) {
hashmap.insert(comment_id, indexes);
} else {
log::warn!("Failed to insert top-level comment {}", comment_id);
}
}
} else {
log::warn!("Failed to get author for comment {}", comment_id);
}
}
node
}
fn to_comment_page(comment_id: Uuid) -> HttpResponse {
crate::redirect(&format!("/comments/{}", comment_id))
}
#[derive(Debug)]
enum ItemWithAuthorInner {
Comment(Comment),
Submission(Submission),
}
#[derive(Debug)]
pub struct ItemWithAuthor {
parent: ItemWithAuthorInner,
author: Profile,
}
impl ItemWithAuthor {
pub(crate) fn from_comment(comment: Comment, author: Profile) -> Self {
ItemWithAuthor {
parent: ItemWithAuthorInner::Comment(comment),
author,
}
}
pub(crate) fn from_submission(submission: Submission, author: Profile) -> Self {
ItemWithAuthor {
parent: ItemWithAuthorInner::Submission(submission),
author,
}
}
pub(crate) fn comment(&self) -> Option<(&Comment, &Profile)> {
match self.parent {
ItemWithAuthorInner::Comment(ref c) => Some((c, &self.author)),
_ => None,
}
}
pub(crate) fn name(&self) -> String {
self.author.name()
}
pub(crate) fn link(&self, dark: bool) -> Link {
let mut link = match &self.parent {
ItemWithAuthorInner::Comment(ref comment) => Link::current_tab(&comment_path(comment)),
ItemWithAuthorInner::Submission(ref submission) => {
Link::current_tab(&submission.view_path())
}
};
link.plain(true).dark(dark);
link
}
}
fn comment_input(dark: bool) -> TextInput {
let mut input = TextInput::new("body");
input
.placeholder("Reply to this comment")
.textarea()
.dark(dark);
input
}
pub struct CommentView {
pub(crate) replying_to: ItemWithAuthor,
pub(crate) submission: Submission,
pub(crate) comments: CommentNode,
pub(crate) input: TextInput,
pub(crate) logged_in: bool,
}
impl CommentView {
fn new(
submission: Submission,
replying_to: ItemWithAuthor,
comments: CommentNode,
logged_in: bool,
dark: bool,
) -> Self {
CommentView {
replying_to,
submission,
comments,
input: comment_input(dark),
logged_in,
}
}
pub(crate) fn submission_path(&self) -> String {
self.submission.view_path()
}
}
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())
}
fn comment_path(comment: &Comment) -> String {
format!("/comments/{}", comment.id())
}
fn can_view_logged_out(comment: &Comment, state: &State) -> Result<bool, Error> {
let submission = match state
.profiles
.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 state
.profiles
.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, state)
}
fn can_view_comment_logged_out(comment: &Comment, state: &State) -> Result<bool, Error> {
if can_view_comment_logged_out_no_recurse(comment, state)? {
if let Some(reply_to_id) = comment.comment_id() {
let new_comment = match state.profiles.store.comments.by_id(reply_to_id)? {
Some(comment) => comment,
None => return Ok(false),
};
return can_view_logged_out(&new_comment, state);
}
return Ok(true);
}
Ok(false)
}
fn can_view_comment_logged_out_no_recurse(comment: &Comment, state: &State) -> Result<bool, Error> {
let commenter = match state.profiles.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, state: &State) -> Result<bool, Error> {
let submission = match state
.profiles
.store
.submissions
.by_id(comment.submission_id())?
{
Some(s) => s,
None => return Ok(false),
};
let submissioner_id = submission.profile_id();
let blocking_submissioner = state
.profiles
.store
.view
.blocks
.by_forward(submissioner_id, profile.id())?
.is_some();
if blocking_submissioner {
return Ok(false);
}
let blocked_by_submissioner = state
.profiles
.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 = state
.profiles
.store
.view
.follows
.by_forward(submissioner_id, profile.id())?
.is_some();
if !follows_submissioner {
return Ok(false);
}
}
}
can_view_comment(profile, comment, state)
}
fn can_view_comment(profile: &Profile, comment: &Comment, state: &State) -> Result<bool, Error> {
if can_view_comment_no_recurse(profile.id(), comment, state)? {
if let Some(reply_to_id) = comment.comment_id() {
let new_comment = match state.profiles.store.comments.by_id(reply_to_id)? {
Some(c) => c,
None => return Ok(false),
};
return can_view_comment(profile, &new_comment, state);
}
return Ok(true);
}
Ok(false)
}
fn can_view_comment_no_recurse(
profile_id: Uuid,
comment: &Comment,
state: &State,
) -> Result<bool, Error> {
let blocking_commenter = state
.profiles
.store
.view
.blocks
.by_forward(comment.profile_id(), profile_id)?
.is_some();
if blocking_commenter {
return Ok(false);
}
let blocked_by_commenter = state
.profiles
.store
.view
.blocks
.by_forward(profile_id, comment.profile_id())?
.is_some();
if blocked_by_commenter {
return Ok(false);
}
Ok(true)
}
fn prepare_view(
comment: Comment,
profile: Option<&Profile>,
nav_state: &NavState,
state: &State,
) -> Result<Option<CommentView>, Error> {
match profile {
Some(profile) if !can_view(&profile, &comment, &state)? => {
return Ok(None);
}
None if !can_view_logged_out(&comment, &state)? => {
return Ok(None);
}
_ => (),
}
let submission = Submission::from_id(comment.submission_id(), &state)?;
let replying_to = if let Some(comment_id) = comment.comment_id() {
let comment = state.profiles.store.comments.by_id(comment_id)?.req()?;
let author = Profile::from_id(comment.profile_id(), &state)?;
ItemWithAuthor::from_comment(comment, author)
} else {
let author = Profile::from_id(submission.profile_id(), &state)?;
ItemWithAuthor::from_submission(submission.clone(), author)
};
let author = Profile::from_id(comment.profile_id(), &state)?;
let node =
match CommentNode::from_root(comment, author, profile.as_ref().map(|p| p.id()), &state) {
Some(node) => node,
None => return Ok(None),
};
let view = CommentView::new(
submission,
replying_to,
node,
profile.is_some(),
nav_state.dark(),
);
Ok(Some(view))
}
async fn edit_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 comment.profile_id() != profile.id() {
return Ok(crate::to_404());
}
let body = comment.body().to_owned();
let mut view = match prepare_view(comment, Some(&profile), &nav_state, &state)? {
Some(v) => v,
None => return Ok(crate::to_404()),
};
view.input.value(&body);
crate::rendered(HttpResponse::Ok(), |cursor| {
crate::templates::comments::edit(cursor, &view, &nav_state)
})
}
async fn update_comment(
comment_id: web::Path<Uuid>,
form: web::Form<CommentForm>,
profile: Profile,
nav_state: NavState,
state: web::Data<State>,
) -> Result<HttpResponse, Error> {
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.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().to_owned();
let mut view = match prepare_view(comment, Some(&profile), &nav_state, &state)? {
Some(v) => v,
None => return Ok(crate::to_404()),
};
view.input.value(&body);
view.input.error_opt(Some(error));
crate::rendered(HttpResponse::BadRequest(), |cursor| {
crate::templates::comments::edit(cursor, &view, &nav_state)
})
}
async fn view_comment(
comment_id: web::Path<Uuid>,
profile: Option<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()),
};
let view = match prepare_view(comment, profile.as_ref(), &nav_state, &state)? {
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<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(
form: web::Form<CommentForm>,
comment_id: web::Path<Uuid>,
profile: Profile,
nav_state: NavState,
state: web::Data<State>,
) -> Result<HttpResponse, Error> {
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)? {
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 mut view = match prepare_view(comment, Some(&profile), &nav_state, &state)? {
Some(v) => v,
None => return Ok(crate::to_404()),
};
view.input.error_opt(Some(error));
crate::rendered(HttpResponse::BadRequest(), |cursor| {
crate::templates::comments::public(cursor, &view, &nav_state)
})
}