2 activities::{verify_is_public, verify_person_in_community},
3 context::lemmy_context,
4 fetcher::object_id::ObjectId,
5 migrations::CommentInReplyToMigration,
6 objects::{create_tombstone, person::ApubPerson, post::ApubPost, Source},
11 chrono::NaiveDateTime,
12 object::{kind::NoteType, Tombstone},
13 primitives::OneOrMany,
17 use anyhow::{anyhow, Context};
18 use chrono::{DateTime, FixedOffset};
19 use html2md::parse_html;
20 use lemmy_api_common::blocking;
22 traits::{ApubObject, FromApub, ToApub},
23 values::{MediaTypeHtml, MediaTypeMarkdown},
24 verify::verify_domains_match,
26 use lemmy_db_schema::{
29 comment::{Comment, CommentForm},
39 utils::{convert_datetime, remove_slurs},
42 use lemmy_websocket::LemmyContext;
43 use serde::{Deserialize, Serialize};
44 use serde_with::skip_serializing_none;
48 #[skip_serializing_none]
49 #[derive(Clone, Debug, Deserialize, Serialize)]
50 #[serde(rename_all = "camelCase")]
52 #[serde(rename = "@context")]
53 context: OneOrMany<AnyBase>,
56 pub(crate) attributed_to: ObjectId<ApubPerson>,
57 /// Indicates that the object is publicly readable. Unlike [`Post.to`], this one doesn't contain
58 /// the community ID, as it would be incompatible with Pleroma (and we can get the community from
59 /// the post in [`in_reply_to`]).
62 media_type: Option<MediaTypeHtml>,
64 in_reply_to: CommentInReplyToMigration,
65 published: Option<DateTime<FixedOffset>>,
66 updated: Option<DateTime<FixedOffset>>,
71 /// Pleroma puts a raw string in the source, so we have to handle it here for deserialization to work
72 #[derive(Clone, Debug, Deserialize, Serialize)]
73 #[serde(rename_all = "camelCase")]
81 pub(crate) fn id_unchecked(&self) -> &Url {
84 pub(crate) fn id(&self, expected_domain: &Url) -> Result<&Url, LemmyError> {
85 verify_domains_match(&self.id, expected_domain)?;
89 pub(crate) async fn get_parents(
91 context: &LemmyContext,
92 request_counter: &mut i32,
93 ) -> Result<(ApubPost, Option<CommentId>), LemmyError> {
94 match &self.in_reply_to {
95 CommentInReplyToMigration::Old(in_reply_to) => {
96 // This post, or the parent comment might not yet exist on this server yet, fetch them.
97 let post_id = in_reply_to.get(0).context(location_info!())?;
98 let post_id = ObjectId::new(post_id.clone());
99 let post = Box::pin(post_id.dereference(context, request_counter)).await?;
101 // The 2nd item, if it exists, is the parent comment apub_id
102 // Nested comments will automatically get fetched recursively
103 let parent_id: Option<CommentId> = match in_reply_to.get(1) {
104 Some(comment_id) => {
105 let comment_id = ObjectId::<ApubComment>::new(comment_id.clone());
106 let parent_comment = Box::pin(comment_id.dereference(context, request_counter)).await?;
108 Some(parent_comment.id)
113 Ok((post, parent_id))
115 CommentInReplyToMigration::New(in_reply_to) => {
116 let parent = Box::pin(in_reply_to.dereference(context, request_counter).await?);
117 match parent.deref() {
118 PostOrComment::Post(p) => {
119 // Workaround because I cant figure out how to get the post out of the box (and we dont
120 // want to stackoverflow in a deep comment hierarchy).
122 let post = blocking(context.pool(), move |conn| Post::read(conn, post_id)).await??;
123 Ok((post.into(), None))
125 PostOrComment::Comment(c) => {
126 let post_id = c.post_id;
127 let post = blocking(context.pool(), move |conn| Post::read(conn, post_id)).await??;
128 Ok((post.into(), Some(c.id)))
135 pub(crate) async fn verify(
137 context: &LemmyContext,
138 request_counter: &mut i32,
139 ) -> Result<(), LemmyError> {
140 let (post, _parent_comment_id) = self.get_parents(context, request_counter).await?;
141 let community_id = post.community_id;
142 let community = blocking(context.pool(), move |conn| {
143 Community::read(conn, community_id)
148 return Err(anyhow!("Post is locked").into());
150 verify_domains_match(self.attributed_to.inner(), &self.id)?;
151 verify_person_in_community(
153 &ObjectId::new(community.actor_id),
158 verify_is_public(&self.to)?;
163 #[derive(Clone, Debug)]
164 pub struct ApubComment(Comment);
166 impl Deref for ApubComment {
167 type Target = Comment;
168 fn deref(&self) -> &Self::Target {
173 impl From<Comment> for ApubComment {
174 fn from(c: Comment) -> Self {
179 #[async_trait::async_trait(?Send)]
180 impl ApubObject for ApubComment {
181 type DataType = LemmyContext;
183 fn last_refreshed_at(&self) -> Option<NaiveDateTime> {
187 async fn read_from_apub_id(
189 context: &LemmyContext,
190 ) -> Result<Option<Self>, LemmyError> {
192 blocking(context.pool(), move |conn| {
193 Comment::read_from_apub_id(conn, object_id)
200 async fn delete(self, context: &LemmyContext) -> Result<(), LemmyError> {
201 blocking(context.pool(), move |conn| {
202 Comment::update_deleted(conn, self.id, true)
209 #[async_trait::async_trait(?Send)]
210 impl ToApub for ApubComment {
211 type ApubType = Note;
212 type TombstoneType = Tombstone;
213 type DataType = DbPool;
215 async fn to_apub(&self, pool: &DbPool) -> Result<Note, LemmyError> {
216 let creator_id = self.creator_id;
217 let creator = blocking(pool, move |conn| Person::read(conn, creator_id)).await??;
219 let post_id = self.post_id;
220 let post = blocking(pool, move |conn| Post::read(conn, post_id)).await??;
222 // Add a vector containing some important info to the "in_reply_to" field
223 // [post_ap_id, Option(parent_comment_ap_id)]
224 let mut in_reply_to_vec = vec![post.ap_id.into_inner()];
226 if let Some(parent_id) = self.parent_id {
227 let parent_comment = blocking(pool, move |conn| Comment::read(conn, parent_id)).await??;
229 in_reply_to_vec.push(parent_comment.ap_id.into_inner());
233 context: lemmy_context(),
234 r#type: NoteType::Note,
235 id: self.ap_id.to_owned().into_inner(),
236 attributed_to: ObjectId::new(creator.actor_id),
238 content: self.content.clone(),
239 media_type: Some(MediaTypeHtml::Html),
240 source: SourceCompat::Lemmy(Source {
241 content: self.content.clone(),
242 media_type: MediaTypeMarkdown::Markdown,
244 in_reply_to: CommentInReplyToMigration::Old(in_reply_to_vec),
245 published: Some(convert_datetime(self.published)),
246 updated: self.updated.map(convert_datetime),
247 unparsed: Default::default(),
253 fn to_tombstone(&self) -> Result<Tombstone, LemmyError> {
256 self.ap_id.to_owned().into(),
263 #[async_trait::async_trait(?Send)]
264 impl FromApub for ApubComment {
265 type ApubType = Note;
266 type DataType = LemmyContext;
268 /// Converts a `Note` to `Comment`.
270 /// If the parent community, post and comment(s) are not known locally, these are also fetched.
273 context: &LemmyContext,
274 expected_domain: &Url,
275 request_counter: &mut i32,
276 ) -> Result<ApubComment, LemmyError> {
277 let ap_id = Some(note.id(expected_domain)?.clone().into());
280 .dereference(context, request_counter)
282 let (post, parent_comment_id) = note.get_parents(context, request_counter).await?;
284 return Err(anyhow!("Post is locked").into());
287 let content = if let SourceCompat::Lemmy(source) = ¬e.source {
288 source.content.clone()
290 parse_html(¬e.content)
292 let content_slurs_removed = remove_slurs(&content, &context.settings().slur_regex());
294 let form = CommentForm {
295 creator_id: creator.id,
297 parent_id: parent_comment_id,
298 content: content_slurs_removed,
301 published: note.published.map(|u| u.to_owned().naive_local()),
302 updated: note.updated.map(|u| u.to_owned().naive_local()),
307 let comment = blocking(context.pool(), move |conn| Comment::upsert(conn, &form)).await??;
315 use crate::objects::{
316 community::ApubCommunity,
317 tests::{file_to_json_object, init_context},
319 use assert_json_diff::assert_json_include;
320 use serial_test::serial;
322 async fn prepare_comment_test(
324 context: &LemmyContext,
325 ) -> (ApubPerson, ApubCommunity, ApubPost) {
326 let person_json = file_to_json_object("assets/lemmy-person.json");
327 let person = ApubPerson::from_apub(&person_json, context, url, &mut 0)
330 let community_json = file_to_json_object("assets/lemmy-community.json");
331 let community = ApubCommunity::from_apub(&community_json, context, url, &mut 0)
334 let post_json = file_to_json_object("assets/lemmy-post.json");
335 let post = ApubPost::from_apub(&post_json, context, url, &mut 0)
338 (person, community, post)
341 fn cleanup(data: (ApubPerson, ApubCommunity, ApubPost), context: &LemmyContext) {
342 Post::delete(&*context.pool().get().unwrap(), data.2.id).unwrap();
343 Community::delete(&*context.pool().get().unwrap(), data.1.id).unwrap();
344 Person::delete(&*context.pool().get().unwrap(), data.0.id).unwrap();
349 async fn test_fetch_lemmy_comment() {
350 let context = init_context();
351 let url = Url::parse("https://lemmy.ml/comment/38741").unwrap();
352 let data = prepare_comment_test(&url, &context).await;
354 let json = file_to_json_object("assets/lemmy-comment.json");
355 let mut request_counter = 0;
356 let comment = ApubComment::from_apub(&json, &context, &url, &mut request_counter)
360 assert_eq!(comment.ap_id.clone().into_inner(), url);
361 assert_eq!(comment.content.len(), 1063);
362 assert!(!comment.local);
363 assert_eq!(request_counter, 0);
365 let to_apub = comment.to_apub(context.pool()).await.unwrap();
366 assert_json_include!(actual: json, expected: to_apub);
368 Comment::delete(&*context.pool().get().unwrap(), comment.id).unwrap();
369 cleanup(data, &context);
374 async fn test_fetch_pleroma_comment() {
375 let context = init_context();
376 let url = Url::parse("https://lemmy.ml/comment/38741").unwrap();
377 let data = prepare_comment_test(&url, &context).await;
380 Url::parse("https://queer.hacktivis.me/objects/8d4973f4-53de-49cd-8c27-df160e16a9c2")
382 let person_json = file_to_json_object("assets/pleroma-person.json");
383 ApubPerson::from_apub(&person_json, &context, &pleroma_url, &mut 0)
386 let json = file_to_json_object("assets/pleroma-comment.json");
387 let mut request_counter = 0;
388 let comment = ApubComment::from_apub(&json, &context, &pleroma_url, &mut request_counter)
392 assert_eq!(comment.ap_id.clone().into_inner(), pleroma_url);
393 assert_eq!(comment.content.len(), 64);
394 assert!(!comment.local);
395 assert_eq!(request_counter, 0);
397 Comment::delete(&*context.pool().get().unwrap(), comment.id).unwrap();
398 cleanup(data, &context);
403 async fn test_html_to_markdown_sanitize() {
404 let parsed = parse_html("<script></script><b>hello</b>");
405 assert_eq!(parsed, "**hello**");