diff --git a/api_tests/package.json b/api_tests/package.json index 9dd1b3ca6d..feabf6b928 100644 --- a/api_tests/package.json +++ b/api_tests/package.json @@ -29,7 +29,7 @@ "eslint": "^9.20.0", "eslint-plugin-prettier": "^5.2.3", "jest": "^29.5.0", - "lemmy-js-client": "0.20.0-api-no-optional-vec.1", + "lemmy-js-client": "1.0.0-block-nsfw.1", "prettier": "^3.5.0", "ts-jest": "^29.1.0", "tsoa": "^6.6.0", diff --git a/api_tests/pnpm-lock.yaml b/api_tests/pnpm-lock.yaml index 18028e46fd..0de9c3909b 100644 --- a/api_tests/pnpm-lock.yaml +++ b/api_tests/pnpm-lock.yaml @@ -33,8 +33,8 @@ importers: specifier: ^29.5.0 version: 29.7.0(@types/node@22.13.1) lemmy-js-client: - specifier: 0.20.0-api-no-optional-vec.1 - version: 0.20.0-api-no-optional-vec.1 + specifier: 1.0.0-block-nsfw.1 + version: 1.0.0-block-nsfw.1 prettier: specifier: ^3.5.0 version: 3.5.0 @@ -1528,8 +1528,8 @@ packages: resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} engines: {node: '>=6'} - lemmy-js-client@0.20.0-api-no-optional-vec.1: - resolution: {integrity: sha512-oIlTCiriuZVzTMScix4ubJyIOf3x0FPpnxCfm12EYbiix3Z9D44XMWs3JTV+ipJgmiAqgAiGhI0fF35RNu3FjQ==} + lemmy-js-client@1.0.0-block-nsfw.1: + resolution: {integrity: sha512-7dIGSflkfl6JZ57tNNwoI4xwHc3uMkj9mp3lMMUh+DkSnvUNEc1BCk4sBGLYJTXGr4XreeJT99bM67RO8fGkmA==} leven@3.1.0: resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} @@ -4169,7 +4169,7 @@ snapshots: kleur@3.0.3: {} - lemmy-js-client@0.20.0-api-no-optional-vec.1: {} + lemmy-js-client@1.0.0-block-nsfw.1: {} leven@3.1.0: {} diff --git a/api_tests/src/post.spec.ts b/api_tests/src/post.spec.ts index 72b0b32e03..65d3ebf4d2 100644 --- a/api_tests/src/post.spec.ts +++ b/api_tests/src/post.spec.ts @@ -46,6 +46,7 @@ import { AdminBlockInstanceParams } from "lemmy-js-client/dist/types/AdminBlockI import { AddModToCommunity, EditSite, + EditPost, PersonPostMentionView, PostReport, PostReportView, @@ -927,6 +928,50 @@ test("Rewrite markdown links", async () => { ); }); +test("Don't allow NSFW posts on instances that disable it", async () => { + // Disallow NSFW on gamma + let editSiteForm: EditSite = { + disallow_nsfw_content: true, + }; + await gamma.editSite(editSiteForm); + + // Wait for cache on Gamma's LocalSite + await delay(1_000); + + if (!betaCommunity) { + throw "Missing beta community"; + } + + // Make a NSFW post + let postRes = await createPost(beta, betaCommunity.community.id); + let form: EditPost = { + nsfw: true, + post_id: postRes.post_view.post.id, + }; + let updatePost = await beta.editPost(form); + + // Gamma reject resolving the post + await expect( + resolvePost(gamma, updatePost.post_view.post), + ).rejects.toStrictEqual(Error("not_found")); + + // Local users can't create NSFW post on Gamma + let gammaCommunity = ( + await resolveCommunity(gamma, betaCommunity.community.ap_id) + ).community?.community; + if (!gammaCommunity) { + throw "Missing gamma community"; + } + let gammaPost = await createPost(gamma, gammaCommunity.id); + let form2: EditPost = { + nsfw: true, + post_id: gammaPost.post_view.post.id, + }; + await expect(gamma.editPost(form2)).rejects.toStrictEqual( + Error("nsfw_not_allowed"), + ); +}); + function checkPostReportName(rcv: ReportCombinedView, report: PostReport) { switch (rcv.type_) { case "Post": diff --git a/crates/api/src/site/purge/post.rs b/crates/api/src/site/purge/post.rs index d0d64d36b5..c94109eec0 100644 --- a/crates/api/src/site/purge/post.rs +++ b/crates/api/src/site/purge/post.rs @@ -2,10 +2,9 @@ use activitypub_federation::config::Data; use actix_web::web::Json; use lemmy_api_common::{ context::LemmyContext, - request::purge_image_from_pictrs, send_activity::{ActivityChannel, SendActivityData}, site::PurgePost, - utils::is_admin, + utils::{is_admin, purge_post_images}, SuccessResponse, }; use lemmy_db_schema::{ @@ -38,14 +37,7 @@ pub async fn purge_post( ) .await?; - // Purge image - if let Some(url) = &post.url { - purge_image_from_pictrs(url, &context).await.ok(); - } - // Purge thumbnail - if let Some(thumbnail_url) = &post.thumbnail_url { - purge_image_from_pictrs(thumbnail_url, &context).await.ok(); - } + purge_post_images(post.url.clone(), post.thumbnail_url.clone(), &context).await; Post::delete(&mut context.pool(), data.post_id).await?; diff --git a/crates/api_common/src/site.rs b/crates/api_common/src/site.rs index 601aed5bd6..348cd5fd25 100644 --- a/crates/api_common/src/site.rs +++ b/crates/api_common/src/site.rs @@ -254,6 +254,8 @@ pub struct CreateSite { pub comment_downvotes: Option, #[cfg_attr(feature = "full", ts(optional))] pub disable_donation_dialog: Option, + #[cfg_attr(feature = "full", ts(optional))] + pub disallow_nsfw_content: Option, } #[skip_serializing_none] @@ -388,6 +390,9 @@ pub struct EditSite { /// donations. #[cfg_attr(feature = "full", ts(optional))] pub disable_donation_dialog: Option, + /// Block NSFW content being created + #[cfg_attr(feature = "full", ts(optional))] + pub disallow_nsfw_content: Option, } #[derive(Debug, Serialize, Deserialize, Clone)] diff --git a/crates/api_common/src/utils.rs b/crates/api_common/src/utils.rs index 3a7c1ac1f1..3add64a6bd 100644 --- a/crates/api_common/src/utils.rs +++ b/crates/api_common/src/utils.rs @@ -659,6 +659,17 @@ pub fn check_private_instance_and_federation_enabled(local_site: &LocalSite) -> } } +pub fn check_nsfw_allowed(nsfw: Option, local_site: Option<&LocalSite>) -> LemmyResult<()> { + let is_nsfw = nsfw.unwrap_or_default(); + let nsfw_disallowed = local_site.is_some_and(|s| s.disallow_nsfw_content); + + if nsfw_disallowed && is_nsfw { + Err(LemmyErrorType::NsfwNotAllowed)? + } + + Ok(()) +} + /// Read the site for an ap_id. /// /// Used for GetCommunityResponse and GetPersonDetails @@ -671,6 +682,19 @@ pub async fn read_site_for_actor( Ok(site) } +pub async fn purge_post_images( + url: Option, + thumbnail_url: Option, + context: &LemmyContext, +) { + if let Some(url) = url { + purge_image_from_pictrs(&url, context).await.ok(); + } + if let Some(thumbnail_url) = thumbnail_url { + purge_image_from_pictrs(&thumbnail_url, context).await.ok(); + } +} + pub async fn purge_image_posts_for_person( banned_person_id: PersonId, context: &LemmyContext, @@ -678,12 +702,7 @@ pub async fn purge_image_posts_for_person( let pool = &mut context.pool(); let posts = Post::fetch_pictrs_posts_for_creator(pool, banned_person_id).await?; for post in posts { - if let Some(url) = post.url { - purge_image_from_pictrs(&url, context).await.ok(); - } - if let Some(thumbnail_url) = post.thumbnail_url { - purge_image_from_pictrs(&thumbnail_url, context).await.ok(); - } + purge_post_images(post.url, post.thumbnail_url, context).await; } Post::remove_pictrs_post_images_and_thumbnails_for_creator(pool, banned_person_id).await?; @@ -715,12 +734,7 @@ pub async fn purge_image_posts_for_community( let pool = &mut context.pool(); let posts = Post::fetch_pictrs_posts_for_community(pool, banned_community_id).await?; for post in posts { - if let Some(url) = post.url { - purge_image_from_pictrs(&url, context).await.ok(); - } - if let Some(thumbnail_url) = post.thumbnail_url { - purge_image_from_pictrs(&thumbnail_url, context).await.ok(); - } + purge_post_images(post.url, post.thumbnail_url, context).await; } Post::remove_pictrs_post_images_and_thumbnails_for_community(pool, banned_community_id).await?; diff --git a/crates/api_crud/src/community/create.rs b/crates/api_crud/src/community/create.rs index de218fe2d6..df8e2ac765 100644 --- a/crates/api_crud/src/community/create.rs +++ b/crates/api_crud/src/community/create.rs @@ -6,6 +6,7 @@ use lemmy_api_common::{ community::{CommunityResponse, CreateCommunity}, context::LemmyContext, utils::{ + check_nsfw_allowed, generate_followers_url, generate_inbox_url, get_url_blocklist, @@ -54,6 +55,7 @@ pub async fn create_community( Err(LemmyErrorType::OnlyAdminsCanCreateCommunities)? } + check_nsfw_allowed(data.nsfw, Some(&local_site))?; let slur_regex = slur_regex(&context).await?; let url_blocklist = get_url_blocklist(&context).await?; check_slurs(&data.name, &slur_regex)?; diff --git a/crates/api_crud/src/community/update.rs b/crates/api_crud/src/community/update.rs index eded217dd1..61bd219366 100644 --- a/crates/api_crud/src/community/update.rs +++ b/crates/api_crud/src/community/update.rs @@ -7,12 +7,19 @@ use lemmy_api_common::{ community::{CommunityResponse, EditCommunity}, context::LemmyContext, send_activity::{ActivityChannel, SendActivityData}, - utils::{check_community_mod_action, get_url_blocklist, process_markdown_opt, slur_regex}, + utils::{ + check_community_mod_action, + check_nsfw_allowed, + get_url_blocklist, + process_markdown_opt, + slur_regex, + }, }; use lemmy_db_schema::{ source::{ actor_language::{CommunityLanguage, SiteLanguage}, community::{Community, CommunityUpdateForm}, + local_site::LocalSite, }, traits::Crud, utils::diesel_string_update, @@ -28,9 +35,12 @@ pub async fn update_community( context: Data, local_user_view: LocalUserView, ) -> LemmyResult> { + let local_site = LocalSite::read(&mut context.pool()).await?; + let slur_regex = slur_regex(&context).await?; let url_blocklist = get_url_blocklist(&context).await?; check_slurs_opt(&data.title, &slur_regex)?; + check_nsfw_allowed(data.nsfw, Some(&local_site))?; let sidebar = diesel_string_update( process_markdown_opt(&data.sidebar, &slur_regex, &url_blocklist, &context) diff --git a/crates/api_crud/src/post/create.rs b/crates/api_crud/src/post/create.rs index a2d90b1459..75827b1b45 100644 --- a/crates/api_crud/src/post/create.rs +++ b/crates/api_crud/src/post/create.rs @@ -9,6 +9,7 @@ use lemmy_api_common::{ send_activity::SendActivityData, utils::{ check_community_user_action, + check_nsfw_allowed, get_url_blocklist, honeypot_check, process_markdown_opt, @@ -21,6 +22,7 @@ use lemmy_db_schema::{ newtypes::PostOrCommentId, source::{ community::Community, + local_site::LocalSite, post::{Post, PostInsertForm, PostLike, PostLikeForm, PostRead, PostReadForm}, }, traits::{Crud, Likeable}, @@ -48,6 +50,7 @@ pub async fn create_post( local_user_view: LocalUserView, ) -> LemmyResult> { honeypot_check(&data.honeypot)?; + let local_site = LocalSite::read(&mut context.pool()).await?; let slur_regex = slur_regex(&context).await?; check_slurs(&data.name, &slur_regex)?; @@ -56,6 +59,7 @@ 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 custom_thumbnail = diesel_url_create(data.custom_thumbnail.as_deref())?; + check_nsfw_allowed(data.nsfw, Some(&local_site))?; is_valid_post_title(&data.name)?; diff --git a/crates/api_crud/src/post/update.rs b/crates/api_crud/src/post/update.rs index da1c17b133..66501af31a 100644 --- a/crates/api_crud/src/post/update.rs +++ b/crates/api_crud/src/post/update.rs @@ -10,6 +10,7 @@ use lemmy_api_common::{ send_activity::SendActivityData, utils::{ check_community_user_action, + check_nsfw_allowed, get_url_blocklist, process_markdown_opt, send_webmention, @@ -21,6 +22,7 @@ use lemmy_db_schema::{ newtypes::PostOrCommentId, source::{ community::Community, + local_site::LocalSite, post::{Post, PostUpdateForm}, }, traits::Crud, @@ -48,6 +50,7 @@ pub async fn update_post( context: Data, local_user_view: LocalUserView, ) -> LemmyResult> { + let local_site = LocalSite::read(&mut context.pool()).await?; let url = diesel_url_update(data.url.as_deref())?; let custom_thumbnail = diesel_url_update(data.custom_thumbnail.as_deref())?; @@ -62,6 +65,8 @@ pub async fn update_post( .as_deref(), ); + check_nsfw_allowed(data.nsfw, Some(&local_site))?; + let alt_text = diesel_string_update(data.alt_text.as_deref()); if let Some(name) = &data.name { diff --git a/crates/api_crud/src/site/create.rs b/crates/api_crud/src/site/create.rs index 1b2ff37b02..2720fe5377 100644 --- a/crates/api_crud/src/site/create.rs +++ b/crates/api_crud/src/site/create.rs @@ -105,6 +105,7 @@ pub async fn create_site( comment_upvotes: data.comment_upvotes, comment_downvotes: data.comment_downvotes, disable_donation_dialog: data.disable_donation_dialog, + disallow_nsfw_content: data.disallow_nsfw_content, ..Default::default() }; diff --git a/crates/api_crud/src/site/update.rs b/crates/api_crud/src/site/update.rs index a0374baeb7..81ba487984 100644 --- a/crates/api_crud/src/site/update.rs +++ b/crates/api_crud/src/site/update.rs @@ -114,6 +114,7 @@ pub async fn update_site( comment_upvotes: data.comment_upvotes, comment_downvotes: data.comment_downvotes, disable_donation_dialog: data.disable_donation_dialog, + disallow_nsfw_content: data.disallow_nsfw_content, ..Default::default() }; diff --git a/crates/apub/src/objects/community.rs b/crates/apub/src/objects/community.rs index a1e239ab64..0376ccfaa4 100644 --- a/crates/apub/src/objects/community.rs +++ b/crates/apub/src/objects/community.rs @@ -18,6 +18,7 @@ use chrono::{DateTime, Utc}; use lemmy_api_common::{ context::LemmyContext, utils::{ + check_nsfw_allowed, generate_featured_url, generate_moderators_url, generate_outbox_url, @@ -33,6 +34,7 @@ use lemmy_db_schema::{ activity::ActorType, actor_language::CommunityLanguage, community::{Community, CommunityInsertForm, CommunityUpdateForm}, + local_site::LocalSite, }, traits::{ApubActor, Crud}, CommunityVisibility, @@ -134,6 +136,7 @@ impl Object for ApubCommunity { /// Converts a `Group` to `Community`, inserts it into the database and updates moderators. async fn from_json(group: Group, context: &Data) -> LemmyResult { + let local_site = LocalSite::read(&mut context.pool()).await.ok(); let instance_id = fetch_instance_actor_for_object(&group.id, context).await?; let slur_regex = slur_regex(context).await?; @@ -148,6 +151,12 @@ impl Object for ApubCommunity { } else { CommunityVisibility::Public }); + + // If NSFW is not allowed, then remove NSFW communities + let removed = check_nsfw_allowed(group.sensitive, local_site.as_ref()) + .err() + .map(|_| true); + let form = CommunityInsertForm { published: group.published, updated: group.updated, @@ -159,6 +168,7 @@ impl Object for ApubCommunity { icon, banner, sidebar, + removed, description: group.summary, followers_url: group.followers.clone().map(Into::into), inbox_url: Some( diff --git a/crates/apub/src/objects/post.rs b/crates/apub/src/objects/post.rs index 73bb77107e..9422ef459e 100644 --- a/crates/apub/src/objects/post.rs +++ b/crates/apub/src/objects/post.rs @@ -27,11 +27,18 @@ use html2text::{from_read_with_decorator, render::TrivialDecorator}; use lemmy_api_common::{ context::LemmyContext, request::generate_post_link_metadata, - utils::{get_url_blocklist, process_markdown_opt, slur_regex}, + utils::{ + check_nsfw_allowed, + get_url_blocklist, + process_markdown_opt, + purge_post_images, + slur_regex, + }, }; use lemmy_db_schema::{ source::{ community::Community, + local_site::LocalSite, person::Person, post::{Post, PostInsertForm, PostUpdateForm}, }, @@ -171,6 +178,7 @@ impl Object for ApubPost { } async fn from_json(page: Page, context: &Data) -> LemmyResult { + let local_site = LocalSite::read(&mut context.pool()).await.ok(); let creator = page.creator()?.dereference(context).await?; let community = page.community(context).await?; @@ -220,6 +228,17 @@ impl Object for ApubPost { None }; + // If NSFW is not allowed, reject NSFW posts and delete existing + // posts that get updated to be NSFW + let block_for_nsfw = check_nsfw_allowed(page.sensitive, local_site.as_ref()); + if let Err(e) = block_for_nsfw { + let url = url.clone().map(std::convert::Into::into); + let thumbnail_url = page.image.map(|i| i.url.into()); + purge_post_images(url, thumbnail_url, context).await; + Post::delete_from_apub_id(&mut context.pool(), page.id.inner().clone()).await?; + Err(e)? + } + let url_blocklist = get_url_blocklist(context).await?; let url = if let Some(url) = url { diff --git a/crates/db_schema/Cargo.toml b/crates/db_schema/Cargo.toml index 252dfeb95e..d338766d2f 100644 --- a/crates/db_schema/Cargo.toml +++ b/crates/db_schema/Cargo.toml @@ -55,6 +55,7 @@ diesel = { workspace = true, features = [ "postgres", "serde_json", "uuid", + "64-column-tables", ], optional = true } diesel-derive-newtype = { workspace = true, optional = true } diesel-derive-enum = { workspace = true, optional = true } diff --git a/crates/db_schema/src/impls/post.rs b/crates/db_schema/src/impls/post.rs index 3ea37d45ab..d10cee502c 100644 --- a/crates/db_schema/src/impls/post.rs +++ b/crates/db_schema/src/impls/post.rs @@ -182,6 +182,19 @@ impl Post { .optional() } + pub async fn delete_from_apub_id( + pool: &mut DbPool<'_>, + object_id: Url, + ) -> Result, Error> { + let conn = &mut get_conn(pool).await?; + let object_id: DbUrl = object_id.into(); + + diesel::update(post::table.filter(post::ap_id.eq(object_id))) + .set(post::deleted.eq(true)) + .get_results::(conn) + .await + } + pub async fn fetch_pictrs_posts_for_creator( pool: &mut DbPool<'_>, for_creator_id: PersonId, diff --git a/crates/db_schema/src/schema.rs b/crates/db_schema/src/schema.rs index 316da43609..2af744497d 100644 --- a/crates/db_schema/src/schema.rs +++ b/crates/db_schema/src/schema.rs @@ -449,6 +449,7 @@ diesel::table! { comment_downvotes -> FederationModeEnum, disable_donation_dialog -> Bool, default_post_time_range_seconds -> Nullable, + disallow_nsfw_content -> Bool, } } diff --git a/crates/db_schema/src/source/local_site.rs b/crates/db_schema/src/source/local_site.rs index 78d5da2f34..6d390dd60c 100644 --- a/crates/db_schema/src/source/local_site.rs +++ b/crates/db_schema/src/source/local_site.rs @@ -89,6 +89,8 @@ pub struct LocalSite { #[cfg_attr(feature = "full", ts(optional))] /// A default time range limit to apply to post sorts, in seconds. pub default_post_time_range_seconds: Option, + /// Block NSFW content being created + pub disallow_nsfw_content: bool, } #[derive(Clone, derive_new::new)] @@ -152,6 +154,8 @@ pub struct LocalSiteInsertForm { pub disable_donation_dialog: Option, #[new(default)] pub default_post_time_range_seconds: Option>, + #[new(default)] + pub disallow_nsfw_content: bool, } #[derive(Clone, Default)] @@ -187,4 +191,5 @@ pub struct LocalSiteUpdateForm { pub comment_downvotes: Option, pub disable_donation_dialog: Option, pub default_post_time_range_seconds: Option>, + pub disallow_nsfw_content: Option, } diff --git a/crates/utils/src/error.rs b/crates/utils/src/error.rs index f5156f3ec8..5814395926 100644 --- a/crates/utils/src/error.rs +++ b/crates/utils/src/error.rs @@ -58,6 +58,7 @@ pub enum LemmyErrorType { LanguageNotAllowed, CouldntUpdatePost, NoPostEditAllowed, + NsfwNotAllowed, EditPrivateMessageNotAllowed, SiteAlreadyExists, ApplicationQuestionRequired, diff --git a/migrations/2025-02-18-143408_block_nsfw/down.sql b/migrations/2025-02-18-143408_block_nsfw/down.sql new file mode 100644 index 0000000000..03b82d6f7d --- /dev/null +++ b/migrations/2025-02-18-143408_block_nsfw/down.sql @@ -0,0 +1,3 @@ +ALTER TABLE local_site + DROP COLUMN disallow_nsfw_content; + diff --git a/migrations/2025-02-18-143408_block_nsfw/up.sql b/migrations/2025-02-18-143408_block_nsfw/up.sql new file mode 100644 index 0000000000..b7e7fc4b82 --- /dev/null +++ b/migrations/2025-02-18-143408_block_nsfw/up.sql @@ -0,0 +1,3 @@ +ALTER TABLE local_site + ADD COLUMN disallow_nsfw_content boolean DEFAULT FALSE NOT NULL; +