use crate::{ activities::extract_community, extensions::context::lemmy_context, fetcher::person::get_or_fetch_and_upsert_person, objects::{create_tombstone, FromApub, Source, ToApub}, }; use activitystreams::{ base::AnyBase, object::{ kind::{ImageType, PageType}, Tombstone, }, primitives::OneOrMany, public, unparsed::Unparsed, }; use chrono::{DateTime, FixedOffset}; use lemmy_api_common::blocking; use lemmy_apub_lib::{ values::{MediaTypeHtml, MediaTypeMarkdown}, verify_domains_match, }; use lemmy_db_queries::{ApubObject, Crud, DbPool}; use lemmy_db_schema::{ self, source::{ community::Community, person::Person, post::{Post, PostForm}, }, }; use lemmy_utils::{ request::fetch_iframely_and_pictrs_data, utils::{check_slurs, convert_datetime, markdown_to_html, remove_slurs}, LemmyError, }; use lemmy_websocket::LemmyContext; use url::Url; #[derive(Clone, Debug, serde::Deserialize, serde::Serialize)] #[serde(rename_all = "camelCase")] pub struct Page { #[serde(rename = "@context")] context: OneOrMany, r#type: PageType, pub(crate) id: Url, pub(crate) attributed_to: Url, to: [Url; 2], name: String, content: Option, media_type: MediaTypeHtml, source: Option, url: Option, image: Option, pub(crate) comments_enabled: Option, sensitive: Option, pub(crate) stickied: Option, published: DateTime, updated: Option>, // unparsed fields #[serde(flatten)] unparsed: Unparsed, } #[derive(Clone, Debug, serde::Deserialize, serde::Serialize)] #[serde(rename_all = "camelCase")] pub struct ImageObject { content: ImageType, url: Url, } impl Page { /// Only mods can change the post's stickied/locked status. So if either of these is changed from /// the current value, it is a mod action and needs to be verified as such. /// /// Both stickied and locked need to be false on a newly created post (verified in [[CreatePost]]. pub(crate) async fn is_mod_action(&self, pool: &DbPool) -> Result { let post_id = self.id.clone(); let old_post = blocking(pool, move |conn| { Post::read_from_apub_id(conn, &post_id.into()) }) .await?; let is_mod_action = if let Ok(old_post) = old_post { self.stickied != Some(old_post.stickied) || self.comments_enabled != Some(!old_post.locked) } else { false }; Ok(is_mod_action) } pub(crate) async fn verify( &self, _context: &LemmyContext, _request_counter: &mut i32, ) -> Result<(), LemmyError> { check_slurs(&self.name)?; verify_domains_match(&self.attributed_to, &self.id)?; Ok(()) } } #[async_trait::async_trait(?Send)] impl ToApub for Post { type ApubType = Page; // Turn a Lemmy post into an ActivityPub page that can be sent out over the network. async fn to_apub(&self, pool: &DbPool) -> Result { let creator_id = self.creator_id; let creator = blocking(pool, move |conn| Person::read(conn, creator_id)).await??; let community_id = self.community_id; let community = blocking(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(|thumb| ImageObject { content: ImageType::Image, url: thumb.into(), }); let page = Page { context: lemmy_context(), r#type: PageType::Page, id: self.ap_id.clone().into(), attributed_to: creator.actor_id.into(), to: [community.actor_id.into(), public()], name: self.name.clone(), content: self.body.as_ref().map(|b| markdown_to_html(b)), media_type: 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: convert_datetime(self.published), updated: self.updated.map(convert_datetime), unparsed: Default::default(), }; Ok(page) } fn to_tombstone(&self) -> Result { create_tombstone( self.deleted, self.ap_id.to_owned().into(), self.updated, PageType::Page, ) } } #[async_trait::async_trait(?Send)] impl FromApub for Post { type ApubType = Page; async fn from_apub( page: &Page, context: &LemmyContext, _expected_domain: Url, request_counter: &mut i32, _mod_action_allowed: bool, ) -> Result { let creator = get_or_fetch_and_upsert_person(&page.attributed_to, context, request_counter).await?; let community = extract_community(&page.to, context, request_counter).await?; let thumbnail_url: Option = page.image.clone().map(|i| i.url); let (iframely_title, iframely_description, iframely_html, pictrs_thumbnail) = if let Some(url) = &page.url { fetch_iframely_and_pictrs_data(context.client(), Some(url)).await } else { (None, None, None, thumbnail_url) }; let body_slurs_removed = page.source.as_ref().map(|s| remove_slurs(&s.content)); let form = PostForm { name: page.name.clone(), url: page.url.clone().map(|u| u.into()), body: body_slurs_removed, creator_id: creator.id, community_id: community.id, removed: None, locked: page.comments_enabled.map(|e| !e), published: Some(page.published.naive_local()), updated: page.updated.map(|u| u.naive_local()), deleted: None, nsfw: page.sensitive, stickied: page.stickied, embed_title: iframely_title, embed_description: iframely_description, embed_html: iframely_html, thumbnail_url: pictrs_thumbnail.map(|u| u.into()), ap_id: Some(page.id.clone().into()), local: Some(false), }; Ok(blocking(context.pool(), move |conn| Post::upsert(conn, &form)).await??) } }