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