]> Untitled Git - lemmy.git/blob - crates/apub/src/objects/post.rs
Rewrite collections to use new fetcher (#1861)
[lemmy.git] / crates / apub / src / objects / post.rs
1 use crate::{
2   activities::{extract_community, verify_is_public, verify_person_in_community},
3   context::lemmy_context,
4   fetcher::object_id::ObjectId,
5   objects::{person::ApubPerson, tombstone::Tombstone, ImageObject, Source},
6 };
7 use activitystreams::{
8   base::AnyBase,
9   object::kind::{ImageType, PageType},
10   primitives::OneOrMany,
11   public,
12   unparsed::Unparsed,
13 };
14 use chrono::{DateTime, FixedOffset, NaiveDateTime};
15 use lemmy_api_common::blocking;
16 use lemmy_apub_lib::{
17   traits::{ActorType, ApubObject},
18   values::{MediaTypeHtml, MediaTypeMarkdown},
19   verify::verify_domains_match,
20 };
21 use lemmy_db_schema::{
22   self,
23   source::{
24     community::Community,
25     person::Person,
26     post::{Post, PostForm},
27   },
28   traits::Crud,
29 };
30 use lemmy_utils::{
31   request::fetch_site_data,
32   utils::{check_slurs, convert_datetime, markdown_to_html, remove_slurs},
33   LemmyError,
34 };
35 use lemmy_websocket::LemmyContext;
36 use serde::{Deserialize, Serialize};
37 use serde_with::skip_serializing_none;
38 use std::ops::Deref;
39 use url::Url;
40
41 #[skip_serializing_none]
42 #[derive(Clone, Debug, Deserialize, Serialize)]
43 #[serde(rename_all = "camelCase")]
44 pub struct Page {
45   #[serde(rename = "@context")]
46   context: OneOrMany<AnyBase>,
47   r#type: PageType,
48   id: Url,
49   pub(crate) attributed_to: ObjectId<ApubPerson>,
50   to: Vec<Url>,
51   name: String,
52   content: Option<String>,
53   media_type: Option<MediaTypeHtml>,
54   source: Option<Source>,
55   url: Option<Url>,
56   image: Option<ImageObject>,
57   pub(crate) comments_enabled: Option<bool>,
58   sensitive: Option<bool>,
59   pub(crate) stickied: Option<bool>,
60   published: Option<DateTime<FixedOffset>>,
61   updated: Option<DateTime<FixedOffset>>,
62   #[serde(flatten)]
63   unparsed: Unparsed,
64 }
65
66 impl Page {
67   pub(crate) fn id_unchecked(&self) -> &Url {
68     &self.id
69   }
70   pub(crate) fn id(&self, expected_domain: &Url) -> Result<&Url, LemmyError> {
71     verify_domains_match(&self.id, expected_domain)?;
72     Ok(&self.id)
73   }
74
75   /// Only mods can change the post's stickied/locked status. So if either of these is changed from
76   /// the current value, it is a mod action and needs to be verified as such.
77   ///
78   /// Both stickied and locked need to be false on a newly created post (verified in [[CreatePost]].
79   pub(crate) async fn is_mod_action(&self, context: &LemmyContext) -> Result<bool, LemmyError> {
80     let old_post = ObjectId::<ApubPost>::new(self.id.clone())
81       .dereference_local(context)
82       .await;
83
84     let is_mod_action = if let Ok(old_post) = old_post {
85       self.stickied != Some(old_post.stickied) || self.comments_enabled != Some(!old_post.locked)
86     } else {
87       false
88     };
89     Ok(is_mod_action)
90   }
91
92   pub(crate) async fn verify(
93     &self,
94     context: &LemmyContext,
95     request_counter: &mut i32,
96   ) -> Result<(), LemmyError> {
97     let community = extract_community(&self.to, context, request_counter).await?;
98
99     check_slurs(&self.name, &context.settings().slur_regex())?;
100     verify_domains_match(self.attributed_to.inner(), &self.id.clone())?;
101     verify_person_in_community(
102       &self.attributed_to,
103       &ObjectId::new(community.actor_id()),
104       context,
105       request_counter,
106     )
107     .await?;
108     verify_is_public(&self.to.clone())?;
109     Ok(())
110   }
111 }
112
113 #[derive(Clone, Debug)]
114 pub struct ApubPost(Post);
115
116 impl Deref for ApubPost {
117   type Target = Post;
118   fn deref(&self) -> &Self::Target {
119     &self.0
120   }
121 }
122
123 impl From<Post> for ApubPost {
124   fn from(p: Post) -> Self {
125     ApubPost { 0: p }
126   }
127 }
128
129 #[async_trait::async_trait(?Send)]
130 impl ApubObject for ApubPost {
131   type DataType = LemmyContext;
132   type ApubType = Page;
133   type TombstoneType = Tombstone;
134
135   fn last_refreshed_at(&self) -> Option<NaiveDateTime> {
136     None
137   }
138
139   async fn read_from_apub_id(
140     object_id: Url,
141     context: &LemmyContext,
142   ) -> Result<Option<Self>, LemmyError> {
143     Ok(
144       blocking(context.pool(), move |conn| {
145         Post::read_from_apub_id(conn, object_id)
146       })
147       .await??
148       .map(Into::into),
149     )
150   }
151
152   async fn delete(self, context: &LemmyContext) -> Result<(), LemmyError> {
153     blocking(context.pool(), move |conn| {
154       Post::update_deleted(conn, self.id, true)
155     })
156     .await??;
157     Ok(())
158   }
159
160   // Turn a Lemmy post into an ActivityPub page that can be sent out over the network.
161   async fn to_apub(&self, context: &LemmyContext) -> Result<Page, LemmyError> {
162     let creator_id = self.creator_id;
163     let creator = blocking(context.pool(), move |conn| Person::read(conn, creator_id)).await??;
164     let community_id = self.community_id;
165     let community = blocking(context.pool(), move |conn| {
166       Community::read(conn, community_id)
167     })
168     .await??;
169
170     let source = self.body.clone().map(|body| Source {
171       content: body,
172       media_type: MediaTypeMarkdown::Markdown,
173     });
174     let image = self.thumbnail_url.clone().map(|thumb| ImageObject {
175       kind: ImageType::Image,
176       url: thumb.into(),
177     });
178
179     let page = Page {
180       context: lemmy_context(),
181       r#type: PageType::Page,
182       id: self.ap_id.clone().into(),
183       attributed_to: ObjectId::new(creator.actor_id),
184       to: vec![community.actor_id.into(), public()],
185       name: self.name.clone(),
186       content: self.body.as_ref().map(|b| markdown_to_html(b)),
187       media_type: Some(MediaTypeHtml::Html),
188       source,
189       url: self.url.clone().map(|u| u.into()),
190       image,
191       comments_enabled: Some(!self.locked),
192       sensitive: Some(self.nsfw),
193       stickied: Some(self.stickied),
194       published: Some(convert_datetime(self.published)),
195       updated: self.updated.map(convert_datetime),
196       unparsed: Default::default(),
197     };
198     Ok(page)
199   }
200
201   fn to_tombstone(&self) -> Result<Tombstone, LemmyError> {
202     Ok(Tombstone::new(
203       PageType::Page,
204       self.updated.unwrap_or(self.published),
205     ))
206   }
207
208   async fn from_apub(
209     page: &Page,
210     context: &LemmyContext,
211     expected_domain: &Url,
212     request_counter: &mut i32,
213   ) -> Result<ApubPost, LemmyError> {
214     // We can't verify the domain in case of mod action, because the mod may be on a different
215     // instance from the post author.
216     let ap_id = if page.is_mod_action(context).await? {
217       page.id_unchecked()
218     } else {
219       page.id(expected_domain)?
220     };
221     let ap_id = Some(ap_id.clone().into());
222     let creator = page
223       .attributed_to
224       .dereference(context, request_counter)
225       .await?;
226     let community = extract_community(&page.to, context, request_counter).await?;
227
228     let thumbnail_url: Option<Url> = page.image.clone().map(|i| i.url);
229     let (metadata_res, pictrs_thumbnail) = if let Some(url) = &page.url {
230       fetch_site_data(context.client(), &context.settings(), Some(url)).await
231     } else {
232       (None, thumbnail_url)
233     };
234     let (embed_title, embed_description, embed_html) = metadata_res
235       .map(|u| (u.title, u.description, u.html))
236       .unwrap_or((None, None, None));
237
238     let body_slurs_removed = page
239       .source
240       .as_ref()
241       .map(|s| remove_slurs(&s.content, &context.settings().slur_regex()));
242     let form = PostForm {
243       name: page.name.clone(),
244       url: page.url.clone().map(|u| u.into()),
245       body: body_slurs_removed,
246       creator_id: creator.id,
247       community_id: community.id,
248       removed: None,
249       locked: page.comments_enabled.map(|e| !e),
250       published: page.published.map(|u| u.naive_local()),
251       updated: page.updated.map(|u| u.naive_local()),
252       deleted: None,
253       nsfw: page.sensitive,
254       stickied: page.stickied,
255       embed_title,
256       embed_description,
257       embed_html,
258       thumbnail_url: pictrs_thumbnail.map(|u| u.into()),
259       ap_id,
260       local: Some(false),
261     };
262     let post = blocking(context.pool(), move |conn| Post::upsert(conn, &form)).await??;
263     Ok(post.into())
264   }
265 }
266
267 #[cfg(test)]
268 mod tests {
269   use super::*;
270   use crate::objects::{
271     community::ApubCommunity,
272     tests::{file_to_json_object, init_context},
273   };
274   use assert_json_diff::assert_json_include;
275   use serial_test::serial;
276
277   #[actix_rt::test]
278   #[serial]
279   async fn test_parse_lemmy_post() {
280     let context = init_context();
281     let url = Url::parse("https://enterprise.lemmy.ml/post/55143").unwrap();
282     let community_json = file_to_json_object("assets/lemmy-community.json");
283     let community = ApubCommunity::from_apub(&community_json, &context, &url, &mut 0)
284       .await
285       .unwrap();
286     let person_json = file_to_json_object("assets/lemmy-person.json");
287     let person = ApubPerson::from_apub(&person_json, &context, &url, &mut 0)
288       .await
289       .unwrap();
290     let json = file_to_json_object("assets/lemmy-post.json");
291     let mut request_counter = 0;
292     let post = ApubPost::from_apub(&json, &context, &url, &mut request_counter)
293       .await
294       .unwrap();
295
296     assert_eq!(post.ap_id.clone().into_inner(), url);
297     assert_eq!(post.name, "Post title");
298     assert!(post.body.is_some());
299     assert_eq!(post.body.as_ref().unwrap().len(), 45);
300     assert!(!post.locked);
301     assert!(post.stickied);
302     assert_eq!(request_counter, 0);
303
304     let to_apub = post.to_apub(&context).await.unwrap();
305     assert_json_include!(actual: json, expected: to_apub);
306
307     Post::delete(&*context.pool().get().unwrap(), post.id).unwrap();
308     Person::delete(&*context.pool().get().unwrap(), person.id).unwrap();
309     Community::delete(&*context.pool().get().unwrap(), community.id).unwrap();
310   }
311 }