]> Untitled Git - lemmy.git/blobdiff - crates/apub/src/objects/post.rs
Implement instance actor (#1798)
[lemmy.git] / crates / apub / src / objects / post.rs
index f532fcc1dd70e97cc7503a398080a5ae2e2e0ae2..b15c9374b55d5bc38279c887de17734f67c9ad11 100644 (file)
@@ -1,30 +1,24 @@
 use crate::{
+  activities::{verify_is_public, verify_person_in_community},
   check_is_apub_id_valid,
-  extensions::{context::lemmy_context, page_extension::PageExtension},
-  fetcher::person::get_or_fetch_and_upsert_person,
-  objects::{
-    check_object_domain,
-    check_object_for_community_or_site_ban,
-    create_tombstone,
-    get_community_from_to_or_cc,
-    get_object_from_apub,
-    get_source_markdown_value,
-    set_content_and_source,
-    FromApub,
-    FromApubToForm,
-    ToApub,
+  protocol::{
+    objects::{
+      page::{Page, PageType},
+      tombstone::Tombstone,
+    },
+    ImageObject,
+    Source,
   },
-  PageExt,
 };
-use activitystreams::{
-  object::{kind::PageType, ApObject, Image, Page, Tombstone},
-  prelude::*,
-  public,
+use activitystreams_kinds::public;
+use chrono::NaiveDateTime;
+use lemmy_api_common::blocking;
+use lemmy_apub_lib::{
+  object_id::ObjectId,
+  traits::ApubObject,
+  values::{MediaTypeHtml, MediaTypeMarkdown},
+  verify::verify_domains_match,
 };
-use activitystreams_ext::Ext1;
-use anyhow::Context;
-use lemmy_api_structs::blocking;
-use lemmy_db_queries::{Crud, DbPool};
 use lemmy_db_schema::{
   self,
   source::{
@@ -32,213 +26,227 @@ use lemmy_db_schema::{
     person::Person,
     post::{Post, PostForm},
   },
+  traits::Crud,
 };
 use lemmy_utils::{
-  location_info,
-  request::fetch_iframely_and_pictrs_data,
-  utils::{check_slurs, convert_datetime, remove_slurs},
+  request::fetch_site_data,
+  utils::{check_slurs, convert_datetime, markdown_to_html, remove_slurs},
   LemmyError,
 };
 use lemmy_websocket::LemmyContext;
+use std::ops::Deref;
 use url::Url;
 
-#[async_trait::async_trait(?Send)]
-impl ToApub for Post {
-  type ApubType = PageExt;
+#[derive(Clone, Debug)]
+pub struct ApubPost(Post);
 
-  // Turn a Lemmy post into an ActivityPub page that can be sent out over the network.
-  async fn to_apub(&self, pool: &DbPool) -> Result<PageExt, LemmyError> {
-    let mut page = ApObject::new(Page::new());
+impl Deref for ApubPost {
+  type Target = Post;
+  fn deref(&self) -> &Self::Target {
+    &self.0
+  }
+}
 
-    let creator_id = self.creator_id;
-    let creator = blocking(pool, move |conn| Person::read(conn, creator_id)).await??;
+impl From<Post> for ApubPost {
+  fn from(p: Post) -> Self {
+    ApubPost { 0: p }
+  }
+}
 
-    let community_id = self.community_id;
-    let community = blocking(pool, move |conn| Community::read(conn, community_id)).await??;
-
-    page
-      // Not needed when the Post is embedded in a collection (like for community outbox)
-      // TODO: need to set proper context defining sensitive/commentsEnabled fields
-      // https://git.asonix.dog/Aardwolf/activitystreams/issues/5
-      .set_many_contexts(lemmy_context()?)
-      .set_id(self.ap_id.to_owned().into_inner())
-      .set_name(self.name.to_owned())
-      // `summary` field for compatibility with lemmy v0.9.9 and older,
-      // TODO: remove this after some time
-      .set_summary(self.name.to_owned())
-      .set_published(convert_datetime(self.published))
-      .set_many_tos(vec![community.actor_id.into_inner(), public()])
-      .set_attributed_to(creator.actor_id.into_inner());
-
-    if let Some(body) = &self.body {
-      set_content_and_source(&mut page, &body)?;
-    }
+#[async_trait::async_trait(?Send)]
+impl ApubObject for ApubPost {
+  type DataType = LemmyContext;
+  type ApubType = Page;
+  type TombstoneType = Tombstone;
 
-    if let Some(url) = &self.url {
-      page.set_url::<Url>(url.to_owned().into());
-    }
+  fn last_refreshed_at(&self) -> Option<NaiveDateTime> {
+    None
+  }
 
-    if let Some(thumbnail_url) = &self.thumbnail_url {
-      let mut image = Image::new();
-      image.set_url::<Url>(thumbnail_url.to_owned().into());
-      page.set_image(image.into_any_base()?);
-    }
+  #[tracing::instrument(skip_all)]
+  async fn read_from_apub_id(
+    object_id: Url,
+    context: &LemmyContext,
+  ) -> Result<Option<Self>, LemmyError> {
+    Ok(
+      blocking(context.pool(), move |conn| {
+        Post::read_from_apub_id(conn, object_id)
+      })
+      .await??
+      .map(Into::into),
+    )
+  }
 
-    if let Some(u) = self.updated {
-      page.set_updated(convert_datetime(u));
+  #[tracing::instrument(skip_all)]
+  async fn delete(self, context: &LemmyContext) -> Result<(), LemmyError> {
+    if !self.deleted {
+      blocking(context.pool(), move |conn| {
+        Post::update_deleted(conn, self.id, true)
+      })
+      .await??;
     }
+    Ok(())
+  }
 
-    let ext = PageExtension {
+  // Turn a Lemmy post into an ActivityPub page that can be sent out over the network.
+  #[tracing::instrument(skip_all)]
+  async fn into_apub(self, context: &LemmyContext) -> Result<Page, LemmyError> {
+    let creator_id = self.creator_id;
+    let creator = blocking(context.pool(), move |conn| Person::read(conn, creator_id)).await??;
+    let community_id = self.community_id;
+    let community = blocking(context.pool(), move |conn| {
+      Community::read(conn, community_id)
+    })
+    .await??;
+
+    let source = self.body.clone().map(|body| Source {
+      content: body,
+      media_type: MediaTypeMarkdown::Markdown,
+    });
+    let image = self.thumbnail_url.clone().map(ImageObject::new);
+
+    let page = Page {
+      r#type: PageType::Page,
+      id: ObjectId::new(self.ap_id.clone()),
+      attributed_to: ObjectId::new(creator.actor_id),
+      to: vec![community.actor_id.into(), public()],
+      cc: vec![],
+      name: self.name.clone(),
+      content: self.body.as_ref().map(|b| markdown_to_html(b)),
+      media_type: Some(MediaTypeHtml::Html),
+      source,
+      url: self.url.clone().map(|u| u.into()),
+      image,
       comments_enabled: Some(!self.locked),
       sensitive: Some(self.nsfw),
       stickied: Some(self.stickied),
+      published: Some(convert_datetime(self.published)),
+      updated: self.updated.map(convert_datetime),
+      unparsed: Default::default(),
     };
-    Ok(Ext1::new(page, ext))
+    Ok(page)
   }
 
   fn to_tombstone(&self) -> Result<Tombstone, LemmyError> {
-    create_tombstone(
-      self.deleted,
-      self.ap_id.to_owned().into(),
-      self.updated,
-      PageType::Page,
-    )
+    Ok(Tombstone::new(self.ap_id.clone().into()))
   }
-}
 
-#[async_trait::async_trait(?Send)]
-impl FromApub for Post {
-  type ApubType = PageExt;
-
-  /// Converts a `PageExt` to `PostForm`.
-  ///
-  /// If the post's community or creator are not known locally, these are also fetched.
-  async fn from_apub(
-    page: &PageExt,
+  #[tracing::instrument(skip_all)]
+  async fn verify(
+    page: &Page,
+    expected_domain: &Url,
     context: &LemmyContext,
-    expected_domain: Url,
     request_counter: &mut i32,
-    mod_action_allowed: bool,
-  ) -> Result<Post, LemmyError> {
-    let post: Post = get_object_from_apub(
-      page,
-      context,
-      expected_domain,
-      request_counter,
-      mod_action_allowed,
-    )
-    .await?;
-    check_object_for_community_or_site_ban(page, post.community_id, context, request_counter)
-      .await?;
-    Ok(post)
+  ) -> Result<(), LemmyError> {
+    // We can't verify the domain in case of mod action, because the mod may be on a different
+    // instance from the post author.
+    if !page.is_mod_action(context).await? {
+      verify_domains_match(page.id.inner(), expected_domain)?;
+    };
+
+    let community = page.extract_community(context, request_counter).await?;
+    check_is_apub_id_valid(page.id.inner(), community.local, &context.settings())?;
+    verify_person_in_community(&page.attributed_to, &community, context, request_counter).await?;
+    check_slurs(&page.name, &context.settings().slur_regex())?;
+    verify_domains_match(page.attributed_to.inner(), page.id.inner())?;
+    verify_is_public(&page.to, &page.cc)?;
+    Ok(())
   }
-}
 
-#[async_trait::async_trait(?Send)]
-impl FromApubToForm<PageExt> for PostForm {
+  #[tracing::instrument(skip_all)]
   async fn from_apub(
-    page: &PageExt,
+    page: Page,
     context: &LemmyContext,
-    expected_domain: Url,
     request_counter: &mut i32,
-    mod_action_allowed: bool,
-  ) -> Result<PostForm, LemmyError> {
-    let ap_id = if mod_action_allowed {
-      let id = page.id_unchecked().context(location_info!())?;
-      check_is_apub_id_valid(id)?;
-      id.to_owned().into()
+  ) -> Result<ApubPost, LemmyError> {
+    let creator = page
+      .attributed_to
+      .dereference(context, context.client(), request_counter)
+      .await?;
+    let community = page.extract_community(context, request_counter).await?;
+
+    let thumbnail_url: Option<Url> = page.image.map(|i| i.url);
+    let (metadata_res, pictrs_thumbnail) = if let Some(url) = &page.url {
+      fetch_site_data(context.client(), &context.settings(), Some(url)).await
     } else {
-      check_object_domain(page, expected_domain)?
+      (None, thumbnail_url)
     };
-    let ext = &page.ext_one;
-    let creator_actor_id = page
-      .inner
-      .attributed_to()
+    let (embed_title, embed_description, embed_html) = metadata_res
+      .map(|u| (u.title, u.description, u.html))
+      .unwrap_or((None, None, None));
+
+    let body_slurs_removed = page
+      .source
       .as_ref()
-      .context(location_info!())?
-      .as_single_xsd_any_uri()
-      .context(location_info!())?;
-
-    let creator =
-      get_or_fetch_and_upsert_person(creator_actor_id, context, request_counter).await?;
-
-    let community = get_community_from_to_or_cc(page, context, request_counter).await?;
-
-    let thumbnail_url: Option<Url> = match &page.inner.image() {
-      Some(any_image) => Image::from_any_base(
-        any_image
-          .to_owned()
-          .as_one()
-          .context(location_info!())?
-          .to_owned(),
-      )?
-      .context(location_info!())?
-      .url()
-      .context(location_info!())?
-      .as_single_xsd_any_uri()
-      .map(|url| url.to_owned()),
-      None => None,
-    };
-    let url = page
-      .inner
-      .url()
-      .map(|u| u.as_single_xsd_any_uri())
-      .flatten()
-      .map(|u| u.to_owned());
-
-    let (iframely_title, iframely_description, iframely_html, pictrs_thumbnail) =
-      if let Some(url) = &url {
-        fetch_iframely_and_pictrs_data(context.client(), Some(url)).await
-      } else {
-        (None, None, None, thumbnail_url)
-      };
-
-    let name = page
-      .inner
-      .name()
-      // The following is for compatibility with lemmy v0.9.9 and older
-      // TODO: remove it after some time (along with the map above)
-      .or_else(|| page.inner.summary())
-      .context(location_info!())?
-      .as_single_xsd_string()
-      .context(location_info!())?
-      .to_string();
-    let body = get_source_markdown_value(page)?;
-
-    // TODO: expected_domain is wrong in this case, because it simply takes the domain of the actor
-    //       maybe we need to take id_unchecked() if the activity is from community to user?
-    //       why did this work before? -> i dont think it did?
-    //       -> try to make expected_domain optional and set it null if it is a mod action
-
-    check_slurs(&name)?;
-    let body_slurs_removed = body.map(|b| remove_slurs(&b));
-    Ok(PostForm {
-      name,
-      url: url.map(|u| u.into()),
+      .map(|s| remove_slurs(&s.content, &context.settings().slur_regex()));
+    let form = PostForm {
+      name: page.name,
+      url: page.url.map(|u| u.into()),
       body: body_slurs_removed,
       creator_id: creator.id,
       community_id: community.id,
       removed: None,
-      locked: ext.comments_enabled.map(|e| !e),
-      published: page
-        .inner
-        .published()
-        .as_ref()
-        .map(|u| u.to_owned().naive_local()),
-      updated: page
-        .inner
-        .updated()
-        .as_ref()
-        .map(|u| u.to_owned().naive_local()),
+      locked: page.comments_enabled.map(|e| !e),
+      published: page.published.map(|u| u.naive_local()),
+      updated: page.updated.map(|u| u.naive_local()),
       deleted: None,
-      nsfw: ext.sensitive.unwrap_or(false),
-      stickied: ext.stickied.or(Some(false)),
-      embed_title: iframely_title,
-      embed_description: iframely_description,
-      embed_html: iframely_html,
+      nsfw: page.sensitive,
+      stickied: page.stickied,
+      embed_title,
+      embed_description,
+      embed_html,
       thumbnail_url: pictrs_thumbnail.map(|u| u.into()),
-      ap_id: Some(ap_id),
-      local: false,
-    })
+      ap_id: Some(page.id.into()),
+      local: Some(false),
+    };
+    let post = blocking(context.pool(), move |conn| Post::upsert(conn, &form)).await??;
+    Ok(post.into())
+  }
+}
+
+#[cfg(test)]
+mod tests {
+  use super::*;
+  use crate::objects::{
+    community::tests::parse_lemmy_community,
+    person::tests::parse_lemmy_person,
+    post::ApubPost,
+    tests::{file_to_json_object, init_context},
+  };
+  use lemmy_apub_lib::activity_queue::create_activity_queue;
+  use lemmy_db_schema::source::site::Site;
+  use serial_test::serial;
+
+  #[actix_rt::test]
+  #[serial]
+  async fn test_parse_lemmy_post() {
+    let client = reqwest::Client::new().into();
+    let manager = create_activity_queue(client);
+    let context = init_context(manager.queue_handle().clone());
+    let (person, site) = parse_lemmy_person(&context).await;
+    let community = parse_lemmy_community(&context).await;
+
+    let json = file_to_json_object("assets/lemmy/objects/page.json").unwrap();
+    let url = Url::parse("https://enterprise.lemmy.ml/post/55143").unwrap();
+    let mut request_counter = 0;
+    ApubPost::verify(&json, &url, &context, &mut request_counter)
+      .await
+      .unwrap();
+    let post = ApubPost::from_apub(json, &context, &mut request_counter)
+      .await
+      .unwrap();
+
+    assert_eq!(post.ap_id, url.into());
+    assert_eq!(post.name, "Post title");
+    assert!(post.body.is_some());
+    assert_eq!(post.body.as_ref().unwrap().len(), 45);
+    assert!(!post.locked);
+    assert!(post.stickied);
+    assert_eq!(request_counter, 0);
+
+    Post::delete(&*context.pool().get().unwrap(), post.id).unwrap();
+    Person::delete(&*context.pool().get().unwrap(), person.id).unwrap();
+    Community::delete(&*context.pool().get().unwrap(), community.id).unwrap();
+    Site::delete(&*context.pool().get().unwrap(), site.id).unwrap();
   }
 }