]> Untitled Git - lemmy.git/blob - crates/apub/src/protocol/objects/page.rs
Cache & Optimize Woodpecker CI (#3450)
[lemmy.git] / crates / apub / src / protocol / objects / page.rs
1 use crate::{
2   activities::verify_community_matches,
3   fetcher::user_or_community::{PersonOrGroupType, UserOrCommunity},
4   objects::{community::ApubCommunity, person::ApubPerson, post::ApubPost},
5   protocol::{objects::LanguageTag, ImageObject, InCommunity, Source},
6 };
7 use activitypub_federation::{
8   config::Data,
9   fetch::object_id::ObjectId,
10   kinds::{
11     link::LinkType,
12     object::{DocumentType, ImageType},
13   },
14   protocol::{
15     helpers::{deserialize_one_or_many, deserialize_skip_error},
16     values::MediaTypeMarkdownOrHtml,
17   },
18   traits::{ActivityHandler, Object},
19 };
20 use chrono::{DateTime, FixedOffset};
21 use itertools::Itertools;
22 use lemmy_api_common::context::LemmyContext;
23 use lemmy_db_schema::newtypes::DbUrl;
24 use lemmy_utils::error::{LemmyError, LemmyErrorType};
25 use serde::{de::Error, Deserialize, Deserializer, Serialize};
26 use serde_with::skip_serializing_none;
27 use url::Url;
28
29 #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
30 pub enum PageType {
31   Page,
32   Article,
33   Note,
34   Video,
35   Event,
36 }
37
38 #[skip_serializing_none]
39 #[derive(Clone, Debug, Deserialize, Serialize)]
40 #[serde(rename_all = "camelCase")]
41 pub struct Page {
42   #[serde(rename = "type")]
43   pub(crate) kind: PageType,
44   pub(crate) id: ObjectId<ApubPost>,
45   pub(crate) attributed_to: AttributedTo,
46   #[serde(deserialize_with = "deserialize_one_or_many")]
47   pub(crate) to: Vec<Url>,
48   // If there is inReplyTo field this is actually a comment and must not be parsed
49   #[serde(deserialize_with = "deserialize_not_present", default)]
50   pub(crate) in_reply_to: Option<String>,
51
52   pub(crate) name: Option<String>,
53   #[serde(deserialize_with = "deserialize_one_or_many", default)]
54   pub(crate) cc: Vec<Url>,
55   pub(crate) content: Option<String>,
56   pub(crate) media_type: Option<MediaTypeMarkdownOrHtml>,
57   #[serde(deserialize_with = "deserialize_skip_error", default)]
58   pub(crate) source: Option<Source>,
59   /// most software uses array type for attachment field, so we do the same. nevertheless, we only
60   /// use the first item
61   #[serde(default)]
62   pub(crate) attachment: Vec<Attachment>,
63   pub(crate) image: Option<ImageObject>,
64   pub(crate) comments_enabled: Option<bool>,
65   pub(crate) sensitive: Option<bool>,
66   pub(crate) published: Option<DateTime<FixedOffset>>,
67   pub(crate) updated: Option<DateTime<FixedOffset>>,
68   pub(crate) language: Option<LanguageTag>,
69   pub(crate) audience: Option<ObjectId<ApubCommunity>>,
70 }
71
72 #[derive(Clone, Debug, Deserialize, Serialize)]
73 #[serde(rename_all = "camelCase")]
74 pub(crate) struct Link {
75   pub(crate) href: Url,
76   pub(crate) r#type: LinkType,
77 }
78
79 #[derive(Clone, Debug, Deserialize, Serialize)]
80 #[serde(rename_all = "camelCase")]
81 pub(crate) struct Image {
82   #[serde(rename = "type")]
83   pub(crate) kind: ImageType,
84   pub(crate) url: Url,
85 }
86
87 #[derive(Clone, Debug, Deserialize, Serialize)]
88 #[serde(rename_all = "camelCase")]
89 pub(crate) struct Document {
90   #[serde(rename = "type")]
91   pub(crate) kind: DocumentType,
92   pub(crate) url: Url,
93 }
94
95 #[derive(Clone, Debug, Deserialize, Serialize)]
96 #[serde(untagged)]
97 pub(crate) enum Attachment {
98   Link(Link),
99   Image(Image),
100   Document(Document),
101 }
102
103 impl Attachment {
104   pub(crate) fn url(self) -> Url {
105     match self {
106       // url as sent by Lemmy (new)
107       Attachment::Link(l) => l.href,
108       // image sent by lotide
109       Attachment::Image(i) => i.url,
110       // sent by mobilizon
111       Attachment::Document(d) => d.url,
112     }
113   }
114 }
115
116 #[derive(Clone, Debug, Deserialize, Serialize)]
117 #[serde(untagged)]
118 pub(crate) enum AttributedTo {
119   Lemmy(ObjectId<ApubPerson>),
120   Peertube([AttributedToPeertube; 2]),
121 }
122
123 #[derive(Clone, Debug, Deserialize, Serialize)]
124 #[serde(rename_all = "camelCase")]
125 pub(crate) struct AttributedToPeertube {
126   #[serde(rename = "type")]
127   pub kind: PersonOrGroupType,
128   pub id: ObjectId<UserOrCommunity>,
129 }
130
131 impl Page {
132   /// Only mods can change the post's locked status. So if it is changed from the default value,
133   /// it is a mod action and needs to be verified as such.
134   ///
135   /// Locked needs to be false on a newly created post (verified in [[CreatePost]].
136   pub(crate) async fn is_mod_action(
137     &self,
138     context: &Data<LemmyContext>,
139   ) -> Result<bool, LemmyError> {
140     let old_post = self.id.clone().dereference_local(context).await;
141     Ok(Page::is_locked_changed(&old_post, &self.comments_enabled))
142   }
143
144   pub(crate) fn is_locked_changed<E>(
145     old_post: &Result<ApubPost, E>,
146     new_comments_enabled: &Option<bool>,
147   ) -> bool {
148     if let Some(new_comments_enabled) = new_comments_enabled {
149       if let Ok(old_post) = old_post {
150         return new_comments_enabled != &!old_post.locked;
151       }
152     }
153
154     false
155   }
156
157   pub(crate) fn creator(&self) -> Result<ObjectId<ApubPerson>, LemmyError> {
158     match &self.attributed_to {
159       AttributedTo::Lemmy(l) => Ok(l.clone()),
160       AttributedTo::Peertube(p) => p
161         .iter()
162         .find(|a| a.kind == PersonOrGroupType::Person)
163         .map(|a| ObjectId::<ApubPerson>::from(a.id.clone().into_inner()))
164         .ok_or_else(|| LemmyErrorType::PageDoesNotSpecifyCreator.into()),
165     }
166   }
167 }
168
169 impl Attachment {
170   pub(crate) fn new(url: DbUrl) -> Attachment {
171     Attachment::Link(Link {
172       href: url.into(),
173       r#type: Default::default(),
174     })
175   }
176 }
177
178 // Used for community outbox, so that it can be compatible with Pleroma/Mastodon.
179 #[async_trait::async_trait]
180 impl ActivityHandler for Page {
181   type DataType = LemmyContext;
182   type Error = LemmyError;
183   fn id(&self) -> &Url {
184     unimplemented!()
185   }
186   fn actor(&self) -> &Url {
187     unimplemented!()
188   }
189   async fn verify(&self, data: &Data<Self::DataType>) -> Result<(), LemmyError> {
190     ApubPost::verify(self, self.id.inner(), data).await
191   }
192   async fn receive(self, data: &Data<Self::DataType>) -> Result<(), LemmyError> {
193     ApubPost::from_json(self, data).await?;
194     Ok(())
195   }
196 }
197
198 #[async_trait::async_trait]
199 impl InCommunity for Page {
200   async fn community(&self, context: &Data<LemmyContext>) -> Result<ApubCommunity, LemmyError> {
201     let community = match &self.attributed_to {
202       AttributedTo::Lemmy(_) => {
203         let mut iter = self.to.iter().merge(self.cc.iter());
204         loop {
205           if let Some(cid) = iter.next() {
206             let cid = ObjectId::from(cid.clone());
207             if let Ok(c) = cid.dereference(context).await {
208               break c;
209             }
210           } else {
211             return Err(LemmyErrorType::NoCommunityFoundInCc)?;
212           }
213         }
214       }
215       AttributedTo::Peertube(p) => {
216         p.iter()
217           .find(|a| a.kind == PersonOrGroupType::Group)
218           .map(|a| ObjectId::<ApubCommunity>::from(a.id.clone().into_inner()))
219           .ok_or(LemmyErrorType::PageDoesNotSpecifyGroup)?
220           .dereference(context)
221           .await?
222       }
223     };
224     if let Some(audience) = &self.audience {
225       verify_community_matches(audience, community.actor_id.clone())?;
226     }
227     Ok(community)
228   }
229 }
230
231 /// Only allows deserialization if the field is missing or null. If it is present, throws an error.
232 pub fn deserialize_not_present<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
233 where
234   D: Deserializer<'de>,
235 {
236   let result: Option<String> = Deserialize::deserialize(deserializer)?;
237   match result {
238     None => Ok(None),
239     Some(_) => Err(D::Error::custom("Post must not have inReplyTo property")),
240   }
241 }
242
243 #[cfg(test)]
244 mod tests {
245   #![allow(clippy::unwrap_used)]
246   #![allow(clippy::indexing_slicing)]
247
248   use crate::protocol::{objects::page::Page, tests::test_parse_lemmy_item};
249
250   #[test]
251   fn test_not_parsing_note_as_page() {
252     assert!(test_parse_lemmy_item::<Page>("assets/lemmy/objects/note.json").is_err());
253   }
254 }