X-Git-Url: http://these/git/?a=blobdiff_plain;f=crates%2Fapub%2Fsrc%2Fobjects%2Fpost.rs;h=48b573d30ab0dfdfd58398cdd2159ce7ee266a85;hb=92568956353f21649ed9aff68b42699c9d036f30;hp=1e59c339edd3cb84a976082cd74d7a37bb831a11;hpb=b47a4748631393530b07dce6db22ce5f032dd5bf;p=lemmy.git diff --git a/crates/apub/src/objects/post.rs b/crates/apub/src/objects/post.rs index 1e59c339..48b573d3 100644 --- a/crates/apub/src/objects/post.rs +++ b/crates/apub/src/objects/post.rs @@ -1,46 +1,59 @@ use crate::{ activities::{verify_is_public, verify_person_in_community}, - check_is_apub_id_valid, - objects::read_from_string_or_source_opt, + check_apub_id_valid_with_strictness, + local_site_data_cached, + objects::{read_from_string_or_source_opt, verify_is_remote_object}, protocol::{ objects::{ - page::{Attachment, Page, PageType}, - tombstone::Tombstone, + page::{Attachment, AttributedTo, Page, PageType}, + LanguageTag, }, ImageObject, + InCommunity, Source, }, }; -use activitystreams_kinds::public; +use activitypub_federation::{ + config::Data, + kinds::public, + protocol::{values::MediaTypeMarkdownOrHtml, verification::verify_domains_match}, + traits::Object, +}; +use anyhow::anyhow; use chrono::NaiveDateTime; -use lemmy_api_common::blocking; -use lemmy_apub_lib::{ - object_id::ObjectId, - traits::ApubObject, - values::MediaTypeHtml, - verify::verify_domains_match, +use html2md::parse_html; +use lemmy_api_common::{ + context::LemmyContext, + request::fetch_site_data, + utils::{is_mod_or_admin, local_site_opt_to_sensitive, local_site_opt_to_slur_regex}, }; use lemmy_db_schema::{ self, source::{ community::Community, - moderator::{ModLockPost, ModLockPostForm, ModStickyPost, ModStickyPostForm}, + local_site::LocalSite, + moderator::{ModLockPost, ModLockPostForm}, person::Person, - post::{Post, PostForm}, + post::{Post, PostInsertForm, PostUpdateForm}, }, traits::Crud, }; use lemmy_utils::{ - request::fetch_site_data, - utils::{check_slurs, convert_datetime, markdown_to_html, remove_slurs}, - LemmyError, + error::LemmyError, + utils::{ + markdown::markdown_to_html, + slurs::{check_slurs_opt, remove_slurs}, + time::convert_datetime, + validation::check_url_scheme, + }, }; -use lemmy_websocket::LemmyContext; use std::ops::Deref; use url::Url; +const MAX_TITLE_LENGTH: usize = 200; + #[derive(Clone, Debug)] -pub struct ApubPost(Post); +pub struct ApubPost(pub(crate) Post); impl Deref for ApubPost { type Target = Post; @@ -55,177 +68,210 @@ impl From for ApubPost { } } -#[async_trait::async_trait(?Send)] -impl ApubObject for ApubPost { +#[async_trait::async_trait] +impl Object for ApubPost { type DataType = LemmyContext; - type ApubType = Page; - type DbType = Post; - type TombstoneType = Tombstone; + type Kind = Page; + type Error = LemmyError; fn last_refreshed_at(&self) -> Option { None } #[tracing::instrument(skip_all)] - async fn read_from_apub_id( + async fn read_from_id( object_id: Url, - context: &LemmyContext, + context: &Data, ) -> Result, LemmyError> { Ok( - blocking(context.pool(), move |conn| { - Post::read_from_apub_id(conn, object_id) - }) - .await?? - .map(Into::into), + Post::read_from_apub_id(&mut context.pool(), object_id) + .await? + .map(Into::into), ) } #[tracing::instrument(skip_all)] - async fn delete(self, context: &LemmyContext) -> Result<(), LemmyError> { + async fn delete(self, context: &Data) -> Result<(), LemmyError> { if !self.deleted { - blocking(context.pool(), move |conn| { - Post::update_deleted(conn, self.id, true) - }) - .await??; + let form = PostUpdateForm::builder().deleted(Some(true)).build(); + Post::update(&mut context.pool(), self.id, &form).await?; } Ok(()) } // 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 { + async fn into_json(self, context: &Data) -> Result { let creator_id = self.creator_id; - let creator = blocking(context.pool(), move |conn| Person::read(conn, creator_id)).await??; + let creator = Person::read(&mut context.pool(), creator_id).await?; let community_id = self.community_id; - let community = blocking(context.pool(), move |conn| { - Community::read(conn, community_id) - }) - .await??; + let community = Community::read(&mut context.pool(), community_id).await?; + let language = LanguageTag::new_single(self.language_id, &mut context.pool()).await?; 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()], + kind: PageType::Page, + id: self.ap_id.clone().into(), + attributed_to: AttributedTo::Lemmy(creator.actor_id.into()), + to: vec![community.actor_id.clone().into(), public()], cc: vec![], - name: self.name.clone(), + name: Some(self.name.clone()), content: self.body.as_ref().map(|b| markdown_to_html(b)), - media_type: Some(MediaTypeHtml::Html), + media_type: Some(MediaTypeMarkdownOrHtml::Html), source: self.body.clone().map(Source::new), - url: self.url.clone().map(|u| u.into()), attachment: self.url.clone().map(Attachment::new).into_iter().collect(), image: self.thumbnail_url.clone().map(ImageObject::new), comments_enabled: Some(!self.locked), sensitive: Some(self.nsfw), - stickied: Some(self.stickied), + language, published: Some(convert_datetime(self.published)), updated: self.updated.map(convert_datetime), + audience: Some(community.actor_id.into()), + in_reply_to: None, }; Ok(page) } - fn to_tombstone(&self) -> Result { - Ok(Tombstone::new(self.ap_id.clone().into())) - } - #[tracing::instrument(skip_all)] async fn verify( page: &Page, expected_domain: &Url, - context: &LemmyContext, - request_counter: &mut i32, + context: &Data, ) -> 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)?; + verify_is_remote_object(page.id.inner(), context.settings())?; }; - 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())?; + let community = page.community(context).await?; + check_apub_id_valid_with_strictness(page.id.inner(), community.local, context).await?; + verify_person_in_community(&page.creator()?, &community, context).await?; + + let local_site_data = local_site_data_cached(&mut context.pool()).await?; + let slur_regex = &local_site_opt_to_slur_regex(&local_site_data.local_site); + check_slurs_opt(&page.name, slur_regex)?; + + verify_domains_match(page.creator()?.inner(), page.id.inner())?; verify_is_public(&page.to, &page.cc)?; Ok(()) } #[tracing::instrument(skip_all)] - async fn from_apub( - page: Page, - context: &LemmyContext, - request_counter: &mut i32, - ) -> Result { - let creator = page - .attributed_to - .dereference(context, context.client(), request_counter) - .await?; - let community = page.extract_community(context, request_counter).await?; - - let url = if let Some(attachment) = page.attachment.first() { - Some(attachment.href.clone()) - } else { - page.url - }; - let thumbnail_url: Option = page.image.map(|i| i.url); - let (metadata_res, pictrs_thumbnail) = if let Some(url) = &url { - fetch_site_data(context.client(), &context.settings(), Some(url)).await - } else { - (None, thumbnail_url) - }; - 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 = read_from_string_or_source_opt(&page.content, &page.source) - .map(|s| remove_slurs(&s, &context.settings().slur_regex())); - - let form = PostForm { - name: page.name.clone(), - url: url.map(Into::into), - body: body_slurs_removed, - creator_id: creator.id, - community_id: community.id, - removed: None, - 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: page.sensitive, - stickied: page.stickied, - embed_title, - embed_description, - embed_html, - thumbnail_url: pictrs_thumbnail.map(|u| u.into()), - ap_id: Some(page.id.clone().into()), - local: Some(false), - }; + async fn from_json(page: Page, context: &Data) -> Result { + let creator = page.creator()?.dereference(context).await?; + let community = page.community(context).await?; + if community.posting_restricted_to_mods { + is_mod_or_admin(&mut context.pool(), creator.id, community.id).await?; + } + let mut name = page + .name + .clone() + .or_else(|| { + page + .content + .clone() + .as_ref() + .and_then(|c| parse_html(c).lines().next().map(ToString::to_string)) + }) + .ok_or_else(|| anyhow!("Object must have name or content"))?; + if name.chars().count() > MAX_TITLE_LENGTH { + name = name.chars().take(MAX_TITLE_LENGTH).collect(); + } // read existing, local post if any (for generating mod log) - let old_post = ObjectId::::new(page.id.clone()) - .dereference_local(context) - .await; + let old_post = page.id.dereference_local(context).await; - let post = blocking(context.pool(), move |conn| Post::upsert(conn, &form)).await??; + let form = if !page.is_mod_action(context).await? { + let first_attachment = page.attachment.into_iter().map(Attachment::url).next(); + let url = if first_attachment.is_some() { + first_attachment + } else if page.kind == PageType::Video { + // we cant display videos directly, so insert a link to external video page + Some(page.id.inner().clone()) + } else { + None + }; + check_url_scheme(&url)?; - // write mod log entries for sticky/lock - if Page::is_stickied_changed(&old_post, &page.stickied) { - let form = ModStickyPostForm { - mod_person_id: creator.id, - post_id: post.id, - stickied: Some(post.stickied), + let local_site = LocalSite::read(&mut context.pool()).await.ok(); + let allow_sensitive = local_site_opt_to_sensitive(&local_site); + let page_is_sensitive = page.sensitive.unwrap_or(false); + let include_image = allow_sensitive || !page_is_sensitive; + + // Only fetch metadata if the post has a url and was not seen previously. We dont want to + // waste resources by fetching metadata for the same post multiple times. + // Additionally, only fetch image if content is not sensitive or is allowed on local site. + let (metadata_res, thumbnail) = match &url { + Some(url) if old_post.is_err() => { + fetch_site_data( + context.client(), + context.settings(), + Some(url), + include_image, + ) + .await + } + _ => (None, None), }; - blocking(context.pool(), move |conn| { - ModStickyPost::create(conn, &form) - }) - .await??; - } + // If no image was included with metadata, use post image instead when available. + let thumbnail_url = thumbnail.or_else(|| page.image.map(|i| i.url.into())); + + let (embed_title, embed_description, embed_video_url) = metadata_res + .map(|u| (u.title, u.description, u.embed_video_url)) + .unwrap_or_default(); + let slur_regex = &local_site_opt_to_slur_regex(&local_site); + + let body_slurs_removed = + read_from_string_or_source_opt(&page.content, &page.media_type, &page.source) + .map(|s| remove_slurs(&s, slur_regex)); + let language_id = + LanguageTag::to_language_id_single(page.language, &mut context.pool()).await?; + + PostInsertForm { + name, + url: url.map(Into::into), + body: body_slurs_removed, + creator_id: creator.id, + community_id: community.id, + removed: None, + locked: page.comments_enabled.map(|e| !e), + published: page.published.map(|u| u.naive_local()), + updated: page.updated.map(|u| u.naive_local()), + deleted: Some(false), + nsfw: page.sensitive, + embed_title, + embed_description, + embed_video_url, + thumbnail_url, + ap_id: Some(page.id.clone().into()), + local: Some(false), + language_id, + featured_community: None, + featured_local: None, + } + } else { + // if is mod action, only update locked/stickied fields, nothing else + PostInsertForm::builder() + .name(name) + .creator_id(creator.id) + .community_id(community.id) + .ap_id(Some(page.id.clone().into())) + .locked(page.comments_enabled.map(|e| !e)) + .updated(page.updated.map(|u| u.naive_local())) + .build() + }; + + let post = Post::create(&mut context.pool(), &form).await?; + + // write mod log entry for lock if Page::is_locked_changed(&old_post, &page.comments_enabled) { let form = ModLockPostForm { mod_person_id: creator.id, post_id: post.id, locked: Some(post.locked), }; - blocking(context.pool(), move |conn| ModLockPost::create(conn, &form)).await??; + ModLockPost::create(&mut context.pool(), &form).await?; } Ok(post.into()) @@ -234,6 +280,9 @@ impl ApubObject for ApubPost { #[cfg(test)] mod tests { + #![allow(clippy::unwrap_used)] + #![allow(clippy::indexing_slicing)] + use super::*; use crate::{ objects::{ @@ -247,34 +296,33 @@ mod tests { use lemmy_db_schema::source::site::Site; use serial_test::serial; - #[actix_rt::test] + #[tokio::test] #[serial] async fn test_parse_lemmy_post() { - let context = init_context(); + let context = init_context().await; 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(); + ApubPost::verify(&json, &url, &context).await.unwrap(); + let post = ApubPost::from_json(json, &context).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); + assert!(!post.featured_community); + assert_eq!(context.request_count(), 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(); + Post::delete(&mut context.pool(), post.id).await.unwrap(); + Person::delete(&mut context.pool(), person.id) + .await + .unwrap(); + Community::delete(&mut context.pool(), community.id) + .await + .unwrap(); + Site::delete(&mut context.pool(), site.id).await.unwrap(); } }