]> Untitled Git - lemmy.git/blob - crates/apub/src/objects/post.rs
ace6b913121fd73329f340b1c6a0d2040f51f769
[lemmy.git] / crates / apub / src / objects / post.rs
1 use crate::{
2   activities::extract_community,
3   extensions::context::lemmy_context,
4   fetcher::person::get_or_fetch_and_upsert_person,
5   objects::{create_tombstone, FromApub, ToApub},
6 };
7 use activitystreams::{
8   base::AnyBase,
9   object::{
10     kind::{ImageType, PageType},
11     Tombstone,
12   },
13   primitives::OneOrMany,
14   public,
15   unparsed::Unparsed,
16 };
17 use chrono::{DateTime, FixedOffset};
18 use lemmy_api_common::blocking;
19 use lemmy_apub_lib::verify_domains_match;
20 use lemmy_db_queries::{ApubObject, Crud, DbPool};
21 use lemmy_db_schema::{
22   self,
23   source::{
24     community::Community,
25     person::Person,
26     post::{Post, PostForm},
27   },
28 };
29 use lemmy_utils::{
30   request::fetch_iframely_and_pictrs_data,
31   utils::{check_slurs, convert_datetime, markdown_to_html, remove_slurs},
32   LemmyError,
33 };
34 use lemmy_websocket::LemmyContext;
35 use url::Url;
36
37 #[async_trait::async_trait(?Send)]
38 impl ToApub for Post {
39   type ApubType = Page;
40
41   // Turn a Lemmy post into an ActivityPub page that can be sent out over the network.
42   async fn to_apub(&self, pool: &DbPool) -> Result<Page, LemmyError> {
43     let creator_id = self.creator_id;
44     let creator = blocking(pool, move |conn| Person::read(conn, creator_id)).await??;
45     let community_id = self.community_id;
46     let community = blocking(pool, move |conn| Community::read(conn, community_id)).await??;
47
48     let source = self.body.clone().map(|body| Source {
49       content: body,
50       media_type: MediaTypeMarkdown::Markdown,
51     });
52     let image = self.thumbnail_url.clone().map(|thumb| ImageObject {
53       content: ImageType::Image,
54       url: thumb.into(),
55     });
56
57     let page = Page {
58       context: lemmy_context()?.into(),
59       r#type: PageType::Page,
60       id: self.ap_id.clone().into(),
61       attributed_to: creator.actor_id.into(),
62       to: [community.actor_id.into(), public()],
63       name: self.name.clone(),
64       content: self.body.as_ref().map(|b| markdown_to_html(b)),
65       media_type: MediaTypeHtml::Markdown,
66       source,
67       url: self.url.clone().map(|u| u.into()),
68       image,
69       comments_enabled: Some(!self.locked),
70       sensitive: Some(self.nsfw),
71       stickied: Some(self.stickied),
72       published: convert_datetime(self.published),
73       updated: self.updated.map(convert_datetime),
74       unparsed: Default::default(),
75     };
76     Ok(page)
77   }
78
79   fn to_tombstone(&self) -> Result<Tombstone, LemmyError> {
80     create_tombstone(
81       self.deleted,
82       self.ap_id.to_owned().into(),
83       self.updated,
84       PageType::Page,
85     )
86   }
87 }
88
89 #[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
90 pub enum MediaTypeMarkdown {
91   #[serde(rename = "text/markdown")]
92   Markdown,
93 }
94
95 #[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
96 pub enum MediaTypeHtml {
97   #[serde(rename = "text/html")]
98   Markdown,
99 }
100
101 #[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
102 #[serde(rename_all = "camelCase")]
103 pub struct Source {
104   content: String,
105   media_type: MediaTypeMarkdown,
106 }
107
108 #[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
109 #[serde(rename_all = "camelCase")]
110 pub struct ImageObject {
111   content: ImageType,
112   url: Url,
113 }
114
115 #[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
116 #[serde(rename_all = "camelCase")]
117 pub struct Page {
118   #[serde(rename = "@context")]
119   context: OneOrMany<AnyBase>,
120   r#type: PageType,
121   pub(crate) id: Url,
122   pub(crate) attributed_to: Url,
123   to: [Url; 2],
124   name: String,
125   content: Option<String>,
126   media_type: MediaTypeHtml,
127   source: Option<Source>,
128   url: Option<Url>,
129   image: Option<ImageObject>,
130   pub(crate) comments_enabled: Option<bool>,
131   sensitive: Option<bool>,
132   pub(crate) stickied: Option<bool>,
133   published: DateTime<FixedOffset>,
134   updated: Option<DateTime<FixedOffset>>,
135
136   // unparsed fields
137   #[serde(flatten)]
138   unparsed: Unparsed,
139 }
140
141 impl Page {
142   /// Only mods can change the post's stickied/locked status. So if either of these is changed from
143   /// the current value, it is a mod action and needs to be verified as such.
144   ///
145   /// Both stickied and locked need to be false on a newly created post (verified in [[CreatePost]].
146   pub(crate) async fn is_mod_action(&self, pool: &DbPool) -> Result<bool, LemmyError> {
147     let post_id = self.id.clone();
148     let old_post = blocking(pool, move |conn| {
149       Post::read_from_apub_id(conn, &post_id.into())
150     })
151     .await?;
152
153     let is_mod_action = if let Ok(old_post) = old_post {
154       self.stickied != Some(old_post.stickied) || self.comments_enabled != Some(!old_post.locked)
155     } else {
156       false
157     };
158     Ok(is_mod_action)
159   }
160
161   pub(crate) async fn verify(
162     &self,
163     _context: &LemmyContext,
164     _request_counter: &mut i32,
165   ) -> Result<(), LemmyError> {
166     check_slurs(&self.name)?;
167     verify_domains_match(&self.attributed_to, &self.id)?;
168     Ok(())
169   }
170 }
171
172 #[async_trait::async_trait(?Send)]
173 impl FromApub for Post {
174   type ApubType = Page;
175
176   async fn from_apub(
177     page: &Page,
178     context: &LemmyContext,
179     _expected_domain: Url,
180     request_counter: &mut i32,
181     _mod_action_allowed: bool,
182   ) -> Result<Post, LemmyError> {
183     let creator =
184       get_or_fetch_and_upsert_person(&page.attributed_to, context, request_counter).await?;
185     let community = extract_community(&page.to, context, request_counter).await?;
186
187     let thumbnail_url: Option<Url> = page.image.clone().map(|i| i.url);
188     let (iframely_title, iframely_description, iframely_html, pictrs_thumbnail) =
189       if let Some(url) = &page.url {
190         fetch_iframely_and_pictrs_data(context.client(), Some(url)).await
191       } else {
192         (None, None, None, thumbnail_url)
193       };
194
195     let body_slurs_removed = page.source.as_ref().map(|s| remove_slurs(&s.content));
196     let form = PostForm {
197       name: page.name.clone(),
198       url: page.url.clone().map(|u| u.into()),
199       body: body_slurs_removed,
200       creator_id: creator.id,
201       community_id: community.id,
202       removed: None,
203       locked: page.comments_enabled.map(|e| !e),
204       published: Some(page.published.naive_local()),
205       updated: page.updated.map(|u| u.naive_local()),
206       deleted: None,
207       nsfw: page.sensitive,
208       stickied: page.stickied,
209       embed_title: iframely_title,
210       embed_description: iframely_description,
211       embed_html: iframely_html,
212       thumbnail_url: pictrs_thumbnail.map(|u| u.into()),
213       ap_id: Some(page.id.clone().into()),
214       local: Some(false),
215     };
216     Ok(blocking(context.pool(), move |conn| Post::upsert(conn, &form)).await??)
217   }
218 }