730 lines
20 KiB
Rust
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)
|
|
})
|
|
}
|