diff --git a/crates/api/src/site/purge/post.rs b/crates/api/src/site/purge/post.rs index c622953247..f7325f2172 100644 --- a/crates/api/src/site/purge/post.rs +++ b/crates/api/src/site/purge/post.rs @@ -12,6 +12,7 @@ use lemmy_db_schema::{ local_user::LocalUser, mod_log::admin::{AdminPurgePost, AdminPurgePostForm}, post::Post, + post_gallery::PostGallery, }, traits::Crud, }; @@ -37,7 +38,15 @@ pub async fn purge_post( ) .await?; - purge_post_images(post.url.clone(), post.thumbnail_url.clone(), &context).await; + let gallery = PostGallery::list_from_post_id(post.id, &mut context.pool()).await?; + + purge_post_images( + post.url.clone(), + post.thumbnail_url.clone(), + &gallery, + &context, + ) + .await; Post::delete(&mut context.pool(), data.post_id).await?; diff --git a/crates/api_common/src/post.rs b/crates/api_common/src/post.rs index fdb20c1358..a5294431b5 100644 --- a/crates/api_common/src/post.rs +++ b/crates/api_common/src/post.rs @@ -21,6 +21,8 @@ pub struct CreatePost { pub community_id: CommunityId, #[cfg_attr(feature = "full", ts(optional))] pub url: Option, + #[cfg_attr(feature = "full", ts(optional))] + pub gallery: Option>, /// An optional body for the post in markdown. #[cfg_attr(feature = "full", ts(optional))] pub body: Option, @@ -51,6 +53,23 @@ pub struct PostResponse { pub post_view: PostView, } +#[skip_serializing_none] +#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "full", derive(TS))] +#[cfg_attr(feature = "full", ts(export))] +pub struct CreateGalleryItem { + pub url: String, + /// Can be used to set the order of images in a gallery. + #[cfg_attr(feature = "full", ts(optional))] + pub page: Option, + /// An optional alt_text. + #[cfg_attr(feature = "full", ts(optional))] + pub alt_text: Option, + /// Optional caption to be displayed with the image. + #[cfg_attr(feature = "full", ts(optional))] + pub caption: Option, +} + #[skip_serializing_none] #[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Hash)] #[cfg_attr(feature = "full", derive(TS))] @@ -162,6 +181,8 @@ pub struct EditPost { /// An optional body for the post in markdown. #[cfg_attr(feature = "full", ts(optional))] pub body: Option, + #[cfg_attr(feature = "full", ts(optional))] + pub gallery: Option>, /// An optional alt_text, usable for image posts. #[cfg_attr(feature = "full", ts(optional))] pub alt_text: Option, diff --git a/crates/api_common/src/request.rs b/crates/api_common/src/request.rs index 08cf1c1376..a52065b545 100644 --- a/crates/api_common/src/request.rs +++ b/crates/api_common/src/request.rs @@ -12,6 +12,7 @@ use futures::StreamExt; use lemmy_db_schema::source::{ images::{ImageDetailsInsertForm, LocalImage, LocalImageForm}, post::{Post, PostUpdateForm}, + post_gallery::PostGalleryInsertForm, site::Site, }; use lemmy_utils::{ @@ -244,15 +245,16 @@ pub async fn generate_post_link_metadata( }; let form = PostUpdateForm { - url, + url: url.clone(), embed_title: Some(metadata.opengraph_data.title), embed_description: Some(metadata.opengraph_data.description), embed_video_url: Some(metadata.opengraph_data.embed_video_url), thumbnail_url: Some(thumbnail_url), - url_content_type: Some(metadata.content_type), + url_content_type: Some(metadata.content_type.clone()), ..Default::default() }; let updated_post = Post::update(&mut context.pool(), post.id, &form).await?; + if let Some(send_activity) = send_activity(updated_post) { ActivityChannel::submit_activity(send_activity, &context)?; } @@ -310,6 +312,33 @@ fn extract_opengraph_data(html_bytes: &[u8], url: &Url) -> LemmyResult, + context: &LemmyContext, +) -> Result, LemmyError> { + let mut validated = vec![]; + for item in gallery_items { + let metadata = fetch_link_metadata(&item.url, context, false).await?; + let is_image = metadata + .content_type + .as_ref() + .is_some_and(|content_type| content_type.starts_with("image")); + + if !is_image { + Err(LemmyErrorType::UrlNotImage(item.url.to_string()))? + } else { + let proxied = proxy_image_link(item.url.clone().into(), false, context).await?; + validated.push(PostGalleryInsertForm { + url: proxied, + url_content_type: metadata.content_type, + ..item.clone() + }); + } + } + + Ok(validated) +} + #[derive(Deserialize, Serialize, Debug)] pub struct PictrsResponse { #[serde(default)] diff --git a/crates/api_common/src/utils.rs b/crates/api_common/src/utils.rs index fe9f1e25ce..982a2476fe 100644 --- a/crates/api_common/src/utils.rs +++ b/crates/api_common/src/utils.rs @@ -1,6 +1,7 @@ use crate::{ claims::Claims, context::LemmyContext, + post::CreateGalleryItem, request::{ delete_image_from_pictrs, fetch_pictrs_proxied_image_details, @@ -31,12 +32,13 @@ use lemmy_db_schema::{ oauth_account::OAuthAccount, person::{Person, PersonActions, PersonUpdateForm}, post::{Post, PostActions, PostReadCommentsForm}, + post_gallery::{PostGallery, PostGalleryInsertForm}, private_message::PrivateMessage, registration_application::RegistrationApplication, site::Site, }, traits::{Blockable, Crud, Likeable, ReadComments}, - utils::DbPool, + utils::{diesel_url_create, DbPool}, }; use lemmy_db_schema_file::enums::{FederationMode, RegistrationMode}; use lemmy_db_views_community_follower::CommunityFollowerView; @@ -54,7 +56,15 @@ use lemmy_utils::{ utils::{ markdown::{image_links::markdown_rewrite_image_links, markdown_check_for_blocked_urls}, slurs::remove_slurs, - validation::{build_and_check_regex, clean_urls_in_text}, + validation::{ + build_and_check_regex, + clean_urls_in_text, + is_url_blocked, + is_valid_alt_text_field, + is_valid_post_title, + is_valid_url, + MAX_GALLERY_LENGTH, + }, }, CacheLock, CACHE_DURATION_FEDERATION, @@ -522,6 +532,60 @@ pub fn check_nsfw_allowed(nsfw: Option, local_site: Option<&LocalSite>) -> Ok(()) } +pub fn process_gallery( + gallery_items: Option<&Vec>, + url_blocklist: &RegexSet, +) -> LemmyResult>> { + if let Some(gallery_items) = gallery_items { + if gallery_items.len() > MAX_GALLERY_LENGTH { + Err(LemmyErrorType::TooManyItems)? + } + + let mut gallery_forms = vec![]; + + // Sort the items. Anything with a number is put at the start and ordered by + // that number. Ones without are pushed to the end, in the order they were received. + let mut gallery_items = gallery_items.clone(); + gallery_items.sort_by(|left, right| { + if let (Some(left), Some(right)) = (left.page, right.page) { + left.cmp(&right) + } else { + right.page.cmp(&left.page) + } + }); + + for (index, item) in gallery_items.iter().enumerate() { + let url = diesel_url_create(Some(&item.url))?.ok_or(LemmyErrorType::InvalidUrl)?; + is_url_blocked(&url, url_blocklist)?; + is_valid_url(&url)?; + + if let Some(alt_text) = &item.alt_text { + is_valid_alt_text_field(alt_text)?; + } + + if let Some(caption) = &item.caption { + if is_valid_post_title(caption).is_err() { + Err(LemmyErrorType::InvalidGalleryCaption)?; + } + } + + gallery_forms.push(PostGalleryInsertForm { + // We overwrite this later. + post_id: PostId(0), + page: index.try_into()?, + url, + url_content_type: None, + caption: item.caption.clone(), + alt_text: item.alt_text.clone(), + }); + } + + Ok(Some(gallery_forms)) + } else { + Ok(None) + } +} + /// Read the site for an ap_id. /// /// Used for GetCommunityResponse and GetPersonDetails @@ -537,6 +601,7 @@ pub async fn read_site_for_actor( pub async fn purge_post_images( url: Option, thumbnail_url: Option, + gallery: &[PostGallery], context: &LemmyContext, ) { if let Some(url) = url { @@ -547,6 +612,9 @@ pub async fn purge_post_images( .await .ok(); } + for item in gallery { + purge_image_from_pictrs_url(&item.url, context).await.ok(); + } } /// Delete a local_user's images diff --git a/crates/api_crud/src/post/create.rs b/crates/api_crud/src/post/create.rs index fefe82de3e..ee93927803 100644 --- a/crates/api_crud/src/post/create.rs +++ b/crates/api_crud/src/post/create.rs @@ -2,12 +2,13 @@ use super::convert_published_time; use crate::community_use_pending; use activitypub_federation::config::Data; use actix_web::web::Json; +use diesel_async::{scoped_futures::ScopedFutureExt, AsyncConnection}; use lemmy_api_common::{ build_response::{build_post_response, send_local_notifs}, context::LemmyContext, plugins::{plugin_hook_after, plugin_hook_before}, post::{CreatePost, PostResponse}, - request::generate_post_link_metadata, + request::{check_gallery_items_are_images, generate_post_link_metadata}, send_activity::SendActivityData, tags::update_post_tags, utils::{ @@ -15,24 +16,28 @@ use lemmy_api_common::{ check_nsfw_allowed, get_url_blocklist, honeypot_check, + process_gallery, process_markdown_opt, send_webmention, slur_regex, - }, + }, LemmyErrorType, }; use lemmy_db_schema::{ impls::actor_language::validate_post_language, newtypes::PostOrCommentId, - source::post::{Post, PostActions, PostInsertForm, PostLikeForm, PostReadForm}, + source::{ + post::{Post, PostActions, PostInsertForm, PostLikeForm, PostReadForm}, + post_gallery::{PostGallery, PostGalleryInsertForm}, + }, traits::{Crud, Likeable, Readable}, - utils::diesel_url_create, + utils::{diesel_url_create, get_conn}, }; use lemmy_db_views_community::CommunityView; use lemmy_db_views_community_moderator::CommunityModeratorView; use lemmy_db_views_local_user::LocalUserView; use lemmy_db_views_site::SiteView; use lemmy_utils::{ - error::LemmyResult, + error::{LemmyError, LemmyResult}, utils::{ mention::scrape_text_for_mentions, slurs::check_slurs, @@ -60,11 +65,16 @@ pub async fn create_post( let body = process_markdown_opt(&data.body, &slur_regex, &url_blocklist, &context).await?; let url = diesel_url_create(data.url.as_deref())?; + let gallery_forms = process_gallery(data.gallery.as_ref(), &url_blocklist)?; let custom_thumbnail = diesel_url_create(data.custom_thumbnail.as_deref())?; check_nsfw_allowed(data.nsfw, Some(&local_site))?; is_valid_post_title(&data.name)?; + if url.is_some() && gallery_forms.is_some() { + Err(LemmyErrorType::PostHasGalleryAndUrl)? + } + if let Some(url) = &url { is_url_blocked(url, &url_blocklist)?; is_valid_url(url)?; @@ -74,13 +84,22 @@ pub async fn create_post( is_valid_url(custom_thumbnail)?; } + if let Some(body) = &body { + is_valid_body_field(body, true)?; + } + if let Some(alt_text) = &data.alt_text { is_valid_alt_text_field(alt_text)?; } - if let Some(body) = &body { - is_valid_body_field(body, true)?; - } + let gallery_forms = if let Some(gallery_forms) = gallery_forms { + Some(check_gallery_items_are_images(&gallery_forms, &context).await?) + } else { + None + }; + let url_content_type = gallery_forms + .as_ref() + .and_then(|f| f.first().and_then(|item| item.url_content_type.clone())); let community_view = CommunityView::read( &mut context.pool(), @@ -119,6 +138,7 @@ pub async fn create_post( let scheduled_publish_time = convert_published_time(data.scheduled_publish_time, &local_user_view, &context).await?; + let mut post_form = PostInsertForm { url, body, @@ -127,6 +147,7 @@ pub async fn create_post( language_id: Some(language_id), federation_pending: Some(community_use_pending(community, &context).await), scheduled_publish_time, + url_content_type, ..PostInsertForm::new( data.name.trim().to_string(), local_user_view.person.id, @@ -136,7 +157,33 @@ pub async fn create_post( post_form = plugin_hook_before("before_create_local_post", post_form).await?; - let inserted_post = Post::create(&mut context.pool(), &post_form).await?; + let pool = &mut context.pool(); + let conn = &mut get_conn(pool).await?; + let inserted_post = conn + .transaction::<_, LemmyError, _>(|conn| { + async move { + let post = Post::create(&mut conn.into(), &post_form).await?; + + let _gallery = if let Some(gallery_forms) = gallery_forms { + let post_id = post.id; + let gallert_forms = gallery_forms + .iter() + .map(|f| PostGalleryInsertForm { + post_id, + ..f.clone() + }) + .collect::>(); + + Some(PostGallery::create_from_vec(&gallert_forms, &mut conn.into()).await?) + } else { + None + }; + + Ok(post) + } + .scope_boxed() + }) + .await?; plugin_hook_after("after_create_local_post", &inserted_post)?; @@ -179,7 +226,7 @@ pub async fn create_post( let do_send_email = !local_site.disable_email_notifications; send_local_notifs( mentions, - PostOrCommentId::Post(inserted_post.id), + PostOrCommentId::Post(post_id), &local_user_view.person, do_send_email, &context, diff --git a/crates/api_crud/src/post/update.rs b/crates/api_crud/src/post/update.rs index 096121645c..106f485039 100644 --- a/crates/api_crud/src/post/update.rs +++ b/crates/api_crud/src/post/update.rs @@ -2,18 +2,20 @@ use super::convert_published_time; use activitypub_federation::config::Data; use actix_web::web::Json; use chrono::Utc; +use diesel_async::{scoped_futures::ScopedFutureExt, AsyncConnection}; use lemmy_api_common::{ build_response::{build_post_response, send_local_notifs}, context::LemmyContext, plugins::{plugin_hook_after, plugin_hook_before}, post::{EditPost, PostResponse}, - request::generate_post_link_metadata, + request::{check_gallery_items_are_images, generate_post_link_metadata}, send_activity::SendActivityData, tags::update_post_tags, utils::{ check_community_user_action, check_nsfw_allowed, get_url_blocklist, + process_gallery, process_markdown_opt, send_webmention, slur_regex, @@ -25,16 +27,17 @@ use lemmy_db_schema::{ source::{ community::Community, post::{Post, PostUpdateForm}, + post_gallery::{PostGallery, PostGalleryInsertForm}, }, traits::Crud, - utils::{diesel_string_update, diesel_url_update}, + utils::{diesel_string_update, diesel_url_update, get_conn}, }; use lemmy_db_views_community::CommunityView; use lemmy_db_views_local_user::LocalUserView; use lemmy_db_views_post::PostView; use lemmy_db_views_site::SiteView; use lemmy_utils::{ - error::{LemmyErrorType, LemmyResult}, + error::{LemmyError, LemmyErrorType, LemmyResult}, utils::{ mention::scrape_text_for_mentions, slurs::check_slurs, @@ -56,12 +59,13 @@ pub async fn update_post( ) -> LemmyResult> { let local_site = SiteView::read_local(&mut context.pool()).await?.local_site; let local_instance_id = local_user_view.person.instance_id; - let url = diesel_url_update(data.url.as_deref())?; let custom_thumbnail = diesel_url_update(data.custom_thumbnail.as_deref())?; let url_blocklist = get_url_blocklist(&context).await?; - + let url = diesel_url_update(data.url.as_deref())?; + let gallery_forms = process_gallery(data.gallery.as_ref(), &url_blocklist)?; + let alt_text = diesel_string_update(data.alt_text.as_deref()); let slur_regex = slur_regex(&context).await?; let body = diesel_string_update( @@ -72,7 +76,14 @@ pub async fn update_post( check_nsfw_allowed(data.nsfw, Some(&local_site))?; - let alt_text = diesel_string_update(data.alt_text.as_deref()); + if url.is_some() && gallery_forms.is_some() { + Err(LemmyErrorType::PostHasGalleryAndUrl)? + } + + if let Some(Some(url)) = &url { + is_url_blocked(url, &url_blocklist)?; + is_valid_url(url)?; + } if let Some(name) = &data.name { is_valid_post_title(name)?; @@ -87,11 +98,6 @@ pub async fn update_post( is_valid_alt_text_field(alt_text)?; } - if let Some(Some(url)) = &url { - is_url_blocked(url, &url_blocklist)?; - is_valid_url(url)?; - } - if let Some(Some(custom_thumbnail)) = &custom_thumbnail { is_valid_url(custom_thumbnail)?; } @@ -99,6 +105,7 @@ pub async fn update_post( let post_id = data.post_id; let orig_post = PostView::read(&mut context.pool(), post_id, None, local_instance_id, false).await?; + let orig_gallery = PostGallery::list_from_post_id(post_id, &mut context.pool()).await?; check_community_user_action(&local_user_view, &orig_post.community, &mut context.pool()).await?; @@ -121,6 +128,18 @@ pub async fn update_post( Err(LemmyErrorType::NoPostEditAllowed)? } + if !orig_gallery.is_empty() && gallery_forms.is_none() { + PostGallery::delete_from_post_id(post_id, &mut context.pool()).await?; + } + let gallery_forms = if let Some(gallery_forms) = gallery_forms { + Some(check_gallery_items_are_images(&gallery_forms, &context).await?) + } else { + None + }; + let url_content_type = gallery_forms + .as_ref() + .map(|f| f.first().and_then(|item| item.url_content_type.clone())); + let language_id = validate_post_language( &mut context.pool(), data.language_id, @@ -145,21 +164,45 @@ pub async fn update_post( }; let mut post_form = PostUpdateForm { - name: data.name.clone(), url, + name: data.name.clone(), body, - alt_text, nsfw: data.nsfw, language_id: Some(language_id), updated: Some(Some(Utc::now())), scheduled_publish_time, + url_content_type, ..Default::default() }; post_form = plugin_hook_before("before_update_local_post", post_form).await?; let post_id = data.post_id; - let updated_post = Post::update(&mut context.pool(), post_id, &post_form).await?; - plugin_hook_after("after_update_local_post", &post_form)?; + let pool = &mut context.pool(); + let conn = &mut get_conn(pool).await?; + let updated_post = conn + .transaction::<_, LemmyError, _>(|conn| { + async move { + let post = Post::update(&mut conn.into(), post_id, &post_form).await?; + + if let Some(gallery_forms) = gallery_forms { + let gallert_forms = gallery_forms + .iter() + .map(|f| PostGalleryInsertForm { + post_id, + ..f.clone() + }) + .collect::>(); + + PostGallery::create_from_vec(&gallert_forms, &mut conn.into()).await?; + } + + Ok(post) + } + .scope_boxed() + }) + .await?; + + plugin_hook_after("after_update_local_post", &updated_post.clone())?; // Scan the post body for user mentions, add those rows let mentions = scrape_text_for_mentions(&updated_post.body.clone().unwrap_or_default()); diff --git a/crates/apub_objects/src/objects/post.rs b/crates/apub_objects/src/objects/post.rs index e7540d321d..2e604b6016 100644 --- a/crates/apub_objects/src/objects/post.rs +++ b/crates/apub_objects/src/objects/post.rs @@ -1,11 +1,5 @@ use crate::{ - protocol::page::{ - Attachment, - Hashtag, - HashtagType::{self}, - Page, - PageType, - }, + protocol::page::{Attachment, Hashtag, HashtagType, Page, PageType}, utils::{ functions::{ check_apub_id_valid_with_strictness, @@ -32,7 +26,7 @@ use html2text::{from_read_with_decorator, render::TrivialDecorator}; use lemmy_api_common::{ context::LemmyContext, plugins::{plugin_hook_after, plugin_hook_before}, - request::generate_post_link_metadata, + request::{check_gallery_items_are_images, generate_post_link_metadata}, utils::{check_nsfw_allowed, get_url_blocklist, process_markdown_opt, slur_regex}, }; use lemmy_db_schema::{ @@ -40,6 +34,7 @@ use lemmy_db_schema::{ community::Community, person::Person, post::{Post, PostInsertForm, PostUpdateForm}, + post_gallery::{PostGallery, PostGalleryInsertForm}, }, traits::Crud, }; @@ -51,7 +46,7 @@ use lemmy_utils::{ utils::{ markdown::markdown_to_html, slurs::check_slurs_opt, - validation::{is_url_blocked, is_valid_url}, + validation::{is_url_blocked, is_valid_url, truncate_for_db, MAX_GALLERY_LENGTH}, }, }; use std::ops::Deref; @@ -116,19 +111,31 @@ impl Object for ApubPost { let community_id = self.community_id; let community = Community::read(&mut context.pool(), community_id).await?; let language = Some(LanguageTag::new_single(self.language_id, &mut context.pool()).await?); - - let attachment = self - .url - .clone() - .map(|url| { - Attachment::new( - url.into(), - self.url_content_type.clone(), - self.alt_text.clone(), - ) - }) - .into_iter() - .collect(); + let gallery = PostGallery::list_from_post_id(self.id, &mut context.pool()).await?; + + let url = self.url.clone().map(|url| { + Attachment::new( + url.into(), + self.url_content_type.clone(), + self.alt_text.clone(), + None, + ) + }); + let attachment = if url.is_some() { + url.into_iter().collect() + } else { + gallery + .iter() + .map(|item| { + Attachment::new( + item.url.clone().into(), + item.url_content_type.clone(), + item.alt_text.clone(), + item.caption.clone(), + ) + }) + .collect() + }; let hashtag = Hashtag { href: self.ap_id.clone().into(), name: format!("#{}", &community.name), @@ -226,8 +233,22 @@ impl Object for ApubPost { name = name.chars().take(MAX_TITLE_LENGTH).collect(); } + let is_gallery = page + .attachment + .iter() + .filter(|a| { + let content_type = a.url_content_type(); + content_type + .as_ref() + .is_some_and(|s| !s.starts_with("image")) + || content_type.is_none() + }) + .count() + != 0 + && page.attachment.len() > 1; + let first_attachment = page.attachment.first(); - let url = if let Some(attachment) = first_attachment.cloned() { + let url = if let (Some(attachment), false) = (first_attachment.cloned(), is_gallery) { Some(attachment.url()) } else if page.kind == PageType::Video { // we cant display videos directly, so insert a link to external video page @@ -268,7 +289,11 @@ impl Object for ApubPost { None }; - let alt_text = first_attachment.cloned().and_then(Attachment::alt_text); + let alt_text = if !is_gallery { + first_attachment.cloned().and_then(Attachment::alt_text) + } else { + None + }; let slur_regex = slur_regex(context).await?; @@ -298,14 +323,74 @@ impl Object for ApubPost { let timestamp = page.updated.or(page.published).unwrap_or_else(Utc::now); let post = Post::insert_apub(&mut context.pool(), timestamp, &form).await?; plugin_hook_after("after_receive_federated_post", &post)?; + + let post_id = post.id; + let gallery_forms = if is_gallery { + let len: i32 = page.attachment.len().min(MAX_GALLERY_LENGTH).try_into()?; + Some( + page + .attachment + .iter() + .zip(0..len) + .map(|a| { + let (att, index) = a; + PostGalleryInsertForm { + url: att.clone().url().into(), + post_id, + url_content_type: att.url_content_type(), + alt_text: att.clone().alt_text(), + caption: att + .clone() + .caption() + .map(|c| truncate_for_db(&c, MAX_TITLE_LENGTH)), + page: index, + } + }) + .collect::>(), + ) + } else { + None + }; + let post_ = post.clone(); let context_ = context.reset_request_count(); // Generates a post thumbnail in background task, because some sites can be very slow to // respond. - spawn_try_task( - async move { generate_post_link_metadata(post_, None, |_| None, context_).await }, - ); + spawn_try_task(async move { + if let Some(gallery_forms) = gallery_forms { + let (no_content_type, has_content_type): (Vec<_>, Vec<_>) = gallery_forms + .into_iter() + .partition(|f| f.url_content_type.is_none()); + + match check_gallery_items_are_images(&no_content_type, &context_).await { + // Treat posts with multiple attachments that aren't images as single link post + Err(_) => { + let mut gallery_forms = no_content_type.clone(); + gallery_forms.extend(has_content_type); + + let url = gallery_forms + .iter() + .find(|f| f.page == 0) + .map(|f| f.url.clone()); + + let form = PostUpdateForm { + url: Some(url), + ..Default::default() + }; + Post::update(&mut context_.pool(), post_id, &form).await?; + generate_post_link_metadata(post_, None, |_| None, context_).await + } + Ok(mut gallery_forms) => { + gallery_forms.extend(has_content_type); + PostGallery::create_from_vec(&gallery_forms, &mut context_.pool()).await?; + Ok(()) + } + } + } else { + generate_post_link_metadata(post_, None, |_| None, context_).await + } + }); Ok(post.into()) } diff --git a/crates/apub_objects/src/protocol/page.rs b/crates/apub_objects/src/protocol/page.rs index a62f40c62c..7c986c60c5 100644 --- a/crates/apub_objects/src/protocol/page.rs +++ b/crates/apub_objects/src/protocol/page.rs @@ -89,6 +89,9 @@ pub struct Image { url: Url, /// Used for alt_text name: Option, + media_type: Option, + /// Used to caption the image + summary: Option, } #[derive(Clone, Debug, Deserialize, Serialize)] @@ -130,6 +133,21 @@ impl Attachment { } } + pub(crate) fn url_content_type(&self) -> Option { + match self { + Attachment::Image(i) => i.media_type.clone(), + Attachment::Document(d) => d.media_type.clone(), + Attachment::Link(l) => l.media_type.clone(), + } + } + + pub(crate) fn caption(self) -> Option { + match self { + Attachment::Image(i) => i.summary, + _ => None, + } + } + pub(crate) async fn as_markdown(&self, context: &Data) -> LemmyResult { let (url, name, media_type) = match self { Attachment::Image(i) => (i.url.clone(), i.name.clone(), Some(String::from("image"))), @@ -180,13 +198,20 @@ impl Page { impl Attachment { /// Creates new attachment for a given link and mime type. - pub(crate) fn new(url: Url, media_type: Option, alt_text: Option) -> Attachment { + pub(crate) fn new( + url: Url, + media_type: Option, + alt_text: Option, + caption: Option, + ) -> Attachment { let is_image = media_type.clone().unwrap_or_default().starts_with("image"); if is_image { Attachment::Image(Image { kind: Default::default(), url, + media_type, name: alt_text, + summary: caption, }) } else { Attachment::Link(Link { diff --git a/crates/db_schema/src/impls/mod.rs b/crates/db_schema/src/impls/mod.rs index c440e92bbd..494c1d56a2 100644 --- a/crates/db_schema/src/impls/mod.rs +++ b/crates/db_schema/src/impls/mod.rs @@ -28,6 +28,7 @@ pub mod person; pub mod person_comment_mention; pub mod person_post_mention; pub mod post; +pub mod post_gallery; pub mod post_report; pub mod post_tag; pub mod private_message; diff --git a/crates/db_schema/src/impls/post_gallery.rs b/crates/db_schema/src/impls/post_gallery.rs new file mode 100644 index 0000000000..9c75da95a3 --- /dev/null +++ b/crates/db_schema/src/impls/post_gallery.rs @@ -0,0 +1,106 @@ +use crate::{ + newtypes::{PostGalleryId, PostId}, + source::post_gallery::{PostGallery, PostGalleryInsertForm, PostGalleryView}, + traits::Crud, + utils::{get_conn, DbPool}, +}; +use diesel::{ + deserialize::FromSql, + insert_into, + pg::{Pg, PgValue}, + serialize::ToSql, + sql_types::{self, Nullable}, + ExpressionMethods, + QueryDsl, +}; +use diesel_async::RunQueryDsl; +use lemmy_db_schema_file::schema::post_gallery; +use lemmy_utils::error::{LemmyErrorExt, LemmyErrorType, LemmyResult}; + +impl Crud for PostGallery { + type InsertForm = PostGalleryInsertForm; + type UpdateForm = PostGalleryInsertForm; + type IdType = PostGalleryId; + + async fn create(pool: &mut DbPool<'_>, form: &Self::InsertForm) -> LemmyResult { + let conn = &mut get_conn(pool).await?; + insert_into(post_gallery::table) + .values(form) + .on_conflict((post_gallery::post_id, post_gallery::url)) + .do_update() + .set(form) + .get_result::(conn) + .await + .with_lemmy_type(LemmyErrorType::CouldntCreateGalleryItem) + } + + async fn update( + pool: &mut DbPool<'_>, + post_url_id: Self::IdType, + form: &Self::UpdateForm, + ) -> LemmyResult { + let conn = &mut get_conn(pool).await?; + diesel::update(post_gallery::table.find(post_url_id)) + .set(form) + .get_result::(conn) + .await + .with_lemmy_type(LemmyErrorType::CouldntCreateGalleryItem) + } +} + +impl PostGallery { + pub async fn list_from_post_id(post_id: PostId, pool: &mut DbPool<'_>) -> LemmyResult> { + let conn = &mut get_conn(pool).await?; + post_gallery::table + .filter(post_gallery::post_id.eq(post_id)) + .order(post_gallery::page) + .load::(conn) + .await + .with_lemmy_type(LemmyErrorType::NotFound) + } + + pub async fn create_from_vec( + forms: &Vec, + pool: &mut DbPool<'_>, + ) -> LemmyResult> { + let conn = &mut get_conn(pool).await?; + insert_into(post_gallery::table) + .values(forms) + .get_results::(conn) + .await + .with_lemmy_type(LemmyErrorType::CouldntCreateGalleryItem) + } + + pub async fn delete_from_post_id( + post_id: PostId, + pool: &mut DbPool<'_>, + ) -> LemmyResult> { + let conn = &mut get_conn(pool).await?; + diesel::delete(post_gallery::table) + .filter(post_gallery::post_id.eq(post_id)) + .get_results::(conn) + .await + .with_lemmy_type(LemmyErrorType::Deleted) + } +} + +impl FromSql, Pg> for PostGalleryView { + fn from_sql(bytes: PgValue) -> diesel::deserialize::Result { + let value = >::from_sql(bytes)?; + Ok(serde_json::from_value::(value)?) + } + + fn from_nullable_sql(bytes: Option) -> diesel::deserialize::Result { + match bytes { + Some(bytes) => Self::from_sql(bytes), + None => Ok(Self(vec![])), + } + } +} + +impl ToSql, Pg> for PostGalleryView { + fn to_sql(&self, out: &mut diesel::serialize::Output) -> diesel::serialize::Result { + let value = serde_json::to_value(self)?; + >::to_sql(&value, &mut out.reborrow()) + } +} diff --git a/crates/db_schema/src/newtypes.rs b/crates/db_schema/src/newtypes.rs index 35e4788d02..fcc8fdf5d1 100644 --- a/crates/db_schema/src/newtypes.rs +++ b/crates/db_schema/src/newtypes.rs @@ -37,6 +37,18 @@ impl fmt::Display for PostId { } } +#[derive(Debug, Copy, Clone, Hash, Eq, PartialEq, Default, Serialize, Deserialize)] +#[cfg_attr(feature = "full", derive(DieselNewType, TS))] +#[cfg_attr(feature = "full", ts(export))] +/// The post url id. +pub struct PostGalleryId(pub i32); + +impl fmt::Display for PostGalleryId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + #[derive(Debug, Copy, Clone, Hash, Eq, PartialEq, Default, Serialize, Deserialize)] #[cfg_attr(feature = "full", derive(DieselNewType, TS))] #[cfg_attr(feature = "full", ts(export))] diff --git a/crates/db_schema/src/source/mod.rs b/crates/db_schema/src/source/mod.rs index ccb77bc942..f54169eae4 100644 --- a/crates/db_schema/src/source/mod.rs +++ b/crates/db_schema/src/source/mod.rs @@ -34,6 +34,7 @@ pub mod person; pub mod person_comment_mention; pub mod person_post_mention; pub mod post; +pub mod post_gallery; pub mod post_report; pub mod post_tag; pub mod private_message; diff --git a/crates/db_schema/src/source/post_gallery.rs b/crates/db_schema/src/source/post_gallery.rs new file mode 100644 index 0000000000..a44f6d18f2 --- /dev/null +++ b/crates/db_schema/src/source/post_gallery.rs @@ -0,0 +1,52 @@ +use crate::newtypes::{DbUrl, PostGalleryId, PostId}; +use chrono::{DateTime, Utc}; +use diesel::sql_types; +use serde::{Deserialize, Serialize}; +use serde_with::skip_serializing_none; +#[cfg(feature = "full")] +use {lemmy_db_schema_file::schema::post_gallery, ts_rs::TS}; + +#[skip_serializing_none] +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[cfg_attr(feature = "full", derive(Queryable, Selectable, TS, Identifiable))] +#[cfg_attr(feature = "full", ts(export))] +#[cfg_attr(feature = "full", diesel(table_name = post_gallery))] +#[cfg_attr(feature = "full", diesel(belongs_to(post)))] +#[cfg_attr(feature = "full", diesel(check_for_backend(diesel::pg::Pg)))] +pub struct PostGallery { + pub id: PostGalleryId, + #[serde(skip)] + pub post_id: PostId, + pub url: DbUrl, + pub page: i32, + // An optional alt_text, usable for image posts. + #[cfg_attr(feature = "full", ts(optional))] + pub alt_text: Option, + #[cfg_attr(feature = "full", ts(optional))] + pub caption: Option, + #[cfg_attr(feature = "full", ts(optional))] + pub url_content_type: Option, + #[serde(skip)] + pub published: DateTime, +} + +#[derive(Debug, Clone, derive_new::new)] +#[cfg_attr(feature = "full", derive(Insertable, AsChangeset))] +#[cfg_attr(feature = "full", diesel(table_name = post_gallery))] +pub struct PostGalleryInsertForm { + pub post_id: PostId, + pub page: i32, + pub url: DbUrl, + #[new(default)] + pub url_content_type: Option, + #[new(default)] + pub caption: Option, + #[new(default)] + pub alt_text: Option, +} + +#[derive(Clone, Serialize, Deserialize, Debug, PartialEq, Default)] +#[cfg_attr(feature = "full", derive(TS, FromSqlRow, AsExpression))] +#[serde(transparent)] +#[cfg_attr(feature = "full", diesel(sql_type = Nullable))] +pub struct PostGalleryView(pub Vec); diff --git a/crates/db_schema/src/utils/queries.rs b/crates/db_schema/src/utils/queries.rs index 75e7867044..b0c2621c19 100644 --- a/crates/db_schema/src/utils/queries.rs +++ b/crates/db_schema/src/utils/queries.rs @@ -40,6 +40,7 @@ use lemmy_db_schema_file::{ person_actions, post, post_actions, + post_gallery, post_tag, tag, }, @@ -94,6 +95,16 @@ pub fn post_creator_is_admin() -> _ { ) } +#[diesel::dsl::auto_type] +pub fn post_get_gallery() -> _ { + let sel: SqlLiteral = + diesel::dsl::sql::("json_agg(post_gallery.*)"); + post_gallery::table + .select(sel) + .filter(post_gallery::post_id.eq(post::id)) + .single_value() +} + #[diesel::dsl::auto_type] /// Checks to see if a user is site banned from any of these places: /// - Their own instance diff --git a/crates/db_schema_file/src/schema.rs b/crates/db_schema_file/src/schema.rs index df2b215ef2..936d01eeed 100644 --- a/crates/db_schema_file/src/schema.rs +++ b/crates/db_schema_file/src/schema.rs @@ -894,6 +894,21 @@ diesel::table! { } } +diesel::table! { + post_gallery (id) { + id -> Int4, + post_id -> Int4, + #[max_length = 2000] + url -> Varchar, + page -> Int4, + alt_text -> Nullable, + #[max_length = 200] + caption -> Nullable, + url_content_type -> Nullable, + published -> Timestamptz, + } +} + diesel::table! { post_report (id) { id -> Int4, @@ -1177,6 +1192,7 @@ diesel::joinable!(post -> language (language_id)); diesel::joinable!(post -> person (creator_id)); diesel::joinable!(post_actions -> person (person_id)); diesel::joinable!(post_actions -> post (post_id)); +diesel::joinable!(post_gallery -> post (post_id)); diesel::joinable!(post_report -> post (post_id)); diesel::joinable!(post_tag -> post (post_id)); diesel::joinable!(post_tag -> tag (tag_id)); @@ -1255,6 +1271,7 @@ diesel::allow_tables_to_appear_in_same_query!( person_saved_combined, post, post_actions, + post_gallery, post_report, post_tag, previously_run_sql, diff --git a/crates/db_views/person_content_combined/src/impls.rs b/crates/db_views/person_content_combined/src/impls.rs index db1f9abeb4..99a9d4c951 100644 --- a/crates/db_views/person_content_combined/src/impls.rs +++ b/crates/db_views/person_content_combined/src/impls.rs @@ -265,6 +265,7 @@ impl InternalToCombinedView for PersonContentCombinedViewInternal { tags: v.post_tags, can_mod: v.can_mod, creator_banned: v.creator_banned, + gallery: v.post_gallery, })) } } diff --git a/crates/db_views/person_content_combined/src/lib.rs b/crates/db_views/person_content_combined/src/lib.rs index fb1582ed66..aa7f0ebcc4 100644 --- a/crates/db_views/person_content_combined/src/lib.rs +++ b/crates/db_views/person_content_combined/src/lib.rs @@ -8,6 +8,7 @@ use lemmy_db_schema::{ instance::InstanceActions, person::{Person, PersonActions}, post::{Post, PostActions}, + post_gallery::PostGalleryView, tag::TagsView, }, PersonContentType, @@ -27,6 +28,7 @@ use { creator_is_admin, creator_local_instance_actions_select, local_user_can_mod, + post_get_gallery, post_tags_fragment, }, CreatorCommunityActionsAllColumnsTuple, @@ -106,6 +108,12 @@ pub(crate) struct PersonContentCombinedViewInternal { ) )] pub creator_banned: bool, + #[cfg_attr(feature = "full", + diesel( + select_expression = post_get_gallery() + ) + )] + post_gallery: PostGalleryView, } #[derive(Debug, PartialEq, Serialize, Deserialize, Clone)] diff --git a/crates/db_views/person_saved_combined/src/impls.rs b/crates/db_views/person_saved_combined/src/impls.rs index 82b8da4a46..7b6cee9d67 100644 --- a/crates/db_views/person_saved_combined/src/impls.rs +++ b/crates/db_views/person_saved_combined/src/impls.rs @@ -243,6 +243,7 @@ impl InternalToCombinedView for PersonSavedCombinedViewInternal { tags: v.post_tags, can_mod: v.can_mod, creator_banned: v.creator_banned, + gallery: v.post_gallery, })) } } diff --git a/crates/db_views/person_saved_combined/src/lib.rs b/crates/db_views/person_saved_combined/src/lib.rs index 27947035cd..16bb5b75e2 100644 --- a/crates/db_views/person_saved_combined/src/lib.rs +++ b/crates/db_views/person_saved_combined/src/lib.rs @@ -8,6 +8,7 @@ use lemmy_db_schema::{ instance::InstanceActions, person::{Person, PersonActions}, post::{Post, PostActions}, + post_gallery::PostGalleryView, tag::TagsView, }, PersonContentType, @@ -27,6 +28,7 @@ use { creator_is_admin, creator_local_instance_actions_select, local_user_can_mod, + post_get_gallery, post_tags_fragment, }, CreatorCommunityActionsAllColumnsTuple, @@ -106,6 +108,12 @@ pub(crate) struct PersonSavedCombinedViewInternal { ) )] pub creator_banned: bool, + #[cfg_attr(feature = "full", + diesel( + select_expression = post_get_gallery() + ) + )] + post_gallery: PostGalleryView, } #[derive(Debug, PartialEq, Serialize, Deserialize, Clone)] diff --git a/crates/db_views/post/src/impls.rs b/crates/db_views/post/src/impls.rs index 7e278f55c3..b951067935 100644 --- a/crates/db_views/post/src/impls.rs +++ b/crates/db_views/post/src/impls.rs @@ -106,6 +106,7 @@ impl PostView { .left_join(creator_home_instance_actions_join()) .left_join(creator_local_instance_actions_join) .left_join(creator_community_actions_join()) + // .left_join(post_url_join) } pub async fn read( diff --git a/crates/db_views/post/src/lib.rs b/crates/db_views/post/src/lib.rs index 7e2fb4460a..ebe86ad6f8 100644 --- a/crates/db_views/post/src/lib.rs +++ b/crates/db_views/post/src/lib.rs @@ -4,6 +4,7 @@ use lemmy_db_schema::source::{ instance::InstanceActions, person::{Person, PersonActions}, post::{Post, PostActions}, + post_gallery::PostGalleryView, tag::TagsView, }; use serde::{Deserialize, Serialize}; @@ -19,6 +20,7 @@ use { creator_local_instance_actions_select, local_user_can_mod_post, post_creator_is_admin, + post_get_gallery, post_tags_fragment, }, CreatorCommunityActionsAllColumnsTuple, @@ -40,6 +42,10 @@ pub mod impls; pub struct PostView { #[cfg_attr(feature = "full", diesel(embed))] pub post: Post, + #[cfg_attr(feature = "full", diesel( + select_expression = post_get_gallery() + ))] + pub gallery: PostGalleryView, #[cfg_attr(feature = "full", diesel(embed))] pub creator: Person, #[cfg_attr(feature = "full", diesel(embed))] diff --git a/crates/db_views/search_combined/src/impls.rs b/crates/db_views/search_combined/src/impls.rs index 7233900695..629417ef83 100644 --- a/crates/db_views/search_combined/src/impls.rs +++ b/crates/db_views/search_combined/src/impls.rs @@ -410,6 +410,7 @@ impl InternalToCombinedView for SearchCombinedViewInternal { tags: v.post_tags, can_mod: v.can_mod, creator_banned: v.creator_banned, + gallery: v.post_gallery, })) } else if let Some(community) = v.community { Some(SearchCombinedView::Community(CommunityView { diff --git a/crates/db_views/search_combined/src/lib.rs b/crates/db_views/search_combined/src/lib.rs index 82d7929b1c..4c2d511e3f 100644 --- a/crates/db_views/search_combined/src/lib.rs +++ b/crates/db_views/search_combined/src/lib.rs @@ -8,6 +8,7 @@ use lemmy_db_schema::{ instance::InstanceActions, person::{Person, PersonActions}, post::{Post, PostActions}, + post_gallery::PostGalleryView, tag::TagsView, }, SearchSortType, @@ -32,6 +33,7 @@ use { creator_is_admin, creator_local_instance_actions_select, local_user_can_mod, + post_get_gallery, post_tags_fragment, }, CreatorCommunityActionsAllColumnsTuple, @@ -119,6 +121,12 @@ pub(crate) struct SearchCombinedViewInternal { ) )] pub creator_banned: bool, + #[cfg_attr(feature = "full", + diesel( + select_expression = post_get_gallery() + ) + )] + post_gallery: PostGalleryView, } #[derive(Debug, PartialEq, Serialize, Deserialize, Clone)] diff --git a/crates/utils/src/error.rs b/crates/utils/src/error.rs index 59ffaabcfc..42555f8dec 100644 --- a/crates/utils/src/error.rs +++ b/crates/utils/src/error.rs @@ -34,6 +34,7 @@ pub enum LemmyErrorType { NotAnImageType, InvalidImageUpload, ImageUploadDisabled, + UrlNotImage(String), NotAModOrAdmin, NotTopMod, NotLoggedIn, @@ -83,6 +84,7 @@ pub enum LemmyErrorType { InvalidMatrixId, InvalidPostTitle, InvalidBodyField, + InvalidGalleryCaption, BioLengthOverflow, AltTextLengthOverflow, MissingTotpToken, @@ -191,7 +193,9 @@ pub enum LemmyErrorType { CouldntCreateLoginToken, CouldntUpdateLocalSiteUrlBlocklist, CouldntCreateEmailVerification, + CouldntCreateGalleryItem, EmailNotificationsDisabled, + PostHasGalleryAndUrl, } /// Federation related errors, these dont need to be translated. diff --git a/crates/utils/src/utils/validation.rs b/crates/utils/src/utils/validation.rs index fee8f80dab..4abba51230 100644 --- a/crates/utils/src/utils/validation.rs +++ b/crates/utils/src/utils/validation.rs @@ -30,6 +30,7 @@ const MIN_LENGTH_BLOCKING_KEYWORD: usize = 3; const MAX_LENGTH_BLOCKING_KEYWORD: usize = 50; const TAG_NAME_MIN_LENGTH: usize = 3; const TAG_NAME_MAX_LENGTH: usize = 100; +pub const MAX_GALLERY_LENGTH: usize = 25; //Invisible unicode characters, taken from https://invisible-characters.com/ const FORBIDDEN_DISPLAY_CHARS: [char; 53] = [ '\u{0009}', diff --git a/migrations/2025-03-14-040229_gallery/down.sql b/migrations/2025-03-14-040229_gallery/down.sql new file mode 100644 index 0000000000..751c425c87 --- /dev/null +++ b/migrations/2025-03-14-040229_gallery/down.sql @@ -0,0 +1,14 @@ +UPDATE + post +SET + url = post_gallery.url, + alt_text = post_gallery.alt_text, + url_content_type = post_gallery.url_content_type +FROM + post_gallery +WHERE + post_gallery.post_id = post.id + AND post_gallery.page = 0; + +DROP TABLE post_gallery; + diff --git a/migrations/2025-03-14-040229_gallery/up.sql b/migrations/2025-03-14-040229_gallery/up.sql new file mode 100644 index 0000000000..49b40de367 --- /dev/null +++ b/migrations/2025-03-14-040229_gallery/up.sql @@ -0,0 +1,11 @@ +CREATE TABLE post_gallery ( + id serial NOT NULL PRIMARY KEY, + post_id integer REFERENCES post ON UPDATE CASCADE ON DELETE CASCADE NOT NULL, + url character varying(2000) NOT NULL, + page integer NOT NULL DEFAULT 0, + alt_text text, + caption character varying(200), + url_content_type text, + published timestamp with time zone NOT NULL DEFAULT now() +); +