From 6d7b38f4dead0f268940a1be0ab7b2be71a50d58 Mon Sep 17 00:00:00 2001 From: Nutomic Date: Wed, 11 Oct 2023 16:47:22 +0200 Subject: [PATCH] Implement user data import/export (#3976) * Implement endpoints for user data import/export * add test * exclude avatar/banner * increase import url count, add rate limit * also export/import saved posts * rate limit * rename * saved posts also exist * rename routes * fix test * error handling * clippy * limit parallelism * clippy --------- Co-authored-by: Dessalines --- crates/api/src/local_user/save_settings.rs | 1 - crates/api_common/src/utils.rs | 2 + crates/apub/src/api/mod.rs | 1 + crates/apub/src/api/user_settings_backup.rs | 449 ++++++++++++++++++ crates/db_schema/src/impls/local_user.rs | 76 ++- crates/db_schema/src/schema.rs | 3 +- .../src/source/local_site_rate_limit.rs | 6 + crates/db_schema/src/source/local_user.rs | 4 - .../src/registration_application_view.rs | 1 - crates/utils/src/error.rs | 1 + crates/utils/src/rate_limit/mod.rs | 15 + crates/utils/src/rate_limit/rate_limiter.rs | 1 + .../down.sql | 3 + .../up.sql | 4 + .../down.sql | 6 + .../up.sql | 6 + src/api_routes_http.rs | 7 + 17 files changed, 577 insertions(+), 9 deletions(-) create mode 100644 crates/apub/src/api/user_settings_backup.rs create mode 100644 migrations/2023-09-20-110614_drop-show-new-post-notifs/down.sql create mode 100644 migrations/2023-09-20-110614_drop-show-new-post-notifs/up.sql create mode 100644 migrations/2023-09-28-084231_import_user_settings_rate_limit/down.sql create mode 100644 migrations/2023-09-28-084231_import_user_settings_rate_limit/up.sql diff --git a/crates/api/src/local_user/save_settings.rs b/crates/api/src/local_user/save_settings.rs index a88dc431c..c3d7eca23 100644 --- a/crates/api/src/local_user/save_settings.rs +++ b/crates/api/src/local_user/save_settings.rs @@ -106,7 +106,6 @@ pub async fn save_user_settings( email, show_avatars: data.show_avatars, show_read_posts: data.show_read_posts, - show_new_post_notifs: data.show_new_post_notifs, send_notifications_to_email: data.send_notifications_to_email, show_nsfw: data.show_nsfw, blur_nsfw: data.blur_nsfw, diff --git a/crates/api_common/src/utils.rs b/crates/api_common/src/utils.rs index 8f49eb78a..891a35855 100644 --- a/crates/api_common/src/utils.rs +++ b/crates/api_common/src/utils.rs @@ -375,6 +375,8 @@ pub fn local_site_rate_limit_to_rate_limit_config( comment_per_second: l.comment_per_second, search: l.search, search_per_second: l.search_per_second, + import_user_settings: l.import_user_settings, + import_user_settings_per_second: l.import_user_settings_per_second, } } diff --git a/crates/apub/src/api/mod.rs b/crates/apub/src/api/mod.rs index 705e81a30..59586e477 100644 --- a/crates/apub/src/api/mod.rs +++ b/crates/apub/src/api/mod.rs @@ -7,6 +7,7 @@ pub mod read_community; pub mod read_person; pub mod resolve_object; pub mod search; +pub mod user_settings_backup; /// Returns default listing type, depending if the query is for frontpage or community. fn listing_type_with_default( diff --git a/crates/apub/src/api/user_settings_backup.rs b/crates/apub/src/api/user_settings_backup.rs new file mode 100644 index 000000000..0349171a9 --- /dev/null +++ b/crates/apub/src/api/user_settings_backup.rs @@ -0,0 +1,449 @@ +use crate::objects::{ + comment::ApubComment, + community::ApubCommunity, + person::ApubPerson, + post::ApubPost, +}; +use activitypub_federation::{config::Data, fetch::object_id::ObjectId}; +use actix_web::web::Json; +use futures::{future::try_join_all, StreamExt}; +use lemmy_api_common::{context::LemmyContext, utils::sanitize_html_api_opt, SuccessResponse}; +use lemmy_db_schema::{ + newtypes::DbUrl, + source::{ + comment::{CommentSaved, CommentSavedForm}, + community::{CommunityFollower, CommunityFollowerForm}, + community_block::{CommunityBlock, CommunityBlockForm}, + local_user::{LocalUser, LocalUserUpdateForm}, + person::{Person, PersonUpdateForm}, + person_block::{PersonBlock, PersonBlockForm}, + post::{PostSaved, PostSavedForm}, + }, + traits::{Blockable, Crud, Followable, Saveable}, +}; +use lemmy_db_views::structs::LocalUserView; +use lemmy_utils::{ + error::{LemmyError, LemmyErrorType, LemmyResult}, + spawn_try_task, +}; +use serde::{Deserialize, Serialize}; +use tracing::info; + +/// Maximum number of follow/block URLs which can be imported at once, to prevent server overloading. +/// To import a larger backup, split it into multiple parts. +/// +/// TODO: having the user manually split files will very be confusing +const MAX_URL_IMPORT_COUNT: usize = 1000; + +/// Backup of user data. This struct should never be changed so that the data can be used as a +/// long-term backup in case the instance goes down unexpectedly. All fields are optional to allow +/// importing partial backups. +/// +/// This data should not be parsed by apps/clients, but directly downloaded as a file. +/// +/// Be careful with any changes to this struct, to avoid breaking changes which could prevent +/// importing older backups. +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct UserSettingsBackup { + pub display_name: Option, + pub bio: Option, + pub avatar: Option, + pub banner: Option, + pub matrix_id: Option, + pub bot_account: Option, + // TODO: might be worth making a separate struct for settings backup, to avoid breakage in case + // fields are renamed, and to avoid storing unnecessary fields like person_id or email + pub settings: Option, + #[serde(default)] + pub followed_communities: Vec>, + #[serde(default)] + pub saved_posts: Vec>, + #[serde(default)] + pub saved_comments: Vec>, + #[serde(default)] + pub blocked_communities: Vec>, + #[serde(default)] + pub blocked_users: Vec>, +} + +#[tracing::instrument(skip(context))] +pub async fn export_settings( + local_user_view: LocalUserView, + context: Data, +) -> Result, LemmyError> { + let lists = LocalUser::export_backup(&mut context.pool(), local_user_view.person.id).await?; + + let vec_into = |vec: Vec<_>| vec.into_iter().map(Into::into).collect(); + Ok(Json(UserSettingsBackup { + display_name: local_user_view.person.display_name, + bio: local_user_view.person.bio, + avatar: local_user_view.person.avatar, + banner: local_user_view.person.banner, + matrix_id: local_user_view.person.matrix_user_id, + bot_account: local_user_view.person.bot_account.into(), + settings: Some(local_user_view.local_user), + followed_communities: vec_into(lists.followed_communities), + blocked_communities: vec_into(lists.blocked_communities), + blocked_users: lists.blocked_users.into_iter().map(Into::into).collect(), + saved_posts: lists.saved_posts.into_iter().map(Into::into).collect(), + saved_comments: lists.saved_comments.into_iter().map(Into::into).collect(), + })) +} + +#[tracing::instrument(skip(context))] +pub async fn import_settings( + data: Json, + local_user_view: LocalUserView, + context: Data, +) -> Result, LemmyError> { + let display_name = Some(sanitize_html_api_opt(&data.display_name)); + let bio = Some(sanitize_html_api_opt(&data.bio)); + + let person_form = PersonUpdateForm { + display_name, + bio, + matrix_user_id: Some(data.matrix_id.clone()), + bot_account: data.bot_account, + ..Default::default() + }; + Person::update(&mut context.pool(), local_user_view.person.id, &person_form).await?; + + let local_user_form = LocalUserUpdateForm { + show_nsfw: data.settings.as_ref().map(|s| s.show_nsfw), + theme: data.settings.as_ref().map(|s| s.theme.clone()), + default_sort_type: data.settings.as_ref().map(|s| s.default_sort_type), + default_listing_type: data.settings.as_ref().map(|s| s.default_listing_type), + interface_language: data.settings.as_ref().map(|s| s.interface_language.clone()), + show_avatars: data.settings.as_ref().map(|s| s.show_avatars), + send_notifications_to_email: data + .settings + .as_ref() + .map(|s| s.send_notifications_to_email), + show_scores: data.settings.as_ref().map(|s| s.show_scores), + show_bot_accounts: data.settings.as_ref().map(|s| s.show_bot_accounts), + show_read_posts: data.settings.as_ref().map(|s| s.show_read_posts), + open_links_in_new_tab: data.settings.as_ref().map(|s| s.open_links_in_new_tab), + blur_nsfw: data.settings.as_ref().map(|s| s.blur_nsfw), + auto_expand: data.settings.as_ref().map(|s| s.auto_expand), + infinite_scroll_enabled: data.settings.as_ref().map(|s| s.infinite_scroll_enabled), + post_listing_mode: data.settings.as_ref().map(|s| s.post_listing_mode), + ..Default::default() + }; + LocalUser::update( + &mut context.pool(), + local_user_view.local_user.id, + &local_user_form, + ) + .await?; + + let url_count = data.followed_communities.len() + + data.blocked_communities.len() + + data.blocked_users.len() + + data.saved_posts.len() + + data.saved_comments.len(); + if url_count > MAX_URL_IMPORT_COUNT { + Err(LemmyErrorType::UserBackupTooLarge)?; + } + + spawn_try_task(async move { + const PARALLELISM: usize = 10; + let person_id = local_user_view.person.id; + + // These tasks fetch objects from remote instances which might be down. + // TODO: Would be nice if we could send a list of failed items with api response, but then + // the request would likely timeout. + let mut failed_items = vec![]; + + info!( + "Starting settings backup for {}", + local_user_view.person.name + ); + + futures::stream::iter( + data + .followed_communities + .clone() + .into_iter() + // reset_request_count works like clone, and is necessary to avoid running into request limit + .map(|f| (f, context.reset_request_count())) + .map(|(followed, context)| async move { + // need to reset outgoing request count to avoid running into limit + let community = followed.dereference(&context).await?; + let form = CommunityFollowerForm { + person_id, + community_id: community.id, + pending: true, + }; + CommunityFollower::follow(&mut context.pool(), &form).await?; + LemmyResult::Ok(()) + }), + ) + .buffer_unordered(PARALLELISM) + .collect::>() + .await + .into_iter() + .enumerate() + .for_each(|(i, r)| { + if let Err(e) = r { + failed_items.push(data.followed_communities.get(i).map(|u| u.inner().clone())); + info!("Failed to import followed community: {e}"); + } + }); + + futures::stream::iter( + data + .saved_posts + .clone() + .into_iter() + .map(|s| (s, context.reset_request_count())) + .map(|(saved, context)| async move { + let post = saved.dereference(&context).await?; + let form = PostSavedForm { + person_id, + post_id: post.id, + }; + PostSaved::save(&mut context.pool(), &form).await?; + LemmyResult::Ok(()) + }), + ) + .buffer_unordered(PARALLELISM) + .collect::>() + .await + .into_iter() + .enumerate() + .for_each(|(i, r)| { + if let Err(e) = r { + failed_items.push(data.followed_communities.get(i).map(|u| u.inner().clone())); + info!("Failed to import saved post community: {e}"); + } + }); + + futures::stream::iter( + data + .saved_comments + .clone() + .into_iter() + .map(|s| (s, context.reset_request_count())) + .map(|(saved, context)| async move { + let comment = saved.dereference(&context).await?; + let form = CommentSavedForm { + person_id, + comment_id: comment.id, + }; + CommentSaved::save(&mut context.pool(), &form).await?; + LemmyResult::Ok(()) + }), + ) + .buffer_unordered(PARALLELISM) + .collect::>() + .await + .into_iter() + .enumerate() + .for_each(|(i, r)| { + if let Err(e) = r { + failed_items.push(data.followed_communities.get(i).map(|u| u.inner().clone())); + info!("Failed to import saved comment community: {e}"); + } + }); + + let failed_items: Vec<_> = failed_items.into_iter().flatten().collect(); + info!( + "Finished settings backup for {}, failed items: {:#?}", + local_user_view.person.name, failed_items + ); + + // These tasks don't connect to any remote instances but only insert directly in the database. + // That means the only error condition are db connection failures, so no extra error handling is + // needed. + try_join_all(data.blocked_communities.iter().map(|blocked| async { + // dont fetch unknown blocked objects from home server + let community = blocked.dereference_local(&context).await?; + let form = CommunityBlockForm { + person_id, + community_id: community.id, + }; + CommunityBlock::block(&mut context.pool(), &form).await?; + LemmyResult::Ok(()) + })) + .await?; + + try_join_all(data.blocked_users.iter().map(|blocked| async { + // dont fetch unknown blocked objects from home server + let target = blocked.dereference_local(&context).await?; + let form = PersonBlockForm { + person_id, + target_id: target.id, + }; + PersonBlock::block(&mut context.pool(), &form).await?; + LemmyResult::Ok(()) + })) + .await?; + Ok(()) + }); + + Ok(Json(Default::default())) +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used)] + #![allow(clippy::indexing_slicing)] + + use crate::{ + api::user_settings_backup::{export_settings, import_settings}, + objects::tests::init_context, + }; + use activitypub_federation::config::Data; + use lemmy_api_common::context::LemmyContext; + use lemmy_db_schema::{ + source::{ + community::{Community, CommunityFollower, CommunityFollowerForm, CommunityInsertForm}, + instance::Instance, + local_user::{LocalUser, LocalUserInsertForm}, + person::{Person, PersonInsertForm}, + }, + traits::{Crud, Followable}, + }; + use lemmy_db_views::structs::LocalUserView; + use lemmy_db_views_actor::structs::CommunityFollowerView; + use lemmy_utils::error::LemmyErrorType; + use serial_test::serial; + use std::time::Duration; + use tokio::time::sleep; + + async fn create_user( + name: String, + bio: Option, + context: &Data, + ) -> LocalUserView { + let instance = Instance::read_or_create(&mut context.pool(), "example.com".to_string()) + .await + .unwrap(); + let person_form = PersonInsertForm::builder() + .name(name.clone()) + .display_name(Some(name.clone())) + .bio(bio) + .public_key("asd".to_string()) + .instance_id(instance.id) + .build(); + let person = Person::create(&mut context.pool(), &person_form) + .await + .unwrap(); + + let user_form = LocalUserInsertForm::builder() + .person_id(person.id) + .password_encrypted("pass".to_string()) + .build(); + let local_user = LocalUser::create(&mut context.pool(), &user_form) + .await + .unwrap(); + + LocalUserView::read(&mut context.pool(), local_user.id) + .await + .unwrap() + } + + #[tokio::test] + #[serial] + async fn test_settings_export_import() { + let context = init_context().await; + + let export_user = create_user("hanna".to_string(), Some("my bio".to_string()), &context).await; + + let community_form = CommunityInsertForm::builder() + .name("testcom".to_string()) + .title("testcom".to_string()) + .instance_id(export_user.person.instance_id) + .build(); + let community = Community::create(&mut context.pool(), &community_form) + .await + .unwrap(); + let follower_form = CommunityFollowerForm { + community_id: community.id, + person_id: export_user.person.id, + pending: false, + }; + CommunityFollower::follow(&mut context.pool(), &follower_form) + .await + .unwrap(); + + let backup = export_settings(export_user.clone(), context.reset_request_count()) + .await + .unwrap(); + + let import_user = create_user("charles".to_string(), None, &context).await; + + import_settings(backup, import_user.clone(), context.reset_request_count()) + .await + .unwrap(); + let import_user_updated = LocalUserView::read(&mut context.pool(), import_user.local_user.id) + .await + .unwrap(); + + // wait for background task to finish + sleep(Duration::from_millis(100)).await; + + assert_eq!( + export_user.person.display_name, + import_user_updated.person.display_name + ); + assert_eq!(export_user.person.bio, import_user_updated.person.bio); + + let follows = CommunityFollowerView::for_person(&mut context.pool(), import_user.person.id) + .await + .unwrap(); + assert_eq!(follows.len(), 1); + assert_eq!(follows[0].community.actor_id, community.actor_id); + + LocalUser::delete(&mut context.pool(), export_user.local_user.id) + .await + .unwrap(); + LocalUser::delete(&mut context.pool(), import_user.local_user.id) + .await + .unwrap(); + } + + #[tokio::test] + #[serial] + async fn disallow_large_backup() { + let context = init_context().await; + + let export_user = create_user("hanna".to_string(), Some("my bio".to_string()), &context).await; + + let mut backup = export_settings(export_user.clone(), context.reset_request_count()) + .await + .unwrap(); + + for _ in 0..251 { + backup + .followed_communities + .push("http://example.com".parse().unwrap()); + backup + .blocked_communities + .push("http://example2.com".parse().unwrap()); + backup + .saved_posts + .push("http://example3.com".parse().unwrap()); + backup + .saved_comments + .push("http://example4.com".parse().unwrap()); + } + + let import_user = create_user("charles".to_string(), None, &context).await; + + let imported = + import_settings(backup, import_user.clone(), context.reset_request_count()).await; + + assert_eq!( + imported.err().unwrap().error_type, + LemmyErrorType::UserBackupTooLarge + ); + + LocalUser::delete(&mut context.pool(), export_user.local_user.id) + .await + .unwrap(); + LocalUser::delete(&mut context.pool(), import_user.local_user.id) + .await + .unwrap(); + } +} diff --git a/crates/db_schema/src/impls/local_user.rs b/crates/db_schema/src/impls/local_user.rs index 3206322e4..86960c053 100644 --- a/crates/db_schema/src/impls/local_user.rs +++ b/crates/db_schema/src/impls/local_user.rs @@ -1,5 +1,5 @@ use crate::{ - newtypes::LocalUserId, + newtypes::{DbUrl, LocalUserId, PersonId}, schema::local_user::dsl::{ accepted_application, email, @@ -19,7 +19,7 @@ use crate::{ }, }; use bcrypt::{hash, DEFAULT_COST}; -use diesel::{dsl::insert_into, result::Error, ExpressionMethods, QueryDsl}; +use diesel::{dsl::insert_into, result::Error, ExpressionMethods, JoinOnDsl, QueryDsl}; use diesel_async::RunQueryDsl; impl LocalUser { @@ -64,6 +64,78 @@ impl LocalUser { .get_result(conn) .await } + + // TODO: maybe move this and pass in LocalUserView + pub async fn export_backup( + pool: &mut DbPool<'_>, + person_id_: PersonId, + ) -> Result { + use crate::schema::{ + comment, + comment_saved, + community, + community_block, + community_follower, + person, + person_block, + post, + post_saved, + }; + let conn = &mut get_conn(pool).await?; + + let followed_communities = community_follower::dsl::community_follower + .filter(community_follower::person_id.eq(person_id_)) + .inner_join(community::table.on(community_follower::community_id.eq(community::id))) + .select(community::actor_id) + .get_results(conn) + .await?; + + let saved_posts = post_saved::dsl::post_saved + .filter(post_saved::person_id.eq(person_id_)) + .inner_join(post::table.on(post_saved::post_id.eq(post::id))) + .select(post::ap_id) + .get_results(conn) + .await?; + + let saved_comments = comment_saved::dsl::comment_saved + .filter(comment_saved::person_id.eq(person_id_)) + .inner_join(comment::table.on(comment_saved::comment_id.eq(comment::id))) + .select(comment::ap_id) + .get_results(conn) + .await?; + + let blocked_communities = community_block::dsl::community_block + .filter(community_block::person_id.eq(person_id_)) + .inner_join(community::table) + .select(community::actor_id) + .get_results(conn) + .await?; + + let blocked_users = person_block::dsl::person_block + .filter(person_block::person_id.eq(person_id_)) + .inner_join(person::table.on(person_block::target_id.eq(person::id))) + .select(person::actor_id) + .get_results(conn) + .await?; + + // TODO: use join for parallel queries? + + Ok(UserBackupLists { + followed_communities, + saved_posts, + saved_comments, + blocked_communities, + blocked_users, + }) + } +} + +pub struct UserBackupLists { + pub followed_communities: Vec, + pub saved_posts: Vec, + pub saved_comments: Vec, + pub blocked_communities: Vec, + pub blocked_users: Vec, } #[async_trait] diff --git a/crates/db_schema/src/schema.rs b/crates/db_schema/src/schema.rs index 42d90dbec..6942fdccd 100644 --- a/crates/db_schema/src/schema.rs +++ b/crates/db_schema/src/schema.rs @@ -406,6 +406,8 @@ diesel::table! { search_per_second -> Int4, published -> Timestamptz, updated -> Nullable, + import_user_settings -> Int4, + import_user_settings_per_second -> Int4, } } @@ -431,7 +433,6 @@ diesel::table! { show_scores -> Bool, show_bot_accounts -> Bool, show_read_posts -> Bool, - show_new_post_notifs -> Bool, email_verified -> Bool, accepted_application -> Bool, totp_2fa_secret -> Nullable, diff --git a/crates/db_schema/src/source/local_site_rate_limit.rs b/crates/db_schema/src/source/local_site_rate_limit.rs index b16d4e134..af7023f0f 100644 --- a/crates/db_schema/src/source/local_site_rate_limit.rs +++ b/crates/db_schema/src/source/local_site_rate_limit.rs @@ -35,6 +35,8 @@ pub struct LocalSiteRateLimit { pub search_per_second: i32, pub published: DateTime, pub updated: Option>, + pub import_user_settings: i32, + pub import_user_settings_per_second: i32, } #[derive(Clone, TypedBuilder)] @@ -56,6 +58,8 @@ pub struct LocalSiteRateLimitInsertForm { pub comment_per_second: Option, pub search: Option, pub search_per_second: Option, + pub import_user_settings: Option, + pub import_user_settings_per_second: Option, } #[derive(Clone, Default)] @@ -74,5 +78,7 @@ pub struct LocalSiteRateLimitUpdateForm { pub comment_per_second: Option, pub search: Option, pub search_per_second: Option, + pub import_user_settings: Option, + pub import_user_settings_per_second: Option, pub updated: Option>>, } diff --git a/crates/db_schema/src/source/local_user.rs b/crates/db_schema/src/source/local_user.rs index 05c2eaefb..220593698 100644 --- a/crates/db_schema/src/source/local_user.rs +++ b/crates/db_schema/src/source/local_user.rs @@ -40,8 +40,6 @@ pub struct LocalUser { pub show_bot_accounts: bool, /// Whether to show read posts. pub show_read_posts: bool, - /// Whether to show new posts as notifications. - pub show_new_post_notifs: bool, /// Whether their email has been verified. pub email_verified: bool, /// Whether their registration application has been accepted. @@ -82,7 +80,6 @@ pub struct LocalUserInsertForm { pub show_bot_accounts: Option, pub show_scores: Option, pub show_read_posts: Option, - pub show_new_post_notifs: Option, pub email_verified: Option, pub accepted_application: Option, pub totp_2fa_secret: Option>, @@ -112,7 +109,6 @@ pub struct LocalUserUpdateForm { pub show_bot_accounts: Option, pub show_scores: Option, pub show_read_posts: Option, - pub show_new_post_notifs: Option, pub email_verified: Option, pub accepted_application: Option, pub totp_2fa_secret: Option>, diff --git a/crates/db_views/src/registration_application_view.rs b/crates/db_views/src/registration_application_view.rs index b4a952002..c2d49207a 100644 --- a/crates/db_views/src/registration_application_view.rs +++ b/crates/db_views/src/registration_application_view.rs @@ -257,7 +257,6 @@ mod tests { show_bot_accounts: inserted_sara_local_user.show_bot_accounts, show_scores: inserted_sara_local_user.show_scores, show_read_posts: inserted_sara_local_user.show_read_posts, - show_new_post_notifs: inserted_sara_local_user.show_new_post_notifs, email_verified: inserted_sara_local_user.email_verified, accepted_application: inserted_sara_local_user.accepted_application, totp_2fa_secret: inserted_sara_local_user.totp_2fa_secret, diff --git a/crates/utils/src/error.rs b/crates/utils/src/error.rs index f5b7a0be8..b6a4fe4ec 100644 --- a/crates/utils/src/error.rs +++ b/crates/utils/src/error.rs @@ -215,6 +215,7 @@ pub enum LemmyErrorType { InstanceBlockAlreadyExists, /// `jwt` cookie must be marked secure and httponly AuthCookieInsecure, + UserBackupTooLarge, Unknown(String), } diff --git a/crates/utils/src/rate_limit/mod.rs b/crates/utils/src/rate_limit/mod.rs index 1bb6f1b5f..114daf452 100644 --- a/crates/utils/src/rate_limit/mod.rs +++ b/crates/utils/src/rate_limit/mod.rs @@ -57,6 +57,12 @@ pub struct RateLimitConfig { #[builder(default = 600)] /// Interval length for search limit, in seconds pub search_per_second: i32, + #[builder(default = 1)] + /// Maximum number of user settings imports in interval + pub import_user_settings: i32, + #[builder(default = 24 * 60 * 60)] + /// Interval length for importing user settings, in seconds (defaults to 24 hours) + pub import_user_settings_per_second: i32, } #[derive(Debug, Clone)] @@ -125,6 +131,7 @@ impl RateLimitCell { RateLimitType::Image => rate_limit.image_per_second, RateLimitType::Comment => rate_limit.comment_per_second, RateLimitType::Search => rate_limit.search_per_second, + RateLimitType::ImportUserSettings => rate_limit.import_user_settings_per_second } .into_values() .max() @@ -162,6 +169,10 @@ impl RateLimitCell { self.kind(RateLimitType::Search) } + pub fn import_user_settings(&self) -> RateLimitedGuard { + self.kind(RateLimitType::ImportUserSettings) + } + fn kind(&self, type_: RateLimitType) -> RateLimitedGuard { RateLimitedGuard { rate_limit: self.rate_limit.clone(), @@ -193,6 +204,10 @@ impl RateLimitedGuard { RateLimitType::Image => (rate_limit.image, rate_limit.image_per_second), RateLimitType::Comment => (rate_limit.comment, rate_limit.comment_per_second), RateLimitType::Search => (rate_limit.search, rate_limit.search_per_second), + RateLimitType::ImportUserSettings => ( + rate_limit.import_user_settings, + rate_limit.import_user_settings_per_second, + ), }; let limiter = &mut guard.rate_limiter; diff --git a/crates/utils/src/rate_limit/rate_limiter.rs b/crates/utils/src/rate_limit/rate_limiter.rs index 3acf23ba4..7ba1345c5 100644 --- a/crates/utils/src/rate_limit/rate_limiter.rs +++ b/crates/utils/src/rate_limit/rate_limiter.rs @@ -53,6 +53,7 @@ pub(crate) enum RateLimitType { Image, Comment, Search, + ImportUserSettings, } type Map = HashMap>; diff --git a/migrations/2023-09-20-110614_drop-show-new-post-notifs/down.sql b/migrations/2023-09-20-110614_drop-show-new-post-notifs/down.sql new file mode 100644 index 000000000..ac8e06833 --- /dev/null +++ b/migrations/2023-09-20-110614_drop-show-new-post-notifs/down.sql @@ -0,0 +1,3 @@ +ALTER TABLE local_user + ADD COLUMN show_new_post_notifs boolean NOT NULL DEFAULT FALSE; + diff --git a/migrations/2023-09-20-110614_drop-show-new-post-notifs/up.sql b/migrations/2023-09-20-110614_drop-show-new-post-notifs/up.sql new file mode 100644 index 000000000..513b06634 --- /dev/null +++ b/migrations/2023-09-20-110614_drop-show-new-post-notifs/up.sql @@ -0,0 +1,4 @@ +-- this setting is unused with websocket gone +ALTER TABLE local_user + DROP COLUMN show_new_post_notifs; + diff --git a/migrations/2023-09-28-084231_import_user_settings_rate_limit/down.sql b/migrations/2023-09-28-084231_import_user_settings_rate_limit/down.sql new file mode 100644 index 000000000..edbb7dd18 --- /dev/null +++ b/migrations/2023-09-28-084231_import_user_settings_rate_limit/down.sql @@ -0,0 +1,6 @@ +ALTER TABLE local_site_rate_limit + DROP COLUMN import_user_settings; + +ALTER TABLE local_site_rate_limit + DROP COLUMN import_user_settings_per_second; + diff --git a/migrations/2023-09-28-084231_import_user_settings_rate_limit/up.sql b/migrations/2023-09-28-084231_import_user_settings_rate_limit/up.sql new file mode 100644 index 000000000..6ff73f94b --- /dev/null +++ b/migrations/2023-09-28-084231_import_user_settings_rate_limit/up.sql @@ -0,0 +1,6 @@ +ALTER TABLE local_site_rate_limit + ADD COLUMN import_user_settings int NOT NULL DEFAULT 1; + +ALTER TABLE local_site_rate_limit + ADD COLUMN import_user_settings_per_second int NOT NULL DEFAULT 86400; + diff --git a/src/api_routes_http.rs b/src/api_routes_http.rs index 173bce199..3546b3400 100644 --- a/src/api_routes_http.rs +++ b/src/api_routes_http.rs @@ -121,6 +121,7 @@ use lemmy_apub::api::{ read_person::read_person, resolve_object::resolve_object, search::search, + user_settings_backup::{export_settings, import_settings}, }; use lemmy_utils::rate_limit::RateLimitCell; @@ -297,6 +298,12 @@ pub fn config(cfg: &mut web::ServiceConfig, rate_limit: &RateLimitCell) { .route("/totp/update", web::post().to(update_totp)) .route("/list_logins", web::get().to(list_logins)), ) + .service( + web::scope("/user") + .wrap(rate_limit.import_user_settings()) + .route("/export_settings", web::get().to(export_settings)) + .route("/import_settings", web::post().to(import_settings)), + ) // Admin Actions .service( web::scope("/admin")