Adding admin purging of DB items and pictures. #904 #1331 (#1809)

* First pass at adding admin purge. #904 #1331

* Breaking out purge into 4 tables for the 4 purgeable types.

* Using CommunitySafe instead in view

* Fix db_schema features flags.

* Attempting to pass API key.

* Adding pictrs image purging

- Added pictrs_config block, for API_KEY
- Clear out image columns after purging

* Remove the remove_images field from a few of the purge API calls.

* Fix some suggestions by @nutomic.

* Add separate pictrs reqwest client.

* Update defaults.hjson

Co-authored-by: Nutomic <me@nutomic.com>
This commit is contained in:
Dessalines 2022-06-13 15:15:04 -04:00 committed by GitHub
parent 5b7376512f
commit 4e12e25c59
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
38 changed files with 1320 additions and 62 deletions

View file

@ -70,6 +70,13 @@
# activities synchronously for easier testing. Do not use in production. # activities synchronously for easier testing. Do not use in production.
debug: false debug: false
} }
# Pictrs image server configuration.
pictrs_config: {
# Address where pictrs is available (for image hosting)
url: "string"
# Set a custom pictrs API key. ( Required for deleting images )
api_key: "string"
}
captcha: { captcha: {
# Whether captcha is required for signup # Whether captcha is required for signup
enabled: false enabled: false
@ -108,8 +115,7 @@
port: 8536 port: 8536
# Whether the site is available over TLS. Needs to be true for federation to work. # Whether the site is available over TLS. Needs to be true for federation to work.
tls_enabled: true tls_enabled: true
# Address where pictrs is available (for image hosting) # A regex list of slurs to block / hide
pictrs_url: "http://localhost:8080"
slur_filter: "(\bThis\b)|(\bis\b)|(\bsample\b)" slur_filter: "(\bThis\b)|(\bis\b)|(\bsample\b)"
# Maximum length of local community and user names # Maximum length of local community and user names
actor_name_max_length: 20 actor_name_max_length: 20

View file

@ -104,6 +104,16 @@ pub async fn match_websocket_operation(
UserOperation::SaveSiteConfig => { UserOperation::SaveSiteConfig => {
do_websocket_operation::<SaveSiteConfig>(context, id, op, data).await do_websocket_operation::<SaveSiteConfig>(context, id, op, data).await
} }
UserOperation::PurgePerson => {
do_websocket_operation::<PurgePerson>(context, id, op, data).await
}
UserOperation::PurgeCommunity => {
do_websocket_operation::<PurgeCommunity>(context, id, op, data).await
}
UserOperation::PurgePost => do_websocket_operation::<PurgePost>(context, id, op, data).await,
UserOperation::PurgeComment => {
do_websocket_operation::<PurgeComment>(context, id, op, data).await
}
UserOperation::Search => do_websocket_operation::<Search>(context, id, op, data).await, UserOperation::Search => do_websocket_operation::<Search>(context, id, op, data).await,
UserOperation::ResolveObject => { UserOperation::ResolveObject => {
do_websocket_operation::<ResolveObject>(context, id, op, data).await do_websocket_operation::<ResolveObject>(context, id, op, data).await

View file

@ -49,7 +49,13 @@ impl Perform for BanPerson {
// Remove their data if that's desired // Remove their data if that's desired
let remove_data = data.remove_data.unwrap_or(false); let remove_data = data.remove_data.unwrap_or(false);
if remove_data { if remove_data {
remove_user_data(person.id, context.pool()).await?; remove_user_data(
person.id,
context.pool(),
&context.settings(),
context.client(),
)
.await?;
} }
// Mod tables // Mod tables

View file

@ -1,6 +1,7 @@
mod config; mod config;
mod leave_admin; mod leave_admin;
mod mod_log; mod mod_log;
mod purge;
mod registration_applications; mod registration_applications;
mod resolve_object; mod resolve_object;
mod search; mod search;

View file

@ -5,6 +5,10 @@ use lemmy_api_common::{
utils::{blocking, check_private_instance, get_local_user_view_from_jwt_opt}, utils::{blocking, check_private_instance, get_local_user_view_from_jwt_opt},
}; };
use lemmy_db_views_moderator::structs::{ use lemmy_db_views_moderator::structs::{
AdminPurgeCommentView,
AdminPurgeCommunityView,
AdminPurgePersonView,
AdminPurgePostView,
ModAddCommunityView, ModAddCommunityView,
ModAddView, ModAddView,
ModBanFromCommunityView, ModBanFromCommunityView,
@ -83,17 +87,29 @@ impl Perform for GetModlog {
.await??; .await??;
// These arrays are only for the full modlog, when a community isn't given // These arrays are only for the full modlog, when a community isn't given
let (removed_communities, banned, added) = if data.community_id.is_none() { let (
removed_communities,
banned,
added,
admin_purged_persons,
admin_purged_communities,
admin_purged_posts,
admin_purged_comments,
) = if data.community_id.is_none() {
blocking(context.pool(), move |conn| { blocking(context.pool(), move |conn| {
Ok(( Ok((
ModRemoveCommunityView::list(conn, mod_person_id, page, limit)?, ModRemoveCommunityView::list(conn, mod_person_id, page, limit)?,
ModBanView::list(conn, mod_person_id, page, limit)?, ModBanView::list(conn, mod_person_id, page, limit)?,
ModAddView::list(conn, mod_person_id, page, limit)?, ModAddView::list(conn, mod_person_id, page, limit)?,
AdminPurgePersonView::list(conn, mod_person_id, page, limit)?,
AdminPurgeCommunityView::list(conn, mod_person_id, page, limit)?,
AdminPurgePostView::list(conn, mod_person_id, page, limit)?,
AdminPurgeCommentView::list(conn, mod_person_id, page, limit)?,
)) as Result<_, LemmyError> )) as Result<_, LemmyError>
}) })
.await?? .await??
} else { } else {
(Vec::new(), Vec::new(), Vec::new()) Default::default()
}; };
// Return the jwt // Return the jwt
@ -108,6 +124,10 @@ impl Perform for GetModlog {
added_to_community, added_to_community,
added, added,
transferred_to_community, transferred_to_community,
admin_purged_persons,
admin_purged_communities,
admin_purged_posts,
admin_purged_comments,
hidden_communities, hidden_communities,
}) })
} }

View file

@ -0,0 +1,63 @@
use crate::Perform;
use actix_web::web::Data;
use lemmy_api_common::{
site::{PurgeComment, PurgeItemResponse},
utils::{blocking, get_local_user_view_from_jwt, is_admin},
};
use lemmy_db_schema::{
source::{
comment::Comment,
moderator::{AdminPurgeComment, AdminPurgeCommentForm},
},
traits::Crud,
};
use lemmy_utils::{error::LemmyError, ConnectionId};
use lemmy_websocket::LemmyContext;
#[async_trait::async_trait(?Send)]
impl Perform for PurgeComment {
type Response = PurgeItemResponse;
#[tracing::instrument(skip(context, _websocket_id))]
async fn perform(
&self,
context: &Data<LemmyContext>,
_websocket_id: Option<ConnectionId>,
) -> Result<Self::Response, LemmyError> {
let data: &Self = self;
let local_user_view =
get_local_user_view_from_jwt(&data.auth, context.pool(), context.secret()).await?;
// Only let admins purge an item
is_admin(&local_user_view)?;
let comment_id = data.comment_id;
// Read the comment to get the post_id
let comment = blocking(context.pool(), move |conn| Comment::read(conn, comment_id)).await??;
let post_id = comment.post_id;
// TODO read comments for pictrs images and purge them
blocking(context.pool(), move |conn| {
Comment::delete(conn, comment_id)
})
.await??;
// Mod tables
let reason = data.reason.to_owned();
let form = AdminPurgeCommentForm {
admin_person_id: local_user_view.person.id,
reason,
post_id,
};
blocking(context.pool(), move |conn| {
AdminPurgeComment::create(conn, &form)
})
.await??;
Ok(PurgeItemResponse { success: true })
}
}

View file

@ -0,0 +1,82 @@
use crate::Perform;
use actix_web::web::Data;
use lemmy_api_common::{
request::purge_image_from_pictrs,
site::{PurgeCommunity, PurgeItemResponse},
utils::{blocking, get_local_user_view_from_jwt, is_admin, purge_image_posts_for_community},
};
use lemmy_db_schema::{
source::{
community::Community,
moderator::{AdminPurgeCommunity, AdminPurgeCommunityForm},
},
traits::Crud,
};
use lemmy_utils::{error::LemmyError, ConnectionId};
use lemmy_websocket::LemmyContext;
#[async_trait::async_trait(?Send)]
impl Perform for PurgeCommunity {
type Response = PurgeItemResponse;
#[tracing::instrument(skip(context, _websocket_id))]
async fn perform(
&self,
context: &Data<LemmyContext>,
_websocket_id: Option<ConnectionId>,
) -> Result<Self::Response, LemmyError> {
let data: &Self = self;
let local_user_view =
get_local_user_view_from_jwt(&data.auth, context.pool(), context.secret()).await?;
// Only let admins purge an item
is_admin(&local_user_view)?;
let community_id = data.community_id;
// Read the community to get its images
let community = blocking(context.pool(), move |conn| {
Community::read(conn, community_id)
})
.await??;
if let Some(banner) = community.banner {
purge_image_from_pictrs(context.client(), &context.settings(), &banner)
.await
.ok();
}
if let Some(icon) = community.icon {
purge_image_from_pictrs(context.client(), &context.settings(), &icon)
.await
.ok();
}
purge_image_posts_for_community(
community_id,
context.pool(),
&context.settings(),
context.client(),
)
.await?;
blocking(context.pool(), move |conn| {
Community::delete(conn, community_id)
})
.await??;
// Mod tables
let reason = data.reason.to_owned();
let form = AdminPurgeCommunityForm {
admin_person_id: local_user_view.person.id,
reason,
};
blocking(context.pool(), move |conn| {
AdminPurgeCommunity::create(conn, &form)
})
.await??;
Ok(PurgeItemResponse { success: true })
}
}

View file

@ -0,0 +1,4 @@
mod comment;
mod community;
mod person;
mod post;

View file

@ -0,0 +1,75 @@
use crate::Perform;
use actix_web::web::Data;
use lemmy_api_common::{
request::purge_image_from_pictrs,
site::{PurgeItemResponse, PurgePerson},
utils::{blocking, get_local_user_view_from_jwt, is_admin, purge_image_posts_for_person},
};
use lemmy_db_schema::{
source::{
moderator::{AdminPurgePerson, AdminPurgePersonForm},
person::Person,
},
traits::Crud,
};
use lemmy_utils::{error::LemmyError, ConnectionId};
use lemmy_websocket::LemmyContext;
#[async_trait::async_trait(?Send)]
impl Perform for PurgePerson {
type Response = PurgeItemResponse;
#[tracing::instrument(skip(context, _websocket_id))]
async fn perform(
&self,
context: &Data<LemmyContext>,
_websocket_id: Option<ConnectionId>,
) -> Result<Self::Response, LemmyError> {
let data: &Self = self;
let local_user_view =
get_local_user_view_from_jwt(&data.auth, context.pool(), context.secret()).await?;
// Only let admins purge an item
is_admin(&local_user_view)?;
// Read the person to get their images
let person_id = data.person_id;
let person = blocking(context.pool(), move |conn| Person::read(conn, person_id)).await??;
if let Some(banner) = person.banner {
purge_image_from_pictrs(context.client(), &context.settings(), &banner)
.await
.ok();
}
if let Some(avatar) = person.avatar {
purge_image_from_pictrs(context.client(), &context.settings(), &avatar)
.await
.ok();
}
purge_image_posts_for_person(
person_id,
context.pool(),
&context.settings(),
context.client(),
)
.await?;
blocking(context.pool(), move |conn| Person::delete(conn, person_id)).await??;
// Mod tables
let reason = data.reason.to_owned();
let form = AdminPurgePersonForm {
admin_person_id: local_user_view.person.id,
reason,
};
blocking(context.pool(), move |conn| {
AdminPurgePerson::create(conn, &form)
})
.await??;
Ok(PurgeItemResponse { success: true })
}
}

View file

@ -0,0 +1,72 @@
use crate::Perform;
use actix_web::web::Data;
use lemmy_api_common::{
request::purge_image_from_pictrs,
site::{PurgeItemResponse, PurgePost},
utils::{blocking, get_local_user_view_from_jwt, is_admin},
};
use lemmy_db_schema::{
source::{
moderator::{AdminPurgePost, AdminPurgePostForm},
post::Post,
},
traits::Crud,
};
use lemmy_utils::{error::LemmyError, ConnectionId};
use lemmy_websocket::LemmyContext;
#[async_trait::async_trait(?Send)]
impl Perform for PurgePost {
type Response = PurgeItemResponse;
#[tracing::instrument(skip(context, _websocket_id))]
async fn perform(
&self,
context: &Data<LemmyContext>,
_websocket_id: Option<ConnectionId>,
) -> Result<Self::Response, LemmyError> {
let data: &Self = self;
let local_user_view =
get_local_user_view_from_jwt(&data.auth, context.pool(), context.secret()).await?;
// Only let admins purge an item
is_admin(&local_user_view)?;
let post_id = data.post_id;
// Read the post to get the community_id
let post = blocking(context.pool(), move |conn| Post::read(conn, post_id)).await??;
// Purge image
if let Some(url) = post.url {
purge_image_from_pictrs(context.client(), &context.settings(), &url)
.await
.ok();
}
// Purge thumbnail
if let Some(thumbnail_url) = post.thumbnail_url {
purge_image_from_pictrs(context.client(), &context.settings(), &thumbnail_url)
.await
.ok();
}
let community_id = post.community_id;
blocking(context.pool(), move |conn| Post::delete(conn, post_id)).await??;
// Mod tables
let reason = data.reason.to_owned();
let form = AdminPurgePostForm {
admin_person_id: local_user_view.person.id,
reason,
community_id,
};
blocking(context.pool(), move |conn| {
AdminPurgePost::create(conn, &form)
})
.await??;
Ok(PurgeItemResponse { success: true })
}
}

View file

@ -1,7 +1,12 @@
use crate::post::SiteMetadata; use crate::post::SiteMetadata;
use encoding::{all::encodings, DecoderTrap}; use encoding::{all::encodings, DecoderTrap};
use lemmy_db_schema::newtypes::DbUrl; use lemmy_db_schema::newtypes::DbUrl;
use lemmy_utils::{error::LemmyError, settings::structs::Settings, version::VERSION}; use lemmy_utils::{
error::LemmyError,
settings::structs::Settings,
version::VERSION,
REQWEST_TIMEOUT,
};
use percent_encoding::{utf8_percent_encode, NON_ALPHANUMERIC}; use percent_encoding::{utf8_percent_encode, NON_ALPHANUMERIC};
use reqwest_middleware::ClientWithMiddleware; use reqwest_middleware::ClientWithMiddleware;
use serde::Deserialize; use serde::Deserialize;
@ -105,22 +110,31 @@ pub(crate) struct PictrsFile {
delete_token: String, delete_token: String,
} }
#[derive(Deserialize, Debug, Clone)]
pub(crate) struct PictrsPurgeResponse {
msg: String,
}
#[tracing::instrument(skip_all)] #[tracing::instrument(skip_all)]
pub(crate) async fn fetch_pictrs( pub(crate) async fn fetch_pictrs(
client: &ClientWithMiddleware, client: &ClientWithMiddleware,
settings: &Settings, settings: &Settings,
image_url: &Url, image_url: &Url,
) -> Result<PictrsResponse, LemmyError> { ) -> Result<PictrsResponse, LemmyError> {
if let Some(pictrs_url) = settings.pictrs_url.to_owned() { let pictrs_config = settings.pictrs_config()?;
is_image_content_type(client, image_url).await?; is_image_content_type(client, image_url).await?;
let fetch_url = format!( let fetch_url = format!(
"{}/image/download?url={}", "{}/image/download?url={}",
pictrs_url, pictrs_config.url,
utf8_percent_encode(image_url.as_str(), NON_ALPHANUMERIC) // TODO this might not be needed utf8_percent_encode(image_url.as_str(), NON_ALPHANUMERIC) // TODO this might not be needed
); );
let response = client.get(&fetch_url).send().await?; let response = client
.get(&fetch_url)
.timeout(REQWEST_TIMEOUT)
.send()
.await?;
let response: PictrsResponse = response.json().await.map_err(LemmyError::from)?; let response: PictrsResponse = response.json().await.map_err(LemmyError::from)?;
@ -129,8 +143,42 @@ pub(crate) async fn fetch_pictrs(
} else { } else {
Err(LemmyError::from_message(&response.msg)) Err(LemmyError::from_message(&response.msg))
} }
}
/// Purges an image from pictrs
/// Note: This should often be coerced from a Result to .ok() in order to fail softly, because:
/// - It might fail due to image being not local
/// - It might not be an image
/// - Pictrs might not be set up
pub async fn purge_image_from_pictrs(
client: &ClientWithMiddleware,
settings: &Settings,
image_url: &Url,
) -> Result<(), LemmyError> {
let pictrs_config = settings.pictrs_config()?;
is_image_content_type(client, image_url).await?;
let alias = image_url
.path_segments()
.ok_or_else(|| LemmyError::from_message("Image URL missing path segments"))?
.next_back()
.ok_or_else(|| LemmyError::from_message("Image URL missing last path segment"))?;
let purge_url = format!("{}/internal/purge?alias={}", pictrs_config.url, alias);
let response = client
.post(&purge_url)
.timeout(REQWEST_TIMEOUT)
.header("x-api-token", pictrs_config.api_key)
.send()
.await?;
let response: PictrsPurgeResponse = response.json().await.map_err(LemmyError::from)?;
if response.msg == "ok" {
Ok(())
} else { } else {
Err(LemmyError::from_message("pictrs_url not set up in config")) Err(LemmyError::from_message(&response.msg))
} }
} }

View file

@ -1,6 +1,6 @@
use crate::sensitive::Sensitive; use crate::sensitive::Sensitive;
use lemmy_db_schema::{ use lemmy_db_schema::{
newtypes::{CommunityId, PersonId}, newtypes::{CommentId, CommunityId, PersonId, PostId},
ListingType, ListingType,
SearchType, SearchType,
SortType, SortType,
@ -21,6 +21,10 @@ use lemmy_db_views_actor::structs::{
PersonViewSafe, PersonViewSafe,
}; };
use lemmy_db_views_moderator::structs::{ use lemmy_db_views_moderator::structs::{
AdminPurgeCommentView,
AdminPurgeCommunityView,
AdminPurgePersonView,
AdminPurgePostView,
ModAddCommunityView, ModAddCommunityView,
ModAddView, ModAddView,
ModBanFromCommunityView, ModBanFromCommunityView,
@ -93,6 +97,10 @@ pub struct GetModlogResponse {
pub added_to_community: Vec<ModAddCommunityView>, pub added_to_community: Vec<ModAddCommunityView>,
pub transferred_to_community: Vec<ModTransferCommunityView>, pub transferred_to_community: Vec<ModTransferCommunityView>,
pub added: Vec<ModAddView>, pub added: Vec<ModAddView>,
pub admin_purged_persons: Vec<AdminPurgePersonView>,
pub admin_purged_communities: Vec<AdminPurgeCommunityView>,
pub admin_purged_posts: Vec<AdminPurgePostView>,
pub admin_purged_comments: Vec<AdminPurgeCommentView>,
pub hidden_communities: Vec<ModHideCommunityView>, pub hidden_communities: Vec<ModHideCommunityView>,
} }
@ -194,6 +202,39 @@ pub struct FederatedInstances {
pub blocked: Option<Vec<String>>, pub blocked: Option<Vec<String>>,
} }
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct PurgePerson {
pub person_id: PersonId,
pub reason: Option<String>,
pub auth: String,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct PurgeCommunity {
pub community_id: CommunityId,
pub reason: Option<String>,
pub auth: String,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct PurgePost {
pub post_id: PostId,
pub reason: Option<String>,
pub auth: String,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct PurgeComment {
pub comment_id: CommentId,
pub reason: Option<String>,
pub auth: String,
}
#[derive(Serialize, Deserialize)]
pub struct PurgeItemResponse {
pub success: bool,
}
#[derive(Debug, Serialize, Deserialize, Clone, Default)] #[derive(Debug, Serialize, Deserialize, Clone, Default)]
pub struct ListRegistrationApplications { pub struct ListRegistrationApplications {
/// Only shows the unread applications (IE those without an admin actor) /// Only shows the unread applications (IE those without an admin actor)

View file

@ -1,4 +1,4 @@
use crate::{sensitive::Sensitive, site::FederatedInstances}; use crate::{request::purge_image_from_pictrs, sensitive::Sensitive, site::FederatedInstances};
use lemmy_db_schema::{ use lemmy_db_schema::{
newtypes::{CommunityId, LocalUserId, PersonId, PostId}, newtypes::{CommunityId, LocalUserId, PersonId, PostId},
source::{ source::{
@ -32,6 +32,7 @@ use lemmy_utils::{
settings::structs::Settings, settings::structs::Settings,
utils::generate_random_string, utils::generate_random_string,
}; };
use reqwest_middleware::ClientWithMiddleware;
use rosetta_i18n::{Language, LanguageId}; use rosetta_i18n::{Language, LanguageId};
use tracing::warn; use tracing::warn;
@ -505,13 +506,98 @@ pub async fn check_private_instance_and_federation_enabled(
Ok(()) Ok(())
} }
pub async fn remove_user_data(banned_person_id: PersonId, pool: &DbPool) -> Result<(), LemmyError> { pub async fn purge_image_posts_for_person(
banned_person_id: PersonId,
pool: &DbPool,
settings: &Settings,
client: &ClientWithMiddleware,
) -> Result<(), LemmyError> {
let posts = blocking(pool, move |conn: &'_ _| {
Post::fetch_pictrs_posts_for_creator(conn, banned_person_id)
})
.await??;
for post in posts {
if let Some(url) = post.url {
purge_image_from_pictrs(client, settings, &url).await.ok();
}
if let Some(thumbnail_url) = post.thumbnail_url {
purge_image_from_pictrs(client, settings, &thumbnail_url)
.await
.ok();
}
}
blocking(pool, move |conn| {
Post::remove_pictrs_post_images_and_thumbnails_for_creator(conn, banned_person_id)
})
.await??;
Ok(())
}
pub async fn purge_image_posts_for_community(
banned_community_id: CommunityId,
pool: &DbPool,
settings: &Settings,
client: &ClientWithMiddleware,
) -> Result<(), LemmyError> {
let posts = blocking(pool, move |conn: &'_ _| {
Post::fetch_pictrs_posts_for_community(conn, banned_community_id)
})
.await??;
for post in posts {
if let Some(url) = post.url {
purge_image_from_pictrs(client, settings, &url).await.ok();
}
if let Some(thumbnail_url) = post.thumbnail_url {
purge_image_from_pictrs(client, settings, &thumbnail_url)
.await
.ok();
}
}
blocking(pool, move |conn| {
Post::remove_pictrs_post_images_and_thumbnails_for_community(conn, banned_community_id)
})
.await??;
Ok(())
}
pub async fn remove_user_data(
banned_person_id: PersonId,
pool: &DbPool,
settings: &Settings,
client: &ClientWithMiddleware,
) -> Result<(), LemmyError> {
// Purge user images
let person = blocking(pool, move |conn| Person::read(conn, banned_person_id)).await??;
if let Some(avatar) = person.avatar {
purge_image_from_pictrs(client, settings, &avatar)
.await
.ok();
}
if let Some(banner) = person.banner {
purge_image_from_pictrs(client, settings, &banner)
.await
.ok();
}
// Update the fields to None
blocking(pool, move |conn| {
Person::remove_avatar_and_banner(conn, banned_person_id)
})
.await??;
// Posts // Posts
blocking(pool, move |conn: &'_ _| { blocking(pool, move |conn: &'_ _| {
Post::update_removed_for_creator(conn, banned_person_id, None, true) Post::update_removed_for_creator(conn, banned_person_id, None, true)
}) })
.await??; .await??;
// Purge image posts
purge_image_posts_for_person(banned_person_id, pool, settings, client).await?;
// Communities // Communities
// Remove all communities where they're the top mod // Remove all communities where they're the top mod
// for now, remove the communities manually // for now, remove the communities manually
@ -527,8 +613,24 @@ pub async fn remove_user_data(banned_person_id: PersonId, pool: &DbPool) -> Resu
.collect(); .collect();
for first_mod_community in banned_user_first_communities { for first_mod_community in banned_user_first_communities {
let community_id = first_mod_community.community.id;
blocking(pool, move |conn: &'_ _| { blocking(pool, move |conn: &'_ _| {
Community::update_removed(conn, first_mod_community.community.id, true) Community::update_removed(conn, community_id, true)
})
.await??;
// Delete the community images
if let Some(icon) = first_mod_community.community.icon {
purge_image_from_pictrs(client, settings, &icon).await.ok();
}
if let Some(banner) = first_mod_community.community.banner {
purge_image_from_pictrs(client, settings, &banner)
.await
.ok();
}
// Update the fields to None
blocking(pool, move |conn| {
Community::remove_avatar_and_banner(conn, community_id)
}) })
.await??; .await??;
} }
@ -575,7 +677,26 @@ pub async fn remove_user_data_in_community(
Ok(()) Ok(())
} }
pub async fn delete_user_account(person_id: PersonId, pool: &DbPool) -> Result<(), LemmyError> { pub async fn delete_user_account(
person_id: PersonId,
pool: &DbPool,
settings: &Settings,
client: &ClientWithMiddleware,
) -> Result<(), LemmyError> {
// Delete their images
let person = blocking(pool, move |conn| Person::read(conn, person_id)).await??;
if let Some(avatar) = person.avatar {
purge_image_from_pictrs(client, settings, &avatar)
.await
.ok();
}
if let Some(banner) = person.banner {
purge_image_from_pictrs(client, settings, &banner)
.await
.ok();
}
// No need to update avatar and banner, those are handled in Person::delete_account
// Comments // Comments
let permadelete = move |conn: &'_ _| Comment::permadelete_for_creator(conn, person_id); let permadelete = move |conn: &'_ _| Comment::permadelete_for_creator(conn, person_id);
blocking(pool, permadelete) blocking(pool, permadelete)
@ -588,6 +709,9 @@ pub async fn delete_user_account(person_id: PersonId, pool: &DbPool) -> Result<(
.await? .await?
.map_err(|e| LemmyError::from_error_message(e, "couldnt_update_post"))?; .map_err(|e| LemmyError::from_error_message(e, "couldnt_update_post"))?;
// Purge image posts
purge_image_posts_for_person(person_id, pool, settings, client).await?;
blocking(pool, move |conn| Person::delete_account(conn, person_id)).await??; blocking(pool, move |conn| Person::delete_account(conn, person_id)).await??;
Ok(()) Ok(())

View file

@ -33,7 +33,13 @@ impl PerformCrud for DeleteAccount {
return Err(LemmyError::from_message("password_incorrect")); return Err(LemmyError::from_message("password_incorrect"));
} }
delete_user_account(local_user_view.person.id, context.pool()).await?; delete_user_account(
local_user_view.person.id,
context.pool(),
&context.settings(),
context.client(),
)
.await?;
DeleteUser::send(&local_user_view.person.into(), context).await?; DeleteUser::send(&local_user_view.person.into(), context).await?;
Ok(DeleteAccountResponse {}) Ok(DeleteAccountResponse {})

View file

@ -181,7 +181,13 @@ impl ActivityHandler for BlockUser {
}) })
.await??; .await??;
if self.remove_data.unwrap_or(false) { if self.remove_data.unwrap_or(false) {
remove_user_data(blocked_person.id, context.pool()).await?; remove_user_data(
blocked_person.id,
context.pool(),
&context.settings(),
context.client(),
)
.await?;
} }
// write mod log // write mod log

View file

@ -51,7 +51,13 @@ impl ActivityHandler for DeleteUser {
.actor .actor
.dereference(context, local_instance(context), request_counter) .dereference(context, local_instance(context), request_counter)
.await?; .await?;
delete_user_account(actor.id, context.pool()).await?; delete_user_account(
actor.id,
context.pool(),
&context.settings(),
context.client(),
)
.await?;
Ok(()) Ok(())
} }
} }

View file

@ -138,6 +138,19 @@ impl Community {
.set(community_form) .set(community_form)
.get_result::<Self>(conn) .get_result::<Self>(conn)
} }
pub fn remove_avatar_and_banner(
conn: &PgConnection,
community_id: CommunityId,
) -> Result<Self, Error> {
use crate::schema::community::dsl::*;
diesel::update(community.find(community_id))
.set((
icon.eq::<Option<String>>(None),
banner.eq::<Option<String>>(None),
))
.get_result::<Self>(conn)
}
} }
impl Joinable for CommunityModerator { impl Joinable for CommunityModerator {

View file

@ -263,6 +263,98 @@ impl Crud for ModAdd {
} }
} }
impl Crud for AdminPurgePerson {
type Form = AdminPurgePersonForm;
type IdType = i32;
fn read(conn: &PgConnection, from_id: i32) -> Result<Self, Error> {
use crate::schema::admin_purge_person::dsl::*;
admin_purge_person.find(from_id).first::<Self>(conn)
}
fn create(conn: &PgConnection, form: &Self::Form) -> Result<Self, Error> {
use crate::schema::admin_purge_person::dsl::*;
insert_into(admin_purge_person)
.values(form)
.get_result::<Self>(conn)
}
fn update(conn: &PgConnection, from_id: i32, form: &Self::Form) -> Result<Self, Error> {
use crate::schema::admin_purge_person::dsl::*;
diesel::update(admin_purge_person.find(from_id))
.set(form)
.get_result::<Self>(conn)
}
}
impl Crud for AdminPurgeCommunity {
type Form = AdminPurgeCommunityForm;
type IdType = i32;
fn read(conn: &PgConnection, from_id: i32) -> Result<Self, Error> {
use crate::schema::admin_purge_community::dsl::*;
admin_purge_community.find(from_id).first::<Self>(conn)
}
fn create(conn: &PgConnection, form: &Self::Form) -> Result<Self, Error> {
use crate::schema::admin_purge_community::dsl::*;
insert_into(admin_purge_community)
.values(form)
.get_result::<Self>(conn)
}
fn update(conn: &PgConnection, from_id: i32, form: &Self::Form) -> Result<Self, Error> {
use crate::schema::admin_purge_community::dsl::*;
diesel::update(admin_purge_community.find(from_id))
.set(form)
.get_result::<Self>(conn)
}
}
impl Crud for AdminPurgePost {
type Form = AdminPurgePostForm;
type IdType = i32;
fn read(conn: &PgConnection, from_id: i32) -> Result<Self, Error> {
use crate::schema::admin_purge_post::dsl::*;
admin_purge_post.find(from_id).first::<Self>(conn)
}
fn create(conn: &PgConnection, form: &Self::Form) -> Result<Self, Error> {
use crate::schema::admin_purge_post::dsl::*;
insert_into(admin_purge_post)
.values(form)
.get_result::<Self>(conn)
}
fn update(conn: &PgConnection, from_id: i32, form: &Self::Form) -> Result<Self, Error> {
use crate::schema::admin_purge_post::dsl::*;
diesel::update(admin_purge_post.find(from_id))
.set(form)
.get_result::<Self>(conn)
}
}
impl Crud for AdminPurgeComment {
type Form = AdminPurgeCommentForm;
type IdType = i32;
fn read(conn: &PgConnection, from_id: i32) -> Result<Self, Error> {
use crate::schema::admin_purge_comment::dsl::*;
admin_purge_comment.find(from_id).first::<Self>(conn)
}
fn create(conn: &PgConnection, form: &Self::Form) -> Result<Self, Error> {
use crate::schema::admin_purge_comment::dsl::*;
insert_into(admin_purge_comment)
.values(form)
.get_result::<Self>(conn)
}
fn update(conn: &PgConnection, from_id: i32, form: &Self::Form) -> Result<Self, Error> {
use crate::schema::admin_purge_comment::dsl::*;
diesel::update(admin_purge_comment.find(from_id))
.set(form)
.get_result::<Self>(conn)
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use crate::{ use crate::{

View file

@ -228,6 +228,8 @@ impl Person {
diesel::update(person.find(person_id)) diesel::update(person.find(person_id))
.set(( .set((
display_name.eq::<Option<String>>(None), display_name.eq::<Option<String>>(None),
avatar.eq::<Option<String>>(None),
banner.eq::<Option<String>>(None),
bio.eq::<Option<String>>(None), bio.eq::<Option<String>>(None),
matrix_user_id.eq::<Option<String>>(None), matrix_user_id.eq::<Option<String>>(None),
deleted.eq(true), deleted.eq(true),
@ -265,6 +267,15 @@ impl Person {
.set(admin.eq(false)) .set(admin.eq(false))
.get_result::<Self>(conn) .get_result::<Self>(conn)
} }
pub fn remove_avatar_and_banner(conn: &PgConnection, person_id: PersonId) -> Result<Self, Error> {
diesel::update(person.find(person_id))
.set((
avatar.eq::<Option<String>>(None),
banner.eq::<Option<String>>(None),
))
.get_result::<Self>(conn)
}
} }
impl PersonSafe { impl PersonSafe {

View file

@ -13,7 +13,7 @@ use crate::{
traits::{Crud, DeleteableOrRemoveable, Likeable, Readable, Saveable}, traits::{Crud, DeleteableOrRemoveable, Likeable, Readable, Saveable},
utils::naive_now, utils::naive_now,
}; };
use diesel::{dsl::*, result::Error, ExpressionMethods, PgConnection, QueryDsl, RunQueryDsl}; use diesel::{dsl::*, result::Error, ExpressionMethods, PgConnection, QueryDsl, RunQueryDsl, *};
use url::Url; use url::Url;
impl Crud for Post { impl Crud for Post {
@ -174,6 +174,71 @@ impl Post {
.map(Into::into), .map(Into::into),
) )
} }
pub fn fetch_pictrs_posts_for_creator(
conn: &PgConnection,
for_creator_id: PersonId,
) -> Result<Vec<Self>, Error> {
use crate::schema::post::dsl::*;
let pictrs_search = "%pictrs/image%";
post
.filter(creator_id.eq(for_creator_id))
.filter(url.like(pictrs_search))
.load::<Self>(conn)
}
/// Sets the url and thumbnails fields to None
pub fn remove_pictrs_post_images_and_thumbnails_for_creator(
conn: &PgConnection,
for_creator_id: PersonId,
) -> Result<Vec<Self>, Error> {
use crate::schema::post::dsl::*;
let pictrs_search = "%pictrs/image%";
diesel::update(
post
.filter(creator_id.eq(for_creator_id))
.filter(url.like(pictrs_search)),
)
.set((
url.eq::<Option<String>>(None),
thumbnail_url.eq::<Option<String>>(None),
))
.get_results::<Self>(conn)
}
pub fn fetch_pictrs_posts_for_community(
conn: &PgConnection,
for_community_id: CommunityId,
) -> Result<Vec<Self>, Error> {
use crate::schema::post::dsl::*;
let pictrs_search = "%pictrs/image%";
post
.filter(community_id.eq(for_community_id))
.filter(url.like(pictrs_search))
.load::<Self>(conn)
}
/// Sets the url and thumbnails fields to None
pub fn remove_pictrs_post_images_and_thumbnails_for_community(
conn: &PgConnection,
for_community_id: CommunityId,
) -> Result<Vec<Self>, Error> {
use crate::schema::post::dsl::*;
let pictrs_search = "%pictrs/image%";
diesel::update(
post
.filter(community_id.eq(for_community_id))
.filter(url.like(pictrs_search)),
)
.set((
url.eq::<Option<String>>(None),
thumbnail_url.eq::<Option<String>>(None),
))
.get_results::<Self>(conn)
}
} }
impl Likeable for PostLike { impl Likeable for PostLike {

View file

@ -577,6 +577,16 @@ table! {
} }
} }
table! {
admin_purge_comment (id) {
id -> Int4,
admin_person_id -> Int4,
post_id -> Int4,
reason -> Nullable<Text>,
when_ -> Timestamp,
}
}
table! { table! {
email_verification (id) { email_verification (id) {
id -> Int4, id -> Int4,
@ -587,6 +597,34 @@ table! {
} }
} }
table! {
admin_purge_community (id) {
id -> Int4,
admin_person_id -> Int4,
reason -> Nullable<Text>,
when_ -> Timestamp,
}
}
table! {
admin_purge_person (id) {
id -> Int4,
admin_person_id -> Int4,
reason -> Nullable<Text>,
when_ -> Timestamp,
}
}
table! {
admin_purge_post (id) {
id -> Int4,
admin_person_id -> Int4,
community_id -> Int4,
reason -> Nullable<Text>,
when_ -> Timestamp,
}
}
table! { table! {
registration_application (id) { registration_application (id) {
id -> Int4, id -> Int4,
@ -675,6 +713,13 @@ joinable!(registration_application -> person (admin_id));
joinable!(mod_hide_community -> person (mod_person_id)); joinable!(mod_hide_community -> person (mod_person_id));
joinable!(mod_hide_community -> community (community_id)); joinable!(mod_hide_community -> community (community_id));
joinable!(admin_purge_comment -> person (admin_person_id));
joinable!(admin_purge_comment -> post (post_id));
joinable!(admin_purge_community -> person (admin_person_id));
joinable!(admin_purge_person -> person (admin_person_id));
joinable!(admin_purge_post -> community (community_id));
joinable!(admin_purge_post -> person (admin_person_id));
allow_tables_to_appear_in_same_query!( allow_tables_to_appear_in_same_query!(
activity, activity,
comment, comment,
@ -718,6 +763,10 @@ allow_tables_to_appear_in_same_query!(
comment_alias_1, comment_alias_1,
person_alias_1, person_alias_1,
person_alias_2, person_alias_2,
admin_purge_comment,
admin_purge_community,
admin_purge_person,
admin_purge_post,
email_verification, email_verification,
registration_application registration_application
); );

View file

@ -3,6 +3,10 @@ use serde::{Deserialize, Serialize};
#[cfg(feature = "full")] #[cfg(feature = "full")]
use crate::schema::{ use crate::schema::{
admin_purge_comment,
admin_purge_community,
admin_purge_person,
admin_purge_post,
mod_add, mod_add,
mod_add_community, mod_add_community,
mod_ban, mod_ban,
@ -247,3 +251,75 @@ pub struct ModAddForm {
pub other_person_id: PersonId, pub other_person_id: PersonId,
pub removed: Option<bool>, pub removed: Option<bool>,
} }
#[derive(Clone, PartialEq, Debug, Serialize, Deserialize)]
#[cfg_attr(feature = "full", derive(Queryable, Identifiable))]
#[cfg_attr(feature = "full", table_name = "admin_purge_person")]
pub struct AdminPurgePerson {
pub id: i32,
pub admin_person_id: PersonId,
pub reason: Option<String>,
pub when_: chrono::NaiveDateTime,
}
#[cfg_attr(feature = "full", derive(Insertable, AsChangeset))]
#[cfg_attr(feature = "full", table_name = "admin_purge_person")]
pub struct AdminPurgePersonForm {
pub admin_person_id: PersonId,
pub reason: Option<String>,
}
#[derive(Clone, PartialEq, Debug, Serialize, Deserialize)]
#[cfg_attr(feature = "full", derive(Queryable, Identifiable))]
#[cfg_attr(feature = "full", table_name = "admin_purge_community")]
pub struct AdminPurgeCommunity {
pub id: i32,
pub admin_person_id: PersonId,
pub reason: Option<String>,
pub when_: chrono::NaiveDateTime,
}
#[cfg_attr(feature = "full", derive(Insertable, AsChangeset))]
#[cfg_attr(feature = "full", table_name = "admin_purge_community")]
pub struct AdminPurgeCommunityForm {
pub admin_person_id: PersonId,
pub reason: Option<String>,
}
#[derive(Clone, PartialEq, Debug, Serialize, Deserialize)]
#[cfg_attr(feature = "full", derive(Queryable, Identifiable))]
#[cfg_attr(feature = "full", table_name = "admin_purge_post")]
pub struct AdminPurgePost {
pub id: i32,
pub admin_person_id: PersonId,
pub community_id: CommunityId,
pub reason: Option<String>,
pub when_: chrono::NaiveDateTime,
}
#[cfg_attr(feature = "full", derive(Insertable, AsChangeset))]
#[cfg_attr(feature = "full", table_name = "admin_purge_post")]
pub struct AdminPurgePostForm {
pub admin_person_id: PersonId,
pub community_id: CommunityId,
pub reason: Option<String>,
}
#[derive(Clone, PartialEq, Debug, Serialize, Deserialize)]
#[cfg_attr(feature = "full", derive(Queryable, Identifiable))]
#[cfg_attr(feature = "full", table_name = "admin_purge_comment")]
pub struct AdminPurgeComment {
pub id: i32,
pub admin_person_id: PersonId,
pub post_id: PostId,
pub reason: Option<String>,
pub when_: chrono::NaiveDateTime,
}
#[cfg_attr(feature = "full", derive(Insertable, AsChangeset))]
#[cfg_attr(feature = "full", table_name = "admin_purge_comment")]
pub struct AdminPurgeCommentForm {
pub admin_person_id: PersonId,
pub post_id: PostId,
pub reason: Option<String>,
}

View file

@ -0,0 +1,62 @@
use crate::structs::AdminPurgeCommentView;
use diesel::{result::Error, *};
use lemmy_db_schema::{
newtypes::PersonId,
schema::{admin_purge_comment, person, post},
source::{
moderator::AdminPurgeComment,
person::{Person, PersonSafe},
post::Post,
},
traits::{ToSafe, ViewToVec},
utils::limit_and_offset,
};
type AdminPurgeCommentViewTuple = (AdminPurgeComment, PersonSafe, Post);
impl AdminPurgeCommentView {
pub fn list(
conn: &PgConnection,
admin_person_id: Option<PersonId>,
page: Option<i64>,
limit: Option<i64>,
) -> Result<Vec<Self>, Error> {
let mut query = admin_purge_comment::table
.inner_join(person::table.on(admin_purge_comment::admin_person_id.eq(person::id)))
.inner_join(post::table)
.select((
admin_purge_comment::all_columns,
Person::safe_columns_tuple(),
post::all_columns,
))
.into_boxed();
if let Some(admin_person_id) = admin_person_id {
query = query.filter(admin_purge_comment::admin_person_id.eq(admin_person_id));
};
let (limit, offset) = limit_and_offset(page, limit);
let res = query
.limit(limit)
.offset(offset)
.order_by(admin_purge_comment::when_.desc())
.load::<AdminPurgeCommentViewTuple>(conn)?;
Ok(Self::from_tuple_to_vec(res))
}
}
impl ViewToVec for AdminPurgeCommentView {
type DbTuple = AdminPurgeCommentViewTuple;
fn from_tuple_to_vec(items: Vec<Self::DbTuple>) -> Vec<Self> {
items
.iter()
.map(|a| Self {
admin_purge_comment: a.0.to_owned(),
admin: a.1.to_owned(),
post: a.2.to_owned(),
})
.collect::<Vec<Self>>()
}
}

View file

@ -0,0 +1,58 @@
use crate::structs::AdminPurgeCommunityView;
use diesel::{result::Error, *};
use lemmy_db_schema::{
newtypes::PersonId,
schema::{admin_purge_community, person},
source::{
moderator::AdminPurgeCommunity,
person::{Person, PersonSafe},
},
traits::{ToSafe, ViewToVec},
utils::limit_and_offset,
};
type AdminPurgeCommunityViewTuple = (AdminPurgeCommunity, PersonSafe);
impl AdminPurgeCommunityView {
pub fn list(
conn: &PgConnection,
admin_person_id: Option<PersonId>,
page: Option<i64>,
limit: Option<i64>,
) -> Result<Vec<Self>, Error> {
let mut query = admin_purge_community::table
.inner_join(person::table.on(admin_purge_community::admin_person_id.eq(person::id)))
.select((
admin_purge_community::all_columns,
Person::safe_columns_tuple(),
))
.into_boxed();
if let Some(admin_person_id) = admin_person_id {
query = query.filter(admin_purge_community::admin_person_id.eq(admin_person_id));
};
let (limit, offset) = limit_and_offset(page, limit);
let res = query
.limit(limit)
.offset(offset)
.order_by(admin_purge_community::when_.desc())
.load::<AdminPurgeCommunityViewTuple>(conn)?;
Ok(Self::from_tuple_to_vec(res))
}
}
impl ViewToVec for AdminPurgeCommunityView {
type DbTuple = AdminPurgeCommunityViewTuple;
fn from_tuple_to_vec(items: Vec<Self::DbTuple>) -> Vec<Self> {
items
.iter()
.map(|a| Self {
admin_purge_community: a.0.to_owned(),
admin: a.1.to_owned(),
})
.collect::<Vec<Self>>()
}
}

View file

@ -0,0 +1,58 @@
use crate::structs::AdminPurgePersonView;
use diesel::{result::Error, *};
use lemmy_db_schema::{
newtypes::PersonId,
schema::{admin_purge_person, person},
source::{
moderator::AdminPurgePerson,
person::{Person, PersonSafe},
},
traits::{ToSafe, ViewToVec},
utils::limit_and_offset,
};
type AdminPurgePersonViewTuple = (AdminPurgePerson, PersonSafe);
impl AdminPurgePersonView {
pub fn list(
conn: &PgConnection,
admin_person_id: Option<PersonId>,
page: Option<i64>,
limit: Option<i64>,
) -> Result<Vec<Self>, Error> {
let mut query = admin_purge_person::table
.inner_join(person::table.on(admin_purge_person::admin_person_id.eq(person::id)))
.select((
admin_purge_person::all_columns,
Person::safe_columns_tuple(),
))
.into_boxed();
if let Some(admin_person_id) = admin_person_id {
query = query.filter(admin_purge_person::admin_person_id.eq(admin_person_id));
};
let (limit, offset) = limit_and_offset(page, limit);
let res = query
.limit(limit)
.offset(offset)
.order_by(admin_purge_person::when_.desc())
.load::<AdminPurgePersonViewTuple>(conn)?;
Ok(Self::from_tuple_to_vec(res))
}
}
impl ViewToVec for AdminPurgePersonView {
type DbTuple = AdminPurgePersonViewTuple;
fn from_tuple_to_vec(items: Vec<Self::DbTuple>) -> Vec<Self> {
items
.iter()
.map(|a| Self {
admin_purge_person: a.0.to_owned(),
admin: a.1.to_owned(),
})
.collect::<Vec<Self>>()
}
}

View file

@ -0,0 +1,62 @@
use crate::structs::AdminPurgePostView;
use diesel::{result::Error, *};
use lemmy_db_schema::{
newtypes::PersonId,
schema::{admin_purge_post, community, person},
source::{
community::{Community, CommunitySafe},
moderator::AdminPurgePost,
person::{Person, PersonSafe},
},
traits::{ToSafe, ViewToVec},
utils::limit_and_offset,
};
type AdminPurgePostViewTuple = (AdminPurgePost, PersonSafe, CommunitySafe);
impl AdminPurgePostView {
pub fn list(
conn: &PgConnection,
admin_person_id: Option<PersonId>,
page: Option<i64>,
limit: Option<i64>,
) -> Result<Vec<Self>, Error> {
let mut query = admin_purge_post::table
.inner_join(person::table.on(admin_purge_post::admin_person_id.eq(person::id)))
.inner_join(community::table)
.select((
admin_purge_post::all_columns,
Person::safe_columns_tuple(),
Community::safe_columns_tuple(),
))
.into_boxed();
if let Some(admin_person_id) = admin_person_id {
query = query.filter(admin_purge_post::admin_person_id.eq(admin_person_id));
};
let (limit, offset) = limit_and_offset(page, limit);
let res = query
.limit(limit)
.offset(offset)
.order_by(admin_purge_post::when_.desc())
.load::<AdminPurgePostViewTuple>(conn)?;
Ok(Self::from_tuple_to_vec(res))
}
}
impl ViewToVec for AdminPurgePostView {
type DbTuple = AdminPurgePostViewTuple;
fn from_tuple_to_vec(items: Vec<Self::DbTuple>) -> Vec<Self> {
items
.iter()
.map(|a| Self {
admin_purge_post: a.0.to_owned(),
admin: a.1.to_owned(),
community: a.2.to_owned(),
})
.collect::<Vec<Self>>()
}
}

View file

@ -1,4 +1,12 @@
#[cfg(feature = "full")] #[cfg(feature = "full")]
pub mod admin_purge_comment_view;
#[cfg(feature = "full")]
pub mod admin_purge_community_view;
#[cfg(feature = "full")]
pub mod admin_purge_person_view;
#[cfg(feature = "full")]
pub mod admin_purge_post_view;
#[cfg(feature = "full")]
pub mod mod_add_community_view; pub mod mod_add_community_view;
#[cfg(feature = "full")] #[cfg(feature = "full")]
pub mod mod_add_view; pub mod mod_add_view;

View file

@ -2,6 +2,10 @@ use lemmy_db_schema::source::{
comment::Comment, comment::Comment,
community::CommunitySafe, community::CommunitySafe,
moderator::{ moderator::{
AdminPurgeComment,
AdminPurgeCommunity,
AdminPurgePerson,
AdminPurgePost,
ModAdd, ModAdd,
ModAddCommunity, ModAddCommunity,
ModBan, ModBan,
@ -104,3 +108,29 @@ pub struct ModTransferCommunityView {
pub community: CommunitySafe, pub community: CommunitySafe,
pub modded_person: PersonSafeAlias1, pub modded_person: PersonSafeAlias1,
} }
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct AdminPurgeCommentView {
pub admin_purge_comment: AdminPurgeComment,
pub admin: PersonSafe,
pub post: Post,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct AdminPurgeCommunityView {
pub admin_purge_community: AdminPurgeCommunity,
pub admin: PersonSafe,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct AdminPurgePersonView {
pub admin_purge_person: AdminPurgePerson,
pub admin: PersonSafe,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct AdminPurgePostView {
pub admin_purge_post: AdminPurgePost,
pub admin: PersonSafe,
pub community: CommunitySafe,
}

View file

@ -10,9 +10,8 @@ use actix_web::{
HttpRequest, HttpRequest,
HttpResponse, HttpResponse,
}; };
use anyhow::anyhow;
use futures::stream::{Stream, StreamExt}; use futures::stream::{Stream, StreamExt};
use lemmy_utils::{claims::Claims, error::LemmyError, rate_limit::RateLimit}; use lemmy_utils::{claims::Claims, rate_limit::RateLimit, REQWEST_TIMEOUT};
use lemmy_websocket::LemmyContext; use lemmy_websocket::LemmyContext;
use reqwest::Body; use reqwest::Body;
use reqwest_middleware::{ClientWithMiddleware, RequestBuilder}; use reqwest_middleware::{ClientWithMiddleware, RequestBuilder};
@ -28,7 +27,8 @@ pub fn config(cfg: &mut web::ServiceConfig, client: ClientWithMiddleware, rate_l
) )
// This has optional query params: /image/{filename}?format=jpg&thumbnail=256 // This has optional query params: /image/{filename}?format=jpg&thumbnail=256
.service(web::resource("/pictrs/image/{filename}").route(web::get().to(full_res))) .service(web::resource("/pictrs/image/{filename}").route(web::get().to(full_res)))
.service(web::resource("/pictrs/image/delete/{token}/{filename}").route(web::get().to(delete))); .service(web::resource("/pictrs/image/delete/{token}/{filename}").route(web::get().to(delete)))
.service(web::resource("/pictrs/internal/purge").route(web::post().to(purge)));
} }
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
@ -49,6 +49,14 @@ struct PictrsParams {
thumbnail: Option<String>, thumbnail: Option<String>,
} }
#[derive(Deserialize)]
enum PictrsPurgeParams {
#[serde(rename = "file")]
File(String),
#[serde(rename = "alias")]
Alias(String),
}
fn adapt_request( fn adapt_request(
request: &HttpRequest, request: &HttpRequest,
client: &ClientWithMiddleware, client: &ClientWithMiddleware,
@ -57,7 +65,9 @@ fn adapt_request(
// remove accept-encoding header so that pictrs doesnt compress the response // remove accept-encoding header so that pictrs doesnt compress the response
const INVALID_HEADERS: &[HeaderName] = &[ACCEPT_ENCODING, HOST]; const INVALID_HEADERS: &[HeaderName] = &[ACCEPT_ENCODING, HOST];
let client_request = client.request(request.method().clone(), url); let client_request = client
.request(request.method().clone(), url)
.timeout(REQWEST_TIMEOUT);
request request
.headers() .headers()
@ -86,7 +96,8 @@ async fn upload(
return Ok(HttpResponse::Unauthorized().finish()); return Ok(HttpResponse::Unauthorized().finish());
}; };
let image_url = format!("{}/image", pictrs_url(context.settings().pictrs_url)?); let pictrs_config = context.settings().pictrs_config()?;
let image_url = format!("{}/image", pictrs_config.url);
let mut client_req = adapt_request(&req, &client, image_url); let mut client_req = adapt_request(&req, &client, image_url);
@ -116,22 +127,16 @@ async fn full_res(
let name = &filename.into_inner(); let name = &filename.into_inner();
// If there are no query params, the URL is original // If there are no query params, the URL is original
let pictrs_url_settings = context.settings().pictrs_url; let pictrs_config = context.settings().pictrs_config()?;
let url = if params.format.is_none() && params.thumbnail.is_none() { let url = if params.format.is_none() && params.thumbnail.is_none() {
format!( format!("{}/image/original/{}", pictrs_config.url, name,)
"{}/image/original/{}",
pictrs_url(pictrs_url_settings)?,
name,
)
} else { } else {
// Use jpg as a default when none is given // Use jpg as a default when none is given
let format = params.format.unwrap_or_else(|| "jpg".to_string()); let format = params.format.unwrap_or_else(|| "jpg".to_string());
let mut url = format!( let mut url = format!(
"{}/image/process.{}?src={}", "{}/image/process.{}?src={}",
pictrs_url(pictrs_url_settings)?, pictrs_config.url, format, name,
format,
name,
); );
if let Some(size) = params.thumbnail { if let Some(size) = params.thumbnail {
@ -181,12 +186,8 @@ async fn delete(
) -> Result<HttpResponse, Error> { ) -> Result<HttpResponse, Error> {
let (token, file) = components.into_inner(); let (token, file) = components.into_inner();
let url = format!( let pictrs_config = context.settings().pictrs_config()?;
"{}/image/delete/{}/{}", let url = format!("{}/image/delete/{}/{}", pictrs_config.url, &token, &file);
pictrs_url(context.settings().pictrs_url)?,
&token,
&file
);
let mut client_req = adapt_request(&req, &client, url); let mut client_req = adapt_request(&req, &client, url);
@ -199,8 +200,32 @@ async fn delete(
Ok(HttpResponse::build(res.status()).body(BodyStream::new(res.bytes_stream()))) Ok(HttpResponse::build(res.status()).body(BodyStream::new(res.bytes_stream())))
} }
fn pictrs_url(pictrs_url: Option<String>) -> Result<String, LemmyError> { async fn purge(
pictrs_url.ok_or_else(|| anyhow!("images_disabled").into()) web::Query(params): web::Query<PictrsPurgeParams>,
req: HttpRequest,
client: web::Data<ClientWithMiddleware>,
context: web::Data<LemmyContext>,
) -> Result<HttpResponse, Error> {
let purge_string = match params {
PictrsPurgeParams::File(f) => format!("file={}", f),
PictrsPurgeParams::Alias(a) => format!("alias={}", a),
};
let pictrs_config = context.settings().pictrs_config()?;
let url = format!("{}/internal/purge?{}", pictrs_config.url, &purge_string);
let mut client_req = adapt_request(&req, &client, url);
if let Some(addr) = req.head().peer_addr {
client_req = client_req.header("X-Forwarded-For", addr.to_string())
}
// Add the API token, X-Api-Token header
client_req = client_req.header("x-api-token", pictrs_config.api_key);
let res = client_req.send().await.map_err(error::ErrorBadRequest)?;
Ok(HttpResponse::build(res.status()).body(BodyStream::new(res.bytes_stream())))
} }
fn make_send<S>(mut stream: S) -> impl Stream<Item = S::Item> + Send + Unpin + 'static fn make_send<S>(mut stream: S) -> impl Stream<Item = S::Item> + Send + Unpin + 'static

View file

@ -1,4 +1,8 @@
use crate::{error::LemmyError, location_info, settings::structs::Settings}; use crate::{
error::LemmyError,
location_info,
settings::structs::{PictrsConfig, Settings},
};
use anyhow::{anyhow, Context}; use anyhow::{anyhow, Context};
use deser_hjson::from_str; use deser_hjson::from_str;
use once_cell::sync::Lazy; use once_cell::sync::Lazy;
@ -116,4 +120,11 @@ impl Settings {
.expect("compile regex") .expect("compile regex")
}) })
} }
pub fn pictrs_config(&self) -> Result<PictrsConfig, LemmyError> {
self
.pictrs_config
.to_owned()
.ok_or_else(|| anyhow!("images_disabled").into())
}
} }

View file

@ -14,6 +14,9 @@ pub struct Settings {
/// Settings related to activitypub federation /// Settings related to activitypub federation
#[default(FederationConfig::default())] #[default(FederationConfig::default())]
pub federation: FederationConfig, pub federation: FederationConfig,
/// Pictrs image server configuration.
#[default(None)]
pub(crate) pictrs_config: Option<PictrsConfig>,
#[default(CaptchaConfig::default())] #[default(CaptchaConfig::default())]
pub captcha: CaptchaConfig, pub captcha: CaptchaConfig,
/// Email sending configuration. All options except login/password are mandatory /// Email sending configuration. All options except login/password are mandatory
@ -36,12 +39,9 @@ pub struct Settings {
/// Whether the site is available over TLS. Needs to be true for federation to work. /// Whether the site is available over TLS. Needs to be true for federation to work.
#[default(true)] #[default(true)]
pub tls_enabled: bool, pub tls_enabled: bool,
/// Address where pictrs is available (for image hosting)
#[default(None)]
#[doku(example = "http://localhost:8080")]
pub pictrs_url: Option<String>,
#[default(None)] #[default(None)]
#[doku(example = "(\\bThis\\b)|(\\bis\\b)|(\\bsample\\b)")] #[doku(example = "(\\bThis\\b)|(\\bis\\b)|(\\bsample\\b)")]
/// A regex list of slurs to block / hide
pub slur_filter: Option<String>, pub slur_filter: Option<String>,
/// Maximum length of local community and user names /// Maximum length of local community and user names
#[default(20)] #[default(20)]
@ -56,6 +56,18 @@ pub struct Settings {
pub opentelemetry_url: Option<String>, pub opentelemetry_url: Option<String>,
} }
#[derive(Debug, Deserialize, Serialize, Clone, SmartDefault, Document)]
#[serde(default)]
pub struct PictrsConfig {
/// Address where pictrs is available (for image hosting)
#[default("http://pictrs:8080")]
pub url: String,
/// Set a custom pictrs API key. ( Required for deleting images )
#[default("API_KEY")]
pub api_key: String,
}
#[derive(Debug, Deserialize, Serialize, Clone, SmartDefault, Document)] #[derive(Debug, Deserialize, Serialize, Clone, SmartDefault, Document)]
#[serde(default)] #[serde(default)]
pub struct CaptchaConfig { pub struct CaptchaConfig {

View file

@ -142,6 +142,10 @@ pub enum UserOperation {
GetSiteMetadata, GetSiteMetadata,
BlockCommunity, BlockCommunity,
BlockPerson, BlockPerson,
PurgePerson,
PurgeCommunity,
PurgePost,
PurgeComment,
} }
#[derive(EnumString, Display, Debug, Clone)] #[derive(EnumString, Display, Debug, Clone)]

View file

@ -56,8 +56,10 @@ services:
user: 991:991 user: 991:991
environment: environment:
- PICTRS_OPENTELEMETRY_URL=http://otel:4137 - PICTRS_OPENTELEMETRY_URL=http://otel:4137
- PICTRS__API_KEY=API_KEY
ports: ports:
- "6670:6669" - "6670:6669"
- "8080:8080"
volumes: volumes:
- ./volumes/pictrs:/mnt - ./volumes/pictrs:/mnt
restart: always restart: always

View file

@ -20,8 +20,10 @@
# port where lemmy should listen for incoming requests # port where lemmy should listen for incoming requests
port: 8536 port: 8536
# settings related to the postgresql database # settings related to the postgresql database
# address where pictrs is available pictrs_config: {
pictrs_url: "http://pictrs:8080" url: "http://pictrs:8080"
api_key: "API_KEY"
}
database: { database: {
# name of the postgres database for lemmy # name of the postgres database for lemmy
database: "lemmy" database: "lemmy"

View file

@ -0,0 +1,4 @@
drop table admin_purge_person;
drop table admin_purge_community;
drop table admin_purge_post;
drop table admin_purge_comment;

View file

@ -0,0 +1,31 @@
-- Add the admin_purge tables
create table admin_purge_person (
id serial primary key,
admin_person_id int references person on update cascade on delete cascade not null,
reason text,
when_ timestamp not null default now()
);
create table admin_purge_community (
id serial primary key,
admin_person_id int references person on update cascade on delete cascade not null,
reason text,
when_ timestamp not null default now()
);
create table admin_purge_post (
id serial primary key,
admin_person_id int references person on update cascade on delete cascade not null,
community_id int references community on update cascade on delete cascade not null,
reason text,
when_ timestamp not null default now()
);
create table admin_purge_comment (
id serial primary key,
admin_person_id int references person on update cascade on delete cascade not null,
post_id int references post on update cascade on delete cascade not null,
reason text,
when_ timestamp not null default now()
);

View file

@ -232,6 +232,14 @@ pub fn config(cfg: &mut web::ServiceConfig, rate_limit: &RateLimit) {
"/registration_application/approve", "/registration_application/approve",
web::put().to(route_post::<ApproveRegistrationApplication>), web::put().to(route_post::<ApproveRegistrationApplication>),
), ),
)
.service(
web::scope("/admin/purge")
.wrap(rate_limit.message())
.route("/person", web::post().to(route_post::<PurgePerson>))
.route("/community", web::post().to(route_post::<PurgeCommunity>))
.route("/post", web::post().to(route_post::<PurgePost>))
.route("/comment", web::post().to(route_post::<PurgeComment>)),
), ),
); );
} }

View file

@ -99,7 +99,7 @@ async fn main() -> Result<(), LemmyError> {
settings.bind, settings.port settings.bind, settings.port
); );
let client = Client::builder() let reqwest_client = Client::builder()
.user_agent(build_user_agent(&settings)) .user_agent(build_user_agent(&settings))
.timeout(REQWEST_TIMEOUT) .timeout(REQWEST_TIMEOUT)
.build()?; .build()?;
@ -111,11 +111,16 @@ async fn main() -> Result<(), LemmyError> {
backoff_exponent: 2, backoff_exponent: 2,
}; };
let client = ClientBuilder::new(client) let client = ClientBuilder::new(reqwest_client.clone())
.with(TracingMiddleware) .with(TracingMiddleware)
.with(RetryTransientMiddleware::new_with_policy(retry_policy)) .with(RetryTransientMiddleware::new_with_policy(retry_policy))
.build(); .build();
// Pictrs cannot use the retry middleware
let pictrs_client = ClientBuilder::new(reqwest_client.clone())
.with(TracingMiddleware)
.build();
check_private_instance_and_federation_enabled(&pool, &settings).await?; check_private_instance_and_federation_enabled(&pool, &settings).await?;
let chat_server = ChatServer::startup( let chat_server = ChatServer::startup(
@ -149,7 +154,7 @@ async fn main() -> Result<(), LemmyError> {
.configure(|cfg| api_routes::config(cfg, &rate_limiter)) .configure(|cfg| api_routes::config(cfg, &rate_limiter))
.configure(|cfg| lemmy_apub::http::routes::config(cfg, &settings)) .configure(|cfg| lemmy_apub::http::routes::config(cfg, &settings))
.configure(feeds::config) .configure(feeds::config)
.configure(|cfg| images::config(cfg, client.clone(), &rate_limiter)) .configure(|cfg| images::config(cfg, pictrs_client.clone(), &rate_limiter))
.configure(nodeinfo::config) .configure(nodeinfo::config)
.configure(|cfg| webfinger::config(cfg, &settings)) .configure(|cfg| webfinger::config(cfg, &settings))
}) })