use crate::{ context::LemmyContext, request::{ delete_image_from_pictrs, fetch_pictrs_proxied_image_details, purge_image_from_pictrs, }, site::{FederatedInstances, InstanceWithFederationState}, }; use chrono::{DateTime, Days, Local, TimeZone, Utc}; use enum_map::{enum_map, EnumMap}; use lemmy_db_schema::{ aggregates::structs::{PersonPostAggregates, PersonPostAggregatesForm}, newtypes::{CommentId, CommunityId, DbUrl, InstanceId, PersonId, PostId}, source::{ comment::{Comment, CommentLike, CommentUpdateForm}, community::{Community, CommunityModerator, CommunityUpdateForm}, community_block::CommunityBlock, email_verification::{EmailVerification, EmailVerificationForm}, images::{ImageDetails, RemoteImage}, instance::Instance, instance_block::InstanceBlock, local_site::LocalSite, local_site_rate_limit::LocalSiteRateLimit, local_site_url_blocklist::LocalSiteUrlBlocklist, moderator::{ModRemoveComment, ModRemoveCommentForm, ModRemovePost, ModRemovePostForm}, oauth_account::OAuthAccount, password_reset_request::PasswordResetRequest, person::{Person, PersonUpdateForm}, person_block::PersonBlock, post::{Post, PostLike}, registration_application::RegistrationApplication, site::Site, }, traits::{Crud, Likeable}, utils::DbPool, FederationMode, RegistrationMode, }; use lemmy_db_views::{ comment_view::CommentQuery, structs::{LocalImageView, LocalUserView, SiteView}, }; use lemmy_db_views_actor::structs::{ CommunityFollowerView, CommunityModeratorView, CommunityPersonBanView, CommunityView, }; use lemmy_utils::{ email::{send_email, translations::Lang}, error::{LemmyError, LemmyErrorExt, LemmyErrorType, LemmyResult}, rate_limit::{ActionType, BucketConfig}, settings::{ structs::{PictrsImageMode, Settings}, SETTINGS, }, utils::{ markdown::{image_links::markdown_rewrite_image_links, markdown_check_for_blocked_urls}, slurs::{build_slur_regex, remove_slurs}, validation::clean_urls_in_text, }, CACHE_DURATION_FEDERATION, }; use moka::future::Cache; use regex::{escape, Regex, RegexSet}; use rosetta_i18n::{Language, LanguageId}; use std::sync::LazyLock; use tracing::warn; use url::{ParseError, Url}; use urlencoding::encode; pub static AUTH_COOKIE_NAME: &str = "jwt"; #[tracing::instrument(skip_all)] pub async fn is_mod_or_admin( pool: &mut DbPool<'_>, person: &Person, community_id: CommunityId, ) -> LemmyResult<()> { check_user_valid(person)?; CommunityView::check_is_mod_or_admin(pool, person.id, community_id).await } #[tracing::instrument(skip_all)] pub async fn is_mod_or_admin_opt( pool: &mut DbPool<'_>, local_user_view: Option<&LocalUserView>, community_id: Option, ) -> LemmyResult<()> { if let Some(local_user_view) = local_user_view { if let Some(community_id) = community_id { is_mod_or_admin(pool, &local_user_view.person, community_id).await } else { is_admin(local_user_view) } } else { Err(LemmyErrorType::NotAModOrAdmin)? } } /// Check that a person is either a mod of any community, or an admin /// /// Should only be used for read operations #[tracing::instrument(skip_all)] pub async fn check_community_mod_of_any_or_admin_action( local_user_view: &LocalUserView, pool: &mut DbPool<'_>, ) -> LemmyResult<()> { let person = &local_user_view.person; check_user_valid(person)?; CommunityView::check_is_mod_of_any_or_admin(pool, person.id).await } pub fn is_admin(local_user_view: &LocalUserView) -> LemmyResult<()> { check_user_valid(&local_user_view.person)?; if !local_user_view.local_user.admin { Err(LemmyErrorType::NotAnAdmin)? } else if local_user_view.person.banned { Err(LemmyErrorType::Banned)? } else { Ok(()) } } pub fn is_top_mod( local_user_view: &LocalUserView, community_mods: &[CommunityModeratorView], ) -> LemmyResult<()> { check_user_valid(&local_user_view.person)?; if local_user_view.person.id != community_mods .first() .map(|cm| cm.moderator.id) .unwrap_or(PersonId(0)) { Err(LemmyErrorType::NotTopMod)? } else { Ok(()) } } /// Updates the read comment count for a post. Usually done when reading or creating a new comment. #[tracing::instrument(skip_all)] pub async fn update_read_comments( person_id: PersonId, post_id: PostId, read_comments: i64, pool: &mut DbPool<'_>, ) -> LemmyResult<()> { let person_post_agg_form = PersonPostAggregatesForm { person_id, post_id, read_comments, }; PersonPostAggregates::upsert(pool, &person_post_agg_form).await?; Ok(()) } pub fn check_user_valid(person: &Person) -> LemmyResult<()> { // Check for a site ban if person.banned { Err(LemmyErrorType::SiteBan)? } // check for account deletion else if person.deleted { Err(LemmyErrorType::Deleted)? } else { Ok(()) } } /// Check if the user's email is verified if email verification is turned on /// However, skip checking verification if the user is an admin pub fn check_email_verified( local_user_view: &LocalUserView, site_view: &SiteView, ) -> LemmyResult<()> { if !local_user_view.local_user.admin && site_view.local_site.require_email_verification && !local_user_view.local_user.email_verified { Err(LemmyErrorType::EmailNotVerified)? } Ok(()) } pub async fn check_registration_application( local_user_view: &LocalUserView, local_site: &LocalSite, pool: &mut DbPool<'_>, ) -> LemmyResult<()> { if (local_site.registration_mode == RegistrationMode::RequireApplication || local_site.registration_mode == RegistrationMode::Closed) && !local_user_view.local_user.accepted_application && !local_user_view.local_user.admin { // Fetch the registration application. If no admin id is present its still pending. Otherwise it // was processed (either accepted or denied). let local_user_id = local_user_view.local_user.id; let registration = RegistrationApplication::find_by_local_user_id(pool, local_user_id).await?; if registration.admin_id.is_some() { Err(LemmyErrorType::RegistrationDenied { reason: registration.deny_reason, })? } else { Err(LemmyErrorType::RegistrationApplicationIsPending)? } } Ok(()) } /// Checks that a normal user action (eg posting or voting) is allowed in a given community. /// /// In particular it checks that neither the user nor community are banned or deleted, and that /// the user isn't banned. pub async fn check_community_user_action( person: &Person, community: &Community, pool: &mut DbPool<'_>, ) -> LemmyResult<()> { check_user_valid(person)?; check_community_deleted_removed(community)?; CommunityPersonBanView::check(pool, person.id, community.id).await?; CommunityFollowerView::check_private_community_action(pool, person.id, community).await?; Ok(()) } pub fn check_community_deleted_removed(community: &Community) -> LemmyResult<()> { if community.deleted || community.removed { Err(LemmyErrorType::Deleted)? } Ok(()) } /// Check that the given user can perform a mod action in the community. /// /// In particular it checks that he is an admin or mod, wasn't banned and the community isn't /// removed/deleted. pub async fn check_community_mod_action( person: &Person, community: &Community, allow_deleted: bool, pool: &mut DbPool<'_>, ) -> LemmyResult<()> { is_mod_or_admin(pool, person, community.id).await?; CommunityPersonBanView::check(pool, person.id, community.id).await?; // it must be possible to restore deleted community if !allow_deleted { check_community_deleted_removed(community)?; } Ok(()) } /// Don't allow creating reports for removed / deleted posts pub fn check_post_deleted_or_removed(post: &Post) -> LemmyResult<()> { if post.deleted || post.removed { Err(LemmyErrorType::Deleted)? } else { Ok(()) } } pub fn check_comment_deleted_or_removed(comment: &Comment) -> LemmyResult<()> { if comment.deleted || comment.removed { Err(LemmyErrorType::Deleted)? } else { Ok(()) } } #[tracing::instrument(skip_all)] pub async fn check_person_instance_community_block( my_id: PersonId, potential_blocker_id: PersonId, community_instance_id: InstanceId, community_id: CommunityId, pool: &mut DbPool<'_>, ) -> LemmyResult<()> { PersonBlock::read(pool, potential_blocker_id, my_id).await?; InstanceBlock::read(pool, potential_blocker_id, community_instance_id).await?; CommunityBlock::read(pool, potential_blocker_id, community_id).await?; Ok(()) } /// A vote item type used to check the vote mode. pub enum VoteItem { Post(PostId), Comment(CommentId), } #[tracing::instrument(skip_all)] pub async fn check_local_vote_mode( score: i16, vote_item: VoteItem, local_site: &LocalSite, person_id: PersonId, pool: &mut DbPool<'_>, ) -> LemmyResult<()> { let (downvote_setting, upvote_setting) = match vote_item { VoteItem::Post(_) => (local_site.post_downvotes, local_site.post_upvotes), VoteItem::Comment(_) => (local_site.comment_downvotes, local_site.comment_upvotes), }; let downvote_fail = score == -1 && downvote_setting == FederationMode::Disable; let upvote_fail = score == 1 && upvote_setting == FederationMode::Disable; // Undo previous vote for item if new vote fails if downvote_fail || upvote_fail { match vote_item { VoteItem::Post(post_id) => PostLike::remove(pool, person_id, post_id).await?, VoteItem::Comment(comment_id) => CommentLike::remove(pool, person_id, comment_id).await?, }; } Ok(()) } /// Dont allow bots to do certain actions, like voting #[tracing::instrument(skip_all)] pub fn check_bot_account(person: &Person) -> LemmyResult<()> { if person.bot_account { Err(LemmyErrorType::InvalidBotAction)? } else { Ok(()) } } #[tracing::instrument(skip_all)] pub fn check_private_instance( local_user_view: &Option, local_site: &LocalSite, ) -> LemmyResult<()> { if local_user_view.is_none() && local_site.private_instance { Err(LemmyErrorType::InstanceIsPrivate)? } else { Ok(()) } } /// If private messages are disabled, dont allow them to be sent / received #[tracing::instrument(skip_all)] pub fn check_private_messages_enabled(local_user_view: &LocalUserView) -> Result<(), LemmyError> { if !local_user_view.local_user.enable_private_messages { Err(LemmyErrorType::CouldntCreatePrivateMessage)? } else { Ok(()) } } #[tracing::instrument(skip_all)] pub async fn build_federated_instances( local_site: &LocalSite, pool: &mut DbPool<'_>, ) -> LemmyResult> { if local_site.federation_enabled { let mut linked = Vec::new(); let mut allowed = Vec::new(); let mut blocked = Vec::new(); let all = Instance::read_all_with_fed_state(pool).await?; for (instance, federation_state, is_blocked, is_allowed) in all { let i = InstanceWithFederationState { instance, federation_state: federation_state.map(std::convert::Into::into), }; if is_blocked { // blocked instances will only have an entry here if they had been federated with in the // past. blocked.push(i); } else if is_allowed { allowed.push(i.clone()); linked.push(i); } else { // not explicitly allowed but implicitly linked linked.push(i); } } Ok(Some(FederatedInstances { linked, allowed, blocked, })) } else { Ok(None) } } /// Checks the password length pub fn password_length_check(pass: &str) -> LemmyResult<()> { if !(10..=60).contains(&pass.chars().count()) { Err(LemmyErrorType::InvalidPassword)? } else { Ok(()) } } /// Checks for a honeypot. If this field is filled, fail the rest of the function pub fn honeypot_check(honeypot: &Option) -> LemmyResult<()> { if honeypot.is_some() && honeypot != &Some(String::new()) { Err(LemmyErrorType::HoneypotFailed)? } else { Ok(()) } } pub async fn send_email_to_user( local_user_view: &LocalUserView, subject: &str, body: &str, settings: &Settings, ) { if local_user_view.person.banned || !local_user_view.local_user.send_notifications_to_email { return; } if let Some(user_email) = &local_user_view.local_user.email { match send_email( subject, user_email, &local_user_view.person.name, body, settings, ) .await { Ok(_o) => _o, Err(e) => warn!("{}", e), }; } } pub async fn send_password_reset_email( user: &LocalUserView, pool: &mut DbPool<'_>, settings: &Settings, ) -> LemmyResult<()> { // Generate a random token let token = uuid::Uuid::new_v4().to_string(); let email = &user .local_user .email .clone() .ok_or(LemmyErrorType::EmailRequired)?; let lang = get_interface_language(user); let subject = &lang.password_reset_subject(&user.person.name); let protocol_and_hostname = settings.get_protocol_and_hostname(); let reset_link = format!("{}/password_change/{}", protocol_and_hostname, &token); let body = &lang.password_reset_body(reset_link, &user.person.name); send_email(subject, email, &user.person.name, body, settings).await?; // Insert the row after successful send, to avoid using daily reset limit while // email sending is broken. let local_user_id = user.local_user.id; PasswordResetRequest::create(pool, local_user_id, token.clone()).await?; Ok(()) } /// Send a verification email pub async fn send_verification_email( user: &LocalUserView, new_email: &str, pool: &mut DbPool<'_>, settings: &Settings, ) -> LemmyResult<()> { let form = EmailVerificationForm { local_user_id: user.local_user.id, email: new_email.to_string(), verification_token: uuid::Uuid::new_v4().to_string(), }; let verify_link = format!( "{}/verify_email/{}", settings.get_protocol_and_hostname(), &form.verification_token ); EmailVerification::create(pool, &form).await?; let lang = get_interface_language(user); let subject = lang.verify_email_subject(&settings.hostname); let body = lang.verify_email_body(&settings.hostname, &user.person.name, verify_link); send_email(&subject, new_email, &user.person.name, &body, settings).await?; Ok(()) } pub fn get_interface_language(user: &LocalUserView) -> Lang { lang_str_to_lang(&user.local_user.interface_language) } pub fn get_interface_language_from_settings(user: &LocalUserView) -> Lang { lang_str_to_lang(&user.local_user.interface_language) } #[allow(clippy::expect_used)] fn lang_str_to_lang(lang: &str) -> Lang { let lang_id = LanguageId::new(lang); Lang::from_language_id(&lang_id).unwrap_or_else(|| { let en = LanguageId::new("en"); Lang::from_language_id(&en).expect("default language") }) } pub fn local_site_rate_limit_to_rate_limit_config( l: &LocalSiteRateLimit, ) -> EnumMap { enum_map! { ActionType::Message => (l.message, l.message_per_second), ActionType::Post => (l.post, l.post_per_second), ActionType::Register => (l.register, l.register_per_second), ActionType::Image => (l.image, l.image_per_second), ActionType::Comment => (l.comment, l.comment_per_second), ActionType::Search => (l.search, l.search_per_second), ActionType::ImportUserSettings => (l.import_user_settings, l.import_user_settings_per_second), } .map(|_key, (capacity, secs_to_refill)| BucketConfig { capacity: u32::try_from(capacity).unwrap_or(0), secs_to_refill: u32::try_from(secs_to_refill).unwrap_or(0), }) } pub fn local_site_to_slur_regex(local_site: &LocalSite) -> Option> { build_slur_regex(local_site.slur_filter_regex.as_deref()) } pub fn local_site_opt_to_slur_regex(local_site: &Option) -> Option> { local_site .as_ref() .map(local_site_to_slur_regex) .unwrap_or(None) } pub async fn get_url_blocklist(context: &LemmyContext) -> LemmyResult { static URL_BLOCKLIST: LazyLock> = LazyLock::new(|| { Cache::builder() .max_capacity(1) .time_to_live(CACHE_DURATION_FEDERATION) .build() }); Ok( URL_BLOCKLIST .try_get_with::<_, LemmyError>((), async { let urls = LocalSiteUrlBlocklist::get_all(&mut context.pool()).await?; // The urls are already validated on saving, so just escape them. let regexes = urls.iter().map(|url| escape(&url.url)); let set = RegexSet::new(regexes)?; Ok(set) }) .await .map_err(|e| anyhow::anyhow!("Failed to build URL blocklist due to `{}`", e))?, ) } pub async fn send_application_approved_email( user: &LocalUserView, settings: &Settings, ) -> LemmyResult<()> { let email = &user .local_user .email .clone() .ok_or(LemmyErrorType::EmailRequired)?; let lang = get_interface_language(user); let subject = lang.registration_approved_subject(&user.person.actor_id); let body = lang.registration_approved_body(&settings.hostname); send_email(&subject, email, &user.person.name, &body, settings).await } /// Send a new applicant email notification to all admins pub async fn send_new_applicant_email_to_admins( applicant_username: &str, pool: &mut DbPool<'_>, settings: &Settings, ) -> LemmyResult<()> { // Collect the admins with emails let admins = LocalUserView::list_admins_with_emails(pool).await?; let applications_link = &format!( "{}/registration_applications", settings.get_protocol_and_hostname(), ); for admin in &admins { let email = &admin .local_user .email .clone() .ok_or(LemmyErrorType::EmailRequired)?; let lang = get_interface_language_from_settings(admin); let subject = lang.new_application_subject(&settings.hostname, applicant_username); let body = lang.new_application_body(applications_link); send_email(&subject, email, &admin.person.name, &body, settings).await?; } Ok(()) } /// Send a report to all admins pub async fn send_new_report_email_to_admins( reporter_username: &str, reported_username: &str, pool: &mut DbPool<'_>, settings: &Settings, ) -> LemmyResult<()> { // Collect the admins with emails let admins = LocalUserView::list_admins_with_emails(pool).await?; let reports_link = &format!("{}/reports", settings.get_protocol_and_hostname(),); for admin in &admins { if let Some(email) = &admin.local_user.email { let lang = get_interface_language_from_settings(admin); let subject = lang.new_report_subject(&settings.hostname, reported_username, reporter_username); let body = lang.new_report_body(reports_link); send_email(&subject, email, &admin.person.name, &body, settings).await?; } } Ok(()) } pub fn check_private_instance_and_federation_enabled(local_site: &LocalSite) -> LemmyResult<()> { if local_site.private_instance && local_site.federation_enabled { Err(LemmyErrorType::CantEnablePrivateInstanceAndFederationTogether)? } else { Ok(()) } } /// Read the site for an actor_id. /// /// Used for GetCommunityResponse and GetPersonDetails pub async fn read_site_for_actor( actor_id: DbUrl, context: &LemmyContext, ) -> LemmyResult> { let site_id = Site::instance_actor_id_from_url(actor_id.clone().into()); let site = Site::read_from_apub_id(&mut context.pool(), &site_id.into()).await?; Ok(site) } pub async fn purge_image_posts_for_person( banned_person_id: PersonId, context: &LemmyContext, ) -> LemmyResult<()> { 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(); } } Post::remove_pictrs_post_images_and_thumbnails_for_creator(pool, banned_person_id).await?; Ok(()) } /// Delete a local_user's images async fn delete_local_user_images(person_id: PersonId, context: &LemmyContext) -> LemmyResult<()> { if let Ok(local_user) = LocalUserView::read_person(&mut context.pool(), person_id).await { let pictrs_uploads = LocalImageView::get_all_by_local_user_id(&mut context.pool(), local_user.local_user.id) .await?; // Delete their images for upload in pictrs_uploads { delete_image_from_pictrs( &upload.local_image.pictrs_alias, &upload.local_image.pictrs_delete_token, context, ) .await .ok(); } } Ok(()) } pub async fn purge_image_posts_for_community( banned_community_id: CommunityId, context: &LemmyContext, ) -> LemmyResult<()> { 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(); } } Post::remove_pictrs_post_images_and_thumbnails_for_community(pool, banned_community_id).await?; Ok(()) } /// Removes or restores user data. pub async fn remove_or_restore_user_data( mod_person_id: PersonId, banned_person_id: PersonId, removed: bool, reason: &Option, context: &LemmyContext, ) -> LemmyResult<()> { let pool = &mut context.pool(); // Only these actions are possible when removing, not restoring if removed { // Purge user images let person = Person::read(pool, banned_person_id).await?; if let Some(avatar) = person.avatar { purge_image_from_pictrs(&avatar, context).await.ok(); } if let Some(banner) = person.banner { purge_image_from_pictrs(&banner, context).await.ok(); } // Update the fields to None Person::update( pool, banned_person_id, &PersonUpdateForm { avatar: Some(None), banner: Some(None), bio: Some(None), ..Default::default() }, ) .await?; // Purge image posts purge_image_posts_for_person(banned_person_id, context).await?; // Communities // Remove all communities where they're the top mod // for now, remove the communities manually let first_mod_communities = CommunityModeratorView::get_community_first_mods(pool).await?; // Filter to only this banned users top communities let banned_user_first_communities: Vec = first_mod_communities .into_iter() .filter(|fmc| fmc.moderator.id == banned_person_id) .collect(); for first_mod_community in banned_user_first_communities { let community_id = first_mod_community.community.id; Community::update( pool, community_id, &CommunityUpdateForm { removed: Some(removed), ..Default::default() }, ) .await?; // Delete the community images if let Some(icon) = first_mod_community.community.icon { purge_image_from_pictrs(&icon, context).await.ok(); } if let Some(banner) = first_mod_community.community.banner { purge_image_from_pictrs(&banner, context).await.ok(); } // Update the fields to None Community::update( pool, community_id, &CommunityUpdateForm { icon: Some(None), banner: Some(None), ..Default::default() }, ) .await?; } } // Posts let removed_or_restored_posts = Post::update_removed_for_creator(pool, banned_person_id, None, removed).await?; create_modlog_entries_for_removed_or_restored_posts( pool, mod_person_id, removed_or_restored_posts.iter().map(|r| r.id).collect(), removed, reason, ) .await?; // Comments let removed_or_restored_comments = Comment::update_removed_for_creator(pool, banned_person_id, removed).await?; create_modlog_entries_for_removed_or_restored_comments( pool, mod_person_id, removed_or_restored_comments.iter().map(|r| r.id).collect(), removed, reason, ) .await?; Ok(()) } async fn create_modlog_entries_for_removed_or_restored_posts( pool: &mut DbPool<'_>, mod_person_id: PersonId, post_ids: Vec, removed: bool, reason: &Option, ) -> LemmyResult<()> { // Build the forms let forms = post_ids .iter() .map(|&post_id| ModRemovePostForm { mod_person_id, post_id, removed: Some(removed), reason: reason.clone(), }) .collect(); ModRemovePost::create_multiple(pool, &forms).await?; Ok(()) } async fn create_modlog_entries_for_removed_or_restored_comments( pool: &mut DbPool<'_>, mod_person_id: PersonId, comment_ids: Vec, removed: bool, reason: &Option, ) -> LemmyResult<()> { // Build the forms let forms = comment_ids .iter() .map(|&comment_id| ModRemoveCommentForm { mod_person_id, comment_id, removed: Some(removed), reason: reason.clone(), }) .collect(); ModRemoveComment::create_multiple(pool, &forms).await?; Ok(()) } pub async fn remove_or_restore_user_data_in_community( community_id: CommunityId, mod_person_id: PersonId, banned_person_id: PersonId, remove: bool, reason: &Option, pool: &mut DbPool<'_>, ) -> LemmyResult<()> { // Posts let posts = Post::update_removed_for_creator(pool, banned_person_id, Some(community_id), remove).await?; create_modlog_entries_for_removed_or_restored_posts( pool, mod_person_id, posts.iter().map(|r| r.id).collect(), remove, reason, ) .await?; // Comments // TODO Diesel doesn't allow updates with joins, so this has to be a loop let site = Site::read_local(pool).await?; let comments = CommentQuery { creator_id: Some(banned_person_id), community_id: Some(community_id), ..Default::default() } .list(&site, pool) .await?; for comment_view in &comments { let comment_id = comment_view.comment.id; Comment::update( pool, comment_id, &CommentUpdateForm { removed: Some(remove), ..Default::default() }, ) .await?; } create_modlog_entries_for_removed_or_restored_comments( pool, mod_person_id, comments.iter().map(|r| r.comment.id).collect(), remove, reason, ) .await?; Ok(()) } pub async fn purge_user_account(person_id: PersonId, context: &LemmyContext) -> LemmyResult<()> { let pool = &mut context.pool(); let person = Person::read(pool, person_id).await?; // Delete their local images, if they're a local user delete_local_user_images(person_id, context).await.ok(); // No need to update avatar and banner, those are handled in Person::delete_account if let Some(avatar) = person.avatar { purge_image_from_pictrs(&avatar, context).await.ok(); } if let Some(banner) = person.banner { purge_image_from_pictrs(&banner, context).await.ok(); } // Purge image posts purge_image_posts_for_person(person_id, context).await.ok(); // Comments Comment::permadelete_for_creator(pool, person_id) .await .with_lemmy_type(LemmyErrorType::CouldntUpdateComment)?; // Posts Post::permadelete_for_creator(pool, person_id) .await .with_lemmy_type(LemmyErrorType::CouldntUpdatePost)?; // Leave communities they mod CommunityModerator::leave_all_communities(pool, person_id).await?; // Delete the oauth accounts linked to the local user if let Ok(local_user) = LocalUserView::read_person(pool, person_id).await { OAuthAccount::delete_user_accounts(pool, local_user.local_user.id).await?; } Person::delete_account(pool, person_id).await?; Ok(()) } pub enum EndpointType { Community, Person, Post, Comment, PrivateMessage, } /// Generates an apub endpoint for a given domain, IE xyz.tld pub fn generate_local_apub_endpoint( endpoint_type: EndpointType, name: &str, domain: &str, ) -> Result { let point = match endpoint_type { EndpointType::Community => "c", EndpointType::Person => "u", EndpointType::Post => "post", EndpointType::Comment => "comment", EndpointType::PrivateMessage => "private_message", }; Ok(Url::parse(&format!("{domain}/{point}/{name}"))?.into()) } pub fn generate_followers_url(actor_id: &DbUrl) -> Result { Ok(Url::parse(&format!("{actor_id}/followers"))?.into()) } pub fn generate_inbox_url() -> LemmyResult { let url = format!("{}/inbox", SETTINGS.get_protocol_and_hostname()); Ok(Url::parse(&url)?.into()) } pub fn generate_outbox_url(actor_id: &DbUrl) -> Result { Ok(Url::parse(&format!("{actor_id}/outbox"))?.into()) } pub fn generate_featured_url(actor_id: &DbUrl) -> Result { Ok(Url::parse(&format!("{actor_id}/featured"))?.into()) } pub fn generate_moderators_url(community_id: &DbUrl) -> LemmyResult { Ok(Url::parse(&format!("{community_id}/moderators"))?.into()) } /// Ensure that ban/block expiry is in valid range. If its in past, throw error. If its more /// than 10 years in future, convert to permanent ban. Otherwise return the same value. pub fn check_expire_time(expires_unix_opt: Option) -> LemmyResult>> { if let Some(expires_unix) = expires_unix_opt { let expires = Utc .timestamp_opt(expires_unix, 0) .single() .ok_or(LemmyErrorType::InvalidUnixTime)?; limit_expire_time(expires) } else { Ok(None) } } fn limit_expire_time(expires: DateTime) -> LemmyResult>> { const MAX_BAN_TERM: Days = Days::new(10 * 365); if expires < Local::now() { Err(LemmyErrorType::BanExpirationInPast)? } else if expires > Local::now() + MAX_BAN_TERM { Ok(None) } else { Ok(Some(expires)) } } #[tracing::instrument(skip_all)] pub fn check_conflicting_like_filters( liked_only: Option, disliked_only: Option, ) -> LemmyResult<()> { if liked_only.unwrap_or_default() && disliked_only.unwrap_or_default() { Err(LemmyErrorType::ContradictingFilters)? } else { Ok(()) } } pub async fn process_markdown( text: &str, slur_regex: &Option>, url_blocklist: &RegexSet, context: &LemmyContext, ) -> LemmyResult { let text = remove_slurs(text, slur_regex); let text = clean_urls_in_text(&text); markdown_check_for_blocked_urls(&text, url_blocklist)?; if context.settings().pictrs_config()?.image_mode() == PictrsImageMode::ProxyAllImages { let (text, links) = markdown_rewrite_image_links(text); RemoteImage::create(&mut context.pool(), links.clone()).await?; // Create images and image detail rows for link in links { // Insert image details for the remote image let details_res = fetch_pictrs_proxied_image_details(&link, context).await; if let Ok(details) = details_res { let proxied = build_proxied_image_url(&link, &context.settings().get_protocol_and_hostname())?; let details_form = details.build_image_details_form(&proxied); ImageDetails::create(&mut context.pool(), &details_form).await?; } } Ok(text) } else { Ok(text) } } pub async fn process_markdown_opt( text: &Option, slur_regex: &Option>, url_blocklist: &RegexSet, context: &LemmyContext, ) -> LemmyResult> { match text { Some(t) => process_markdown(t, slur_regex, url_blocklist, context) .await .map(Some), None => Ok(None), } } /// A wrapper for `proxy_image_link` for use in tests. /// /// The parameter `force_image_proxy` is the config value of `pictrs.image_proxy`. Its necessary to /// pass as separate parameter so it can be changed in tests. async fn proxy_image_link_internal( link: Url, image_mode: PictrsImageMode, context: &LemmyContext, ) -> LemmyResult { // Dont rewrite links pointing to local domain. if link.domain() == Some(&context.settings().hostname) { Ok(link.into()) } else if image_mode == PictrsImageMode::ProxyAllImages { RemoteImage::create(&mut context.pool(), vec![link.clone()]).await?; let proxied = build_proxied_image_url(&link, &context.settings().get_protocol_and_hostname())?; // This should fail softly, since pictrs might not even be running let details_res = fetch_pictrs_proxied_image_details(&link, context).await; if let Ok(details) = details_res { let details_form = details.build_image_details_form(&proxied); ImageDetails::create(&mut context.pool(), &details_form).await?; }; Ok(proxied.into()) } else { Ok(link.into()) } } /// Rewrite a link to go through `/api/v3/image_proxy` endpoint. This is only for remote urls and /// if image_proxy setting is enabled. pub async fn proxy_image_link(link: Url, context: &LemmyContext) -> LemmyResult { proxy_image_link_internal( link, context.settings().pictrs_config()?.image_mode(), context, ) .await } pub async fn proxy_image_link_opt_api( link: Option>, context: &LemmyContext, ) -> LemmyResult>> { if let Some(Some(link)) = link { proxy_image_link(link.into(), context) .await .map(Some) .map(Some) } else { Ok(link) } } pub async fn proxy_image_link_api( link: Option, context: &LemmyContext, ) -> LemmyResult> { if let Some(link) = link { proxy_image_link(link.into(), context).await.map(Some) } else { Ok(link) } } pub async fn proxy_image_link_opt_apub( link: Option, context: &LemmyContext, ) -> LemmyResult> { if let Some(l) = link { proxy_image_link(l, context).await.map(Some) } else { Ok(None) } } fn build_proxied_image_url( link: &Url, protocol_and_hostname: &str, ) -> Result { Url::parse(&format!( "{}/api/v3/image_proxy?url={}", protocol_and_hostname, encode(link.as_str()) )) } #[cfg(test)] mod tests { use super::*; use lemmy_db_schema::source::{ comment::CommentInsertForm, community::CommunityInsertForm, person::PersonInsertForm, post::PostInsertForm, }; use lemmy_db_views_moderator::structs::{ ModRemoveCommentView, ModRemovePostView, ModlogListParams, }; use pretty_assertions::assert_eq; use serial_test::serial; #[test] #[rustfmt::skip] fn password_length() { assert!(password_length_check("Õ¼¾°3yË,o¸ãtÌÈú|ÇÁÙAøüÒI©·¤(T]/ð>æºWæ[C¤bªWöaÃÎñ·{=û³&§½K/c").is_ok()); assert!(password_length_check("1234567890").is_ok()); assert!(password_length_check("short").is_err()); assert!(password_length_check("looooooooooooooooooooooooooooooooooooooooooooooooooooooooooong").is_err()); } #[test] fn honeypot() { assert!(honeypot_check(&None).is_ok()); assert!(honeypot_check(&Some(String::new())).is_ok()); assert!(honeypot_check(&Some("1".to_string())).is_err()); assert!(honeypot_check(&Some("message".to_string())).is_err()); } #[test] fn test_limit_ban_term() -> LemmyResult<()> { // Ban expires in past, should throw error assert!(limit_expire_time(Utc::now() - Days::new(5)).is_err()); // Legitimate ban term, return same value let fourteen_days = Utc::now() + Days::new(14); assert_eq!(limit_expire_time(fourteen_days)?, Some(fourteen_days)); let nine_years = Utc::now() + Days::new(365 * 9); assert_eq!(limit_expire_time(nine_years)?, Some(nine_years)); // Too long ban term, changes to None (permanent ban) assert_eq!(limit_expire_time(Utc::now() + Days::new(365 * 11))?, None); Ok(()) } #[tokio::test] #[serial] async fn test_proxy_image_link() -> LemmyResult<()> { let context = LemmyContext::init_test_context().await; // image from local domain is unchanged let local_url = Url::parse("http://lemmy-alpha/image.png")?; let proxied = proxy_image_link_internal(local_url.clone(), PictrsImageMode::ProxyAllImages, &context) .await?; assert_eq!(&local_url, proxied.inner()); // image from remote domain is proxied let remote_image = Url::parse("http://lemmy-beta/image.png")?; let proxied = proxy_image_link_internal( remote_image.clone(), PictrsImageMode::ProxyAllImages, &context, ) .await?; assert_eq!( "https://lemmy-alpha/api/v3/image_proxy?url=http%3A%2F%2Flemmy-beta%2Fimage.png", proxied.as_str() ); // This fails, because the details can't be fetched without pictrs running, // And a remote image won't be inserted. assert!( RemoteImage::validate(&mut context.pool(), remote_image.into()) .await .is_ok() ); Ok(()) } #[tokio::test] #[serial] async fn test_mod_remove_or_restore_data() -> LemmyResult<()> { let context = LemmyContext::init_test_context().await; let pool = &mut context.pool(); let inserted_instance = Instance::read_or_create(pool, "my_domain.tld".to_string()).await?; let new_mod = PersonInsertForm::test_form(inserted_instance.id, "modder"); let inserted_mod = Person::create(pool, &new_mod).await?; let new_person = PersonInsertForm::test_form(inserted_instance.id, "chrimbus"); let inserted_person = Person::create(pool, &new_person).await?; let new_community = CommunityInsertForm::new( inserted_instance.id, "mod_community crepes".to_string(), "nada".to_owned(), "pubkey".to_string(), ); let inserted_community = Community::create(pool, &new_community).await?; let post_form_1 = PostInsertForm::new( "A test post tubular".into(), inserted_person.id, inserted_community.id, ); let inserted_post_1 = Post::create(pool, &post_form_1).await?; let post_form_2 = PostInsertForm::new( "A test post radical".into(), inserted_person.id, inserted_community.id, ); let inserted_post_2 = Post::create(pool, &post_form_2).await?; let comment_form_1 = CommentInsertForm::new( inserted_person.id, inserted_post_1.id, "A test comment tubular".into(), ); let _inserted_comment_1 = Comment::create(pool, &comment_form_1, None).await?; let comment_form_2 = CommentInsertForm::new( inserted_person.id, inserted_post_2.id, "A test comment radical".into(), ); let _inserted_comment_2 = Comment::create(pool, &comment_form_2, None).await?; // Remove the user data remove_or_restore_user_data( inserted_mod.id, inserted_person.id, true, &Some("a remove reason".to_string()), &context, ) .await?; // Verify that their posts and comments are removed. let params = ModlogListParams { community_id: None, mod_person_id: None, other_person_id: None, post_id: None, comment_id: None, page: None, limit: None, hide_modlog_names: false, }; // Posts let post_modlog = ModRemovePostView::list(pool, params).await?; assert_eq!(2, post_modlog.len()); let mod_removed_posts = post_modlog .iter() .map(|p| p.mod_remove_post.removed) .collect::>(); assert_eq!(vec![true, true], mod_removed_posts); let removed_posts = post_modlog .iter() .map(|p| p.post.removed) .collect::>(); assert_eq!(vec![true, true], removed_posts); // Comments let comment_modlog = ModRemoveCommentView::list(pool, params).await?; assert_eq!(2, comment_modlog.len()); let mod_removed_comments = comment_modlog .iter() .map(|p| p.mod_remove_comment.removed) .collect::>(); assert_eq!(vec![true, true], mod_removed_comments); let removed_comments = comment_modlog .iter() .map(|p| p.comment.removed) .collect::>(); assert_eq!(vec![true, true], removed_comments); // Now restore the content, and make sure it got appended remove_or_restore_user_data( inserted_mod.id, inserted_person.id, false, &Some("a restore reason".to_string()), &context, ) .await?; // Posts let post_modlog = ModRemovePostView::list(pool, params).await?; assert_eq!(4, post_modlog.len()); let mod_restored_posts = post_modlog .iter() .map(|p| p.mod_remove_post.removed) .collect::>(); assert_eq!(vec![false, false, true, true], mod_restored_posts); let restored_posts = post_modlog .iter() .map(|p| p.post.removed) .collect::>(); // All of these will be false, cause its the current state of the post assert_eq!(vec![false, false, false, false], restored_posts); // Comments let comment_modlog = ModRemoveCommentView::list(pool, params).await?; assert_eq!(4, comment_modlog.len()); let mod_restored_comments = comment_modlog .iter() .map(|p| p.mod_remove_comment.removed) .collect::>(); assert_eq!(vec![false, false, true, true], mod_restored_comments); let restored_comments = comment_modlog .iter() .map(|p| p.comment.removed) .collect::>(); assert_eq!(vec![false, false, false, false], restored_comments); Instance::delete(pool, inserted_instance.id).await?; Ok(()) } }