Add new setting to block NSFW content (#5436)

* Block NSFW content on instances with it disabled

* Make disallow_nsfw_content a local_site setting

* Clippy

* Add comma

* SQL fmt

* Newline

* Use func in apub + update js-client

* Remove extra db queries, add purge_post_images

* Add back local_site to funcs that need it

* Fix tests

* Add delay to api test

* Address comments

* Cleanup

* Return results from db func

* fmt

* Remove unneeded result

* Sync translations
This commit is contained in:
flamingos-cant 2025-03-03 10:46:39 +00:00 committed by GitHub
parent be91a2f39c
commit e7ab5256f7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 165 additions and 30 deletions

View file

@ -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",

View file

@ -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: {}

View file

@ -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":

View file

@ -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?;

View file

@ -254,6 +254,8 @@ pub struct CreateSite {
pub comment_downvotes: Option<FederationMode>,
#[cfg_attr(feature = "full", ts(optional))]
pub disable_donation_dialog: Option<bool>,
#[cfg_attr(feature = "full", ts(optional))]
pub disallow_nsfw_content: Option<bool>,
}
#[skip_serializing_none]
@ -388,6 +390,9 @@ pub struct EditSite {
/// donations.
#[cfg_attr(feature = "full", ts(optional))]
pub disable_donation_dialog: Option<bool>,
/// Block NSFW content being created
#[cfg_attr(feature = "full", ts(optional))]
pub disallow_nsfw_content: Option<bool>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]

View file

@ -659,6 +659,17 @@ pub fn check_private_instance_and_federation_enabled(local_site: &LocalSite) ->
}
}
pub fn check_nsfw_allowed(nsfw: Option<bool>, 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<DbUrl>,
thumbnail_url: Option<DbUrl>,
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?;

View file

@ -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)?;

View file

@ -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<LemmyContext>,
local_user_view: LocalUserView,
) -> LemmyResult<Json<CommunityResponse>> {
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)

View file

@ -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<Json<PostResponse>> {
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)?;

View file

@ -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<LemmyContext>,
local_user_view: LocalUserView,
) -> LemmyResult<Json<PostResponse>> {
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 {

View file

@ -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()
};

View file

@ -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()
};

View file

@ -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<Self::DataType>) -> LemmyResult<ApubCommunity> {
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(

View file

@ -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<Self::DataType>) -> LemmyResult<ApubPost> {
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 {

View file

@ -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 }

View file

@ -182,6 +182,19 @@ impl Post {
.optional()
}
pub async fn delete_from_apub_id(
pool: &mut DbPool<'_>,
object_id: Url,
) -> Result<Vec<Self>, 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::<Self>(conn)
.await
}
pub async fn fetch_pictrs_posts_for_creator(
pool: &mut DbPool<'_>,
for_creator_id: PersonId,

View file

@ -449,6 +449,7 @@ diesel::table! {
comment_downvotes -> FederationModeEnum,
disable_donation_dialog -> Bool,
default_post_time_range_seconds -> Nullable<Int4>,
disallow_nsfw_content -> Bool,
}
}

View file

@ -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<i32>,
/// 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<bool>,
#[new(default)]
pub default_post_time_range_seconds: Option<Option<i32>>,
#[new(default)]
pub disallow_nsfw_content: bool,
}
#[derive(Clone, Default)]
@ -187,4 +191,5 @@ pub struct LocalSiteUpdateForm {
pub comment_downvotes: Option<FederationMode>,
pub disable_donation_dialog: Option<bool>,
pub default_post_time_range_seconds: Option<Option<i32>>,
pub disallow_nsfw_content: Option<bool>,
}

View file

@ -58,6 +58,7 @@ pub enum LemmyErrorType {
LanguageNotAllowed,
CouldntUpdatePost,
NoPostEditAllowed,
NsfwNotAllowed,
EditPrivateMessageNotAllowed,
SiteAlreadyExists,
ApplicationQuestionRequired,

View file

@ -0,0 +1,3 @@
ALTER TABLE local_site
DROP COLUMN disallow_nsfw_content;

View file

@ -0,0 +1,3 @@
ALTER TABLE local_site
ADD COLUMN disallow_nsfw_content boolean DEFAULT FALSE NOT NULL;