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 <dessalines@users.noreply.github.com>
This commit is contained in:
parent
6d1a7c8ae0
commit
6d7b38f4de
17 changed files with 577 additions and 9 deletions
|
@ -106,7 +106,6 @@ pub async fn save_user_settings(
|
||||||
email,
|
email,
|
||||||
show_avatars: data.show_avatars,
|
show_avatars: data.show_avatars,
|
||||||
show_read_posts: data.show_read_posts,
|
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,
|
send_notifications_to_email: data.send_notifications_to_email,
|
||||||
show_nsfw: data.show_nsfw,
|
show_nsfw: data.show_nsfw,
|
||||||
blur_nsfw: data.blur_nsfw,
|
blur_nsfw: data.blur_nsfw,
|
||||||
|
|
|
@ -375,6 +375,8 @@ pub fn local_site_rate_limit_to_rate_limit_config(
|
||||||
comment_per_second: l.comment_per_second,
|
comment_per_second: l.comment_per_second,
|
||||||
search: l.search,
|
search: l.search,
|
||||||
search_per_second: l.search_per_second,
|
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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -7,6 +7,7 @@ pub mod read_community;
|
||||||
pub mod read_person;
|
pub mod read_person;
|
||||||
pub mod resolve_object;
|
pub mod resolve_object;
|
||||||
pub mod search;
|
pub mod search;
|
||||||
|
pub mod user_settings_backup;
|
||||||
|
|
||||||
/// Returns default listing type, depending if the query is for frontpage or community.
|
/// Returns default listing type, depending if the query is for frontpage or community.
|
||||||
fn listing_type_with_default(
|
fn listing_type_with_default(
|
||||||
|
|
449
crates/apub/src/api/user_settings_backup.rs
Normal file
449
crates/apub/src/api/user_settings_backup.rs
Normal file
|
@ -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<String>,
|
||||||
|
pub bio: Option<String>,
|
||||||
|
pub avatar: Option<DbUrl>,
|
||||||
|
pub banner: Option<DbUrl>,
|
||||||
|
pub matrix_id: Option<String>,
|
||||||
|
pub bot_account: Option<bool>,
|
||||||
|
// 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<LocalUser>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub followed_communities: Vec<ObjectId<ApubCommunity>>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub saved_posts: Vec<ObjectId<ApubPost>>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub saved_comments: Vec<ObjectId<ApubComment>>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub blocked_communities: Vec<ObjectId<ApubCommunity>>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub blocked_users: Vec<ObjectId<ApubPerson>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tracing::instrument(skip(context))]
|
||||||
|
pub async fn export_settings(
|
||||||
|
local_user_view: LocalUserView,
|
||||||
|
context: Data<LemmyContext>,
|
||||||
|
) -> Result<Json<UserSettingsBackup>, 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<UserSettingsBackup>,
|
||||||
|
local_user_view: LocalUserView,
|
||||||
|
context: Data<LemmyContext>,
|
||||||
|
) -> Result<Json<SuccessResponse>, 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::<Vec<_>>()
|
||||||
|
.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::<Vec<_>>()
|
||||||
|
.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::<Vec<_>>()
|
||||||
|
.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<String>,
|
||||||
|
context: &Data<LemmyContext>,
|
||||||
|
) -> 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();
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,5 +1,5 @@
|
||||||
use crate::{
|
use crate::{
|
||||||
newtypes::LocalUserId,
|
newtypes::{DbUrl, LocalUserId, PersonId},
|
||||||
schema::local_user::dsl::{
|
schema::local_user::dsl::{
|
||||||
accepted_application,
|
accepted_application,
|
||||||
email,
|
email,
|
||||||
|
@ -19,7 +19,7 @@ use crate::{
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
use bcrypt::{hash, DEFAULT_COST};
|
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;
|
use diesel_async::RunQueryDsl;
|
||||||
|
|
||||||
impl LocalUser {
|
impl LocalUser {
|
||||||
|
@ -64,6 +64,78 @@ impl LocalUser {
|
||||||
.get_result(conn)
|
.get_result(conn)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: maybe move this and pass in LocalUserView
|
||||||
|
pub async fn export_backup(
|
||||||
|
pool: &mut DbPool<'_>,
|
||||||
|
person_id_: PersonId,
|
||||||
|
) -> Result<UserBackupLists, Error> {
|
||||||
|
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<DbUrl>,
|
||||||
|
pub saved_posts: Vec<DbUrl>,
|
||||||
|
pub saved_comments: Vec<DbUrl>,
|
||||||
|
pub blocked_communities: Vec<DbUrl>,
|
||||||
|
pub blocked_users: Vec<DbUrl>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
|
|
|
@ -406,6 +406,8 @@ diesel::table! {
|
||||||
search_per_second -> Int4,
|
search_per_second -> Int4,
|
||||||
published -> Timestamptz,
|
published -> Timestamptz,
|
||||||
updated -> Nullable<Timestamptz>,
|
updated -> Nullable<Timestamptz>,
|
||||||
|
import_user_settings -> Int4,
|
||||||
|
import_user_settings_per_second -> Int4,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -431,7 +433,6 @@ diesel::table! {
|
||||||
show_scores -> Bool,
|
show_scores -> Bool,
|
||||||
show_bot_accounts -> Bool,
|
show_bot_accounts -> Bool,
|
||||||
show_read_posts -> Bool,
|
show_read_posts -> Bool,
|
||||||
show_new_post_notifs -> Bool,
|
|
||||||
email_verified -> Bool,
|
email_verified -> Bool,
|
||||||
accepted_application -> Bool,
|
accepted_application -> Bool,
|
||||||
totp_2fa_secret -> Nullable<Text>,
|
totp_2fa_secret -> Nullable<Text>,
|
||||||
|
|
|
@ -35,6 +35,8 @@ pub struct LocalSiteRateLimit {
|
||||||
pub search_per_second: i32,
|
pub search_per_second: i32,
|
||||||
pub published: DateTime<Utc>,
|
pub published: DateTime<Utc>,
|
||||||
pub updated: Option<DateTime<Utc>>,
|
pub updated: Option<DateTime<Utc>>,
|
||||||
|
pub import_user_settings: i32,
|
||||||
|
pub import_user_settings_per_second: i32,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, TypedBuilder)]
|
#[derive(Clone, TypedBuilder)]
|
||||||
|
@ -56,6 +58,8 @@ pub struct LocalSiteRateLimitInsertForm {
|
||||||
pub comment_per_second: Option<i32>,
|
pub comment_per_second: Option<i32>,
|
||||||
pub search: Option<i32>,
|
pub search: Option<i32>,
|
||||||
pub search_per_second: Option<i32>,
|
pub search_per_second: Option<i32>,
|
||||||
|
pub import_user_settings: Option<i32>,
|
||||||
|
pub import_user_settings_per_second: Option<i32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Default)]
|
#[derive(Clone, Default)]
|
||||||
|
@ -74,5 +78,7 @@ pub struct LocalSiteRateLimitUpdateForm {
|
||||||
pub comment_per_second: Option<i32>,
|
pub comment_per_second: Option<i32>,
|
||||||
pub search: Option<i32>,
|
pub search: Option<i32>,
|
||||||
pub search_per_second: Option<i32>,
|
pub search_per_second: Option<i32>,
|
||||||
|
pub import_user_settings: Option<i32>,
|
||||||
|
pub import_user_settings_per_second: Option<i32>,
|
||||||
pub updated: Option<Option<DateTime<Utc>>>,
|
pub updated: Option<Option<DateTime<Utc>>>,
|
||||||
}
|
}
|
||||||
|
|
|
@ -40,8 +40,6 @@ pub struct LocalUser {
|
||||||
pub show_bot_accounts: bool,
|
pub show_bot_accounts: bool,
|
||||||
/// Whether to show read posts.
|
/// Whether to show read posts.
|
||||||
pub show_read_posts: bool,
|
pub show_read_posts: bool,
|
||||||
/// Whether to show new posts as notifications.
|
|
||||||
pub show_new_post_notifs: bool,
|
|
||||||
/// Whether their email has been verified.
|
/// Whether their email has been verified.
|
||||||
pub email_verified: bool,
|
pub email_verified: bool,
|
||||||
/// Whether their registration application has been accepted.
|
/// Whether their registration application has been accepted.
|
||||||
|
@ -82,7 +80,6 @@ pub struct LocalUserInsertForm {
|
||||||
pub show_bot_accounts: Option<bool>,
|
pub show_bot_accounts: Option<bool>,
|
||||||
pub show_scores: Option<bool>,
|
pub show_scores: Option<bool>,
|
||||||
pub show_read_posts: Option<bool>,
|
pub show_read_posts: Option<bool>,
|
||||||
pub show_new_post_notifs: Option<bool>,
|
|
||||||
pub email_verified: Option<bool>,
|
pub email_verified: Option<bool>,
|
||||||
pub accepted_application: Option<bool>,
|
pub accepted_application: Option<bool>,
|
||||||
pub totp_2fa_secret: Option<Option<String>>,
|
pub totp_2fa_secret: Option<Option<String>>,
|
||||||
|
@ -112,7 +109,6 @@ pub struct LocalUserUpdateForm {
|
||||||
pub show_bot_accounts: Option<bool>,
|
pub show_bot_accounts: Option<bool>,
|
||||||
pub show_scores: Option<bool>,
|
pub show_scores: Option<bool>,
|
||||||
pub show_read_posts: Option<bool>,
|
pub show_read_posts: Option<bool>,
|
||||||
pub show_new_post_notifs: Option<bool>,
|
|
||||||
pub email_verified: Option<bool>,
|
pub email_verified: Option<bool>,
|
||||||
pub accepted_application: Option<bool>,
|
pub accepted_application: Option<bool>,
|
||||||
pub totp_2fa_secret: Option<Option<String>>,
|
pub totp_2fa_secret: Option<Option<String>>,
|
||||||
|
|
|
@ -257,7 +257,6 @@ mod tests {
|
||||||
show_bot_accounts: inserted_sara_local_user.show_bot_accounts,
|
show_bot_accounts: inserted_sara_local_user.show_bot_accounts,
|
||||||
show_scores: inserted_sara_local_user.show_scores,
|
show_scores: inserted_sara_local_user.show_scores,
|
||||||
show_read_posts: inserted_sara_local_user.show_read_posts,
|
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,
|
email_verified: inserted_sara_local_user.email_verified,
|
||||||
accepted_application: inserted_sara_local_user.accepted_application,
|
accepted_application: inserted_sara_local_user.accepted_application,
|
||||||
totp_2fa_secret: inserted_sara_local_user.totp_2fa_secret,
|
totp_2fa_secret: inserted_sara_local_user.totp_2fa_secret,
|
||||||
|
|
|
@ -215,6 +215,7 @@ pub enum LemmyErrorType {
|
||||||
InstanceBlockAlreadyExists,
|
InstanceBlockAlreadyExists,
|
||||||
/// `jwt` cookie must be marked secure and httponly
|
/// `jwt` cookie must be marked secure and httponly
|
||||||
AuthCookieInsecure,
|
AuthCookieInsecure,
|
||||||
|
UserBackupTooLarge,
|
||||||
Unknown(String),
|
Unknown(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -57,6 +57,12 @@ pub struct RateLimitConfig {
|
||||||
#[builder(default = 600)]
|
#[builder(default = 600)]
|
||||||
/// Interval length for search limit, in seconds
|
/// Interval length for search limit, in seconds
|
||||||
pub search_per_second: i32,
|
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)]
|
#[derive(Debug, Clone)]
|
||||||
|
@ -125,6 +131,7 @@ impl RateLimitCell {
|
||||||
RateLimitType::Image => rate_limit.image_per_second,
|
RateLimitType::Image => rate_limit.image_per_second,
|
||||||
RateLimitType::Comment => rate_limit.comment_per_second,
|
RateLimitType::Comment => rate_limit.comment_per_second,
|
||||||
RateLimitType::Search => rate_limit.search_per_second,
|
RateLimitType::Search => rate_limit.search_per_second,
|
||||||
|
RateLimitType::ImportUserSettings => rate_limit.import_user_settings_per_second
|
||||||
}
|
}
|
||||||
.into_values()
|
.into_values()
|
||||||
.max()
|
.max()
|
||||||
|
@ -162,6 +169,10 @@ impl RateLimitCell {
|
||||||
self.kind(RateLimitType::Search)
|
self.kind(RateLimitType::Search)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn import_user_settings(&self) -> RateLimitedGuard {
|
||||||
|
self.kind(RateLimitType::ImportUserSettings)
|
||||||
|
}
|
||||||
|
|
||||||
fn kind(&self, type_: RateLimitType) -> RateLimitedGuard {
|
fn kind(&self, type_: RateLimitType) -> RateLimitedGuard {
|
||||||
RateLimitedGuard {
|
RateLimitedGuard {
|
||||||
rate_limit: self.rate_limit.clone(),
|
rate_limit: self.rate_limit.clone(),
|
||||||
|
@ -193,6 +204,10 @@ impl RateLimitedGuard {
|
||||||
RateLimitType::Image => (rate_limit.image, rate_limit.image_per_second),
|
RateLimitType::Image => (rate_limit.image, rate_limit.image_per_second),
|
||||||
RateLimitType::Comment => (rate_limit.comment, rate_limit.comment_per_second),
|
RateLimitType::Comment => (rate_limit.comment, rate_limit.comment_per_second),
|
||||||
RateLimitType::Search => (rate_limit.search, rate_limit.search_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;
|
let limiter = &mut guard.rate_limiter;
|
||||||
|
|
||||||
|
|
|
@ -53,6 +53,7 @@ pub(crate) enum RateLimitType {
|
||||||
Image,
|
Image,
|
||||||
Comment,
|
Comment,
|
||||||
Search,
|
Search,
|
||||||
|
ImportUserSettings,
|
||||||
}
|
}
|
||||||
|
|
||||||
type Map<K, C> = HashMap<K, RateLimitedGroup<C>>;
|
type Map<K, C> = HashMap<K, RateLimitedGroup<C>>;
|
||||||
|
|
|
@ -0,0 +1,3 @@
|
||||||
|
ALTER TABLE local_user
|
||||||
|
ADD COLUMN show_new_post_notifs boolean NOT NULL DEFAULT FALSE;
|
||||||
|
|
|
@ -0,0 +1,4 @@
|
||||||
|
-- this setting is unused with websocket gone
|
||||||
|
ALTER TABLE local_user
|
||||||
|
DROP COLUMN show_new_post_notifs;
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -121,6 +121,7 @@ use lemmy_apub::api::{
|
||||||
read_person::read_person,
|
read_person::read_person,
|
||||||
resolve_object::resolve_object,
|
resolve_object::resolve_object,
|
||||||
search::search,
|
search::search,
|
||||||
|
user_settings_backup::{export_settings, import_settings},
|
||||||
};
|
};
|
||||||
use lemmy_utils::rate_limit::RateLimitCell;
|
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("/totp/update", web::post().to(update_totp))
|
||||||
.route("/list_logins", web::get().to(list_logins)),
|
.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
|
// Admin Actions
|
||||||
.service(
|
.service(
|
||||||
web::scope("/admin")
|
web::scope("/admin")
|
||||||
|
|
Loading…
Reference in a new issue