]> Untitled Git - lemmy.git/blob - crates/apub/src/objects/comment.rs
Pleroma federation2 (#1855)
[lemmy.git] / crates / apub / src / objects / comment.rs
1 use crate::{
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},
7   PostOrComment,
8 };
9 use activitystreams::{
10   base::AnyBase,
11   chrono::NaiveDateTime,
12   object::{kind::NoteType, Tombstone},
13   primitives::OneOrMany,
14   public,
15   unparsed::Unparsed,
16 };
17 use anyhow::{anyhow, Context};
18 use chrono::{DateTime, FixedOffset};
19 use html2md::parse_html;
20 use lemmy_api_common::blocking;
21 use lemmy_apub_lib::{
22   traits::{ApubObject, FromApub, ToApub},
23   values::{MediaTypeHtml, MediaTypeMarkdown},
24   verify::verify_domains_match,
25 };
26 use lemmy_db_schema::{
27   newtypes::CommentId,
28   source::{
29     comment::{Comment, CommentForm},
30     community::Community,
31     person::Person,
32     post::Post,
33   },
34   traits::Crud,
35   DbPool,
36 };
37 use lemmy_utils::{
38   location_info,
39   utils::{convert_datetime, remove_slurs},
40   LemmyError,
41 };
42 use lemmy_websocket::LemmyContext;
43 use serde::{Deserialize, Serialize};
44 use serde_with::skip_serializing_none;
45 use std::ops::Deref;
46 use url::Url;
47
48 #[skip_serializing_none]
49 #[derive(Clone, Debug, Deserialize, Serialize)]
50 #[serde(rename_all = "camelCase")]
51 pub struct Note {
52   #[serde(rename = "@context")]
53   context: OneOrMany<AnyBase>,
54   r#type: NoteType,
55   id: Url,
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`]).
60   to: Vec<Url>,
61   content: String,
62   media_type: Option<MediaTypeHtml>,
63   source: SourceCompat,
64   in_reply_to: CommentInReplyToMigration,
65   published: Option<DateTime<FixedOffset>>,
66   updated: Option<DateTime<FixedOffset>>,
67   #[serde(flatten)]
68   unparsed: Unparsed,
69 }
70
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")]
74 #[serde(untagged)]
75 enum SourceCompat {
76   Lemmy(Source),
77   Pleroma(String),
78 }
79
80 impl Note {
81   pub(crate) fn id_unchecked(&self) -> &Url {
82     &self.id
83   }
84   pub(crate) fn id(&self, expected_domain: &Url) -> Result<&Url, LemmyError> {
85     verify_domains_match(&self.id, expected_domain)?;
86     Ok(&self.id)
87   }
88
89   pub(crate) async fn get_parents(
90     &self,
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?;
100
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?;
107
108             Some(parent_comment.id)
109           }
110           None => None,
111         };
112
113         Ok((post, parent_id))
114       }
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).
121             let post_id = p.id;
122             let post = blocking(context.pool(), move |conn| Post::read(conn, post_id)).await??;
123             Ok((post.into(), None))
124           }
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)))
129           }
130         }
131       }
132     }
133   }
134
135   pub(crate) async fn verify(
136     &self,
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)
144     })
145     .await??;
146
147     if post.locked {
148       return Err(anyhow!("Post is locked").into());
149     }
150     verify_domains_match(self.attributed_to.inner(), &self.id)?;
151     verify_person_in_community(
152       &self.attributed_to,
153       &ObjectId::new(community.actor_id),
154       context,
155       request_counter,
156     )
157     .await?;
158     verify_is_public(&self.to)?;
159     Ok(())
160   }
161 }
162
163 #[derive(Clone, Debug)]
164 pub struct ApubComment(Comment);
165
166 impl Deref for ApubComment {
167   type Target = Comment;
168   fn deref(&self) -> &Self::Target {
169     &self.0
170   }
171 }
172
173 impl From<Comment> for ApubComment {
174   fn from(c: Comment) -> Self {
175     ApubComment { 0: c }
176   }
177 }
178
179 #[async_trait::async_trait(?Send)]
180 impl ApubObject for ApubComment {
181   type DataType = LemmyContext;
182
183   fn last_refreshed_at(&self) -> Option<NaiveDateTime> {
184     None
185   }
186
187   async fn read_from_apub_id(
188     object_id: Url,
189     context: &LemmyContext,
190   ) -> Result<Option<Self>, LemmyError> {
191     Ok(
192       blocking(context.pool(), move |conn| {
193         Comment::read_from_apub_id(conn, object_id)
194       })
195       .await??
196       .map(Into::into),
197     )
198   }
199
200   async fn delete(self, context: &LemmyContext) -> Result<(), LemmyError> {
201     blocking(context.pool(), move |conn| {
202       Comment::update_deleted(conn, self.id, true)
203     })
204     .await??;
205     Ok(())
206   }
207 }
208
209 #[async_trait::async_trait(?Send)]
210 impl ToApub for ApubComment {
211   type ApubType = Note;
212   type TombstoneType = Tombstone;
213   type DataType = DbPool;
214
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??;
218
219     let post_id = self.post_id;
220     let post = blocking(pool, move |conn| Post::read(conn, post_id)).await??;
221
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()];
225
226     if let Some(parent_id) = self.parent_id {
227       let parent_comment = blocking(pool, move |conn| Comment::read(conn, parent_id)).await??;
228
229       in_reply_to_vec.push(parent_comment.ap_id.into_inner());
230     }
231
232     let note = Note {
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),
237       to: vec![public()],
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,
243       }),
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(),
248     };
249
250     Ok(note)
251   }
252
253   fn to_tombstone(&self) -> Result<Tombstone, LemmyError> {
254     create_tombstone(
255       self.deleted,
256       self.ap_id.to_owned().into(),
257       self.updated,
258       NoteType::Note,
259     )
260   }
261 }
262
263 #[async_trait::async_trait(?Send)]
264 impl FromApub for ApubComment {
265   type ApubType = Note;
266   type DataType = LemmyContext;
267
268   /// Converts a `Note` to `Comment`.
269   ///
270   /// If the parent community, post and comment(s) are not known locally, these are also fetched.
271   async fn from_apub(
272     note: &Note,
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());
278     let creator = note
279       .attributed_to
280       .dereference(context, request_counter)
281       .await?;
282     let (post, parent_comment_id) = note.get_parents(context, request_counter).await?;
283     if post.locked {
284       return Err(anyhow!("Post is locked").into());
285     }
286
287     let content = if let SourceCompat::Lemmy(source) = &note.source {
288       source.content.clone()
289     } else {
290       parse_html(&note.content)
291     };
292     let content_slurs_removed = remove_slurs(&content, &context.settings().slur_regex());
293
294     let form = CommentForm {
295       creator_id: creator.id,
296       post_id: post.id,
297       parent_id: parent_comment_id,
298       content: content_slurs_removed,
299       removed: None,
300       read: None,
301       published: note.published.map(|u| u.to_owned().naive_local()),
302       updated: note.updated.map(|u| u.to_owned().naive_local()),
303       deleted: None,
304       ap_id,
305       local: Some(false),
306     };
307     let comment = blocking(context.pool(), move |conn| Comment::upsert(conn, &form)).await??;
308     Ok(comment.into())
309   }
310 }
311
312 #[cfg(test)]
313 mod tests {
314   use super::*;
315   use crate::objects::{
316     community::ApubCommunity,
317     tests::{file_to_json_object, init_context},
318   };
319   use assert_json_diff::assert_json_include;
320   use serial_test::serial;
321
322   async fn prepare_comment_test(
323     url: &Url,
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)
328       .await
329       .unwrap();
330     let community_json = file_to_json_object("assets/lemmy-community.json");
331     let community = ApubCommunity::from_apub(&community_json, context, url, &mut 0)
332       .await
333       .unwrap();
334     let post_json = file_to_json_object("assets/lemmy-post.json");
335     let post = ApubPost::from_apub(&post_json, context, url, &mut 0)
336       .await
337       .unwrap();
338     (person, community, post)
339   }
340
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();
345   }
346
347   #[actix_rt::test]
348   #[serial]
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;
353
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)
357       .await
358       .unwrap();
359
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);
364
365     let to_apub = comment.to_apub(context.pool()).await.unwrap();
366     assert_json_include!(actual: json, expected: to_apub);
367
368     Comment::delete(&*context.pool().get().unwrap(), comment.id).unwrap();
369     cleanup(data, &context);
370   }
371
372   #[actix_rt::test]
373   #[serial]
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;
378
379     let pleroma_url =
380       Url::parse("https://queer.hacktivis.me/objects/8d4973f4-53de-49cd-8c27-df160e16a9c2")
381         .unwrap();
382     let person_json = file_to_json_object("assets/pleroma-person.json");
383     ApubPerson::from_apub(&person_json, &context, &pleroma_url, &mut 0)
384       .await
385       .unwrap();
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)
389       .await
390       .unwrap();
391
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);
396
397     Comment::delete(&*context.pool().get().unwrap(), comment.id).unwrap();
398     cleanup(data, &context);
399   }
400
401   #[actix_rt::test]
402   #[serial]
403   async fn test_html_to_markdown_sanitize() {
404     let parsed = parse_html("<script></script><b>hello</b>");
405     assert_eq!(parsed, "**hello**");
406   }
407 }