lemmy/crates/apub/src/api/user_settings_backup.rs
Dessalines 5fa6a490d5
Create actions structs (#5482)
* migration

* update code

* tests

* triggers

* fix

* fmt

* clippy

* post aggregate migration

* changes for post aggregate code

* wip: update tests for post aggregate

* format

* fix partialeq

* trigger fix

* fix post insert trigger

* wip

* reorder

* fixes

* community aggregate migration

* update code

* triggers

* person aggregate migration

* person aggregate code

* person triggers

* test fixes

* fix scheduled task

* update api tests

* site_aggregates to local_site migration

* site_aggregates code changes

* triggers, tests

* more fixes

* Rename PersonPostAggregates to PostActions

* Merge local_user_vote_display_mode into local_user

* fix schema

* remove duplicate fields

* remove "aggregates" from index names

* uncomment indices

* if count = 0

* remove commentaggregates

* Fix triggers in remove aggregates tables pr (#5451)

* prevent all db_schema test errors

* fix the delete_comments_before_post problem in a way that doesn't affect the returned number of affected rows

* remove unnecessary recursion checks and add comment to remaining check

* clean up

* Fixing SQL format.

* Update triggers.sql

* Update triggers.sql

* Update triggers.sql

* Update triggers.sql

* remove update of deleted column

---------

Co-authored-by: Dessalines <tyhou13@gmx.com>

* rename migration

* Fix migration errors

* Move community.hidden to visibility (fixes #5458)

* Fixing person_saved_combined. (#5481)

* Remove comment and post specific action structs. #5473

* Doing reports

* fix up migration by dropping index

* also add enum variant `LocalOnlyPublic`, rename `LocalOnly` to `LocalOnlyPrivate`

fixes #5351

* fix column order in down.sql

* wip

* Moving blocks.

* Adding a few more views.

* more wip

* fixes

* migration for modlog

* fix migration

* Working views and schema.

* Fix ts_optionals.

* wip

* db_schema compiling

* make the code compile

* Merging from main.

* lint

* Fixing SQL format.

* fix down migration

* Fixing api tests.

* Adding field comments for the actions tables.

* Refactoring CommunityFollower to include follow_state

* fix test

* make hidden status federate

* ts attr

* fix

* fix api test

* Update crates/api/src/reports/post_report/resolve.rs

Co-authored-by: Nutomic <me@nutomic.com>

* Addressing PR comments

* Fix ts export.

* update api client

* review

* Extracting filter_not_hidden_or_is_subscribed (#5497)

* Extracting filter_not_hidden_or_is_subscribed

* Cleanup.

* Cleanup 2.

* Remove follower_state_helper function.

* Cleaning up some utils functions.

* rename hidden to unlisted

---------

Co-authored-by: Felix Ableitner <me@nutomic.com>
Co-authored-by: dullbananas <dull.bananas0@gmail.com>
2025-03-12 11:51:34 -04:00

413 lines
14 KiB
Rust

use crate::objects::{
comment::ApubComment,
community::ApubCommunity,
person::ApubPerson,
post::ApubPost,
};
use activitypub_federation::{config::Data, fetch::object_id::ObjectId, traits::Object};
use actix_web::web::Json;
use futures::{future::try_join_all, StreamExt};
use itertools::Itertools;
use lemmy_api_common::{context::LemmyContext, SuccessResponse};
use lemmy_db_schema::{
newtypes::DbUrl,
source::{
comment::{CommentActions, CommentSavedForm},
community::{
CommunityActions,
CommunityBlockForm,
CommunityFollowerForm,
CommunityFollowerState,
},
instance::{Instance, InstanceActions, InstanceBlockForm},
local_user::{LocalUser, LocalUserUpdateForm},
person::{Person, PersonActions, PersonBlockForm, PersonUpdateForm},
post::{PostActions, PostSavedForm},
},
traits::{Blockable, Crud, Followable, Saveable},
};
use lemmy_db_views::structs::LocalUserView;
use lemmy_utils::{
error::{LemmyErrorType, LemmyResult, MAX_API_PARAM_ELEMENTS},
spawn_try_task,
};
use serde::{Deserialize, Serialize};
use std::future::Future;
use tracing::info;
const PARALLELISM: usize = 10;
/// 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, Default)]
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>>,
#[serde(default)]
pub blocked_instances: Vec<String>,
}
pub async fn export_settings(
local_user_view: LocalUserView,
context: Data<LemmyContext>,
) -> LemmyResult<Json<UserSettingsBackup>> {
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_instances: lists.blocked_instances,
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(),
}))
}
pub async fn import_settings(
data: Json<UserSettingsBackup>,
local_user_view: LocalUserView,
context: Data<LemmyContext>,
) -> LemmyResult<Json<SuccessResponse>> {
let person_form = PersonUpdateForm {
display_name: data.display_name.clone().map(Some),
bio: data.bio.clone().map(Some),
matrix_user_id: data.matrix_id.clone().map(Some),
bot_account: data.bot_account,
..Default::default()
};
// ignore error in case form is empty
Person::update(&mut context.pool(), local_user_view.person.id, &person_form)
.await
.ok();
let local_user_form = LocalUserUpdateForm {
show_nsfw: data.settings.as_ref().map(|s| s.show_nsfw),
theme: data.settings.clone().map(|s| s.theme.clone()),
default_post_sort_type: data.settings.as_ref().map(|s| s.default_post_sort_type),
default_comment_sort_type: data.settings.as_ref().map(|s| s.default_comment_sort_type),
default_listing_type: data.settings.as_ref().map(|s| s.default_listing_type),
interface_language: data.settings.clone().map(|s| s.interface_language),
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_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),
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),
show_score: data.settings.as_ref().map(|s| s.show_score),
show_upvotes: data.settings.as_ref().map(|s| s.show_upvotes),
show_downvotes: data.settings.as_ref().map(|s| s.show_downvotes),
show_upvote_percentage: data.settings.as_ref().map(|s| s.show_upvote_percentage),
..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.blocked_instances.len()
+ data.saved_posts.len()
+ data.saved_comments.len();
if url_count > MAX_API_PARAM_ELEMENTS {
Err(LemmyErrorType::TooManyItems)?;
}
spawn_try_task(async move {
let person_id = local_user_view.person.id;
info!(
"Starting settings import for {}",
local_user_view.person.name
);
let failed_followed_communities = fetch_and_import(
data.followed_communities.clone(),
&context,
|(followed, context)| async move {
let community = followed.dereference(&context).await?;
let form =
CommunityFollowerForm::new(community.id, person_id, CommunityFollowerState::Pending);
CommunityActions::follow(&mut context.pool(), &form).await?;
LemmyResult::Ok(())
},
)
.await?;
let failed_saved_posts = fetch_and_import(
data.saved_posts.clone(),
&context,
|(saved, context)| async move {
let post = saved.dereference(&context).await?;
let form = PostSavedForm::new(post.id, person_id);
PostActions::save(&mut context.pool(), &form).await?;
LemmyResult::Ok(())
},
)
.await?;
let failed_saved_comments = fetch_and_import(
data.saved_comments.clone(),
&context,
|(saved, context)| async move {
let comment = saved.dereference(&context).await?;
let form = CommentSavedForm::new(person_id, comment.id);
CommentActions::save(&mut context.pool(), &form).await?;
LemmyResult::Ok(())
},
)
.await?;
let failed_community_blocks = fetch_and_import(
data.blocked_communities.clone(),
&context,
|(blocked, context)| async move {
let community = blocked.dereference(&context).await?;
let form = CommunityBlockForm::new(community.id, person_id);
CommunityActions::block(&mut context.pool(), &form).await?;
LemmyResult::Ok(())
},
)
.await?;
let failed_user_blocks = fetch_and_import(
data.blocked_users.clone(),
&context,
|(blocked, context)| async move {
let context = context.reset_request_count();
let target = blocked.dereference(&context).await?;
let form = PersonBlockForm::new(person_id, target.id);
PersonActions::block(&mut context.pool(), &form).await?;
LemmyResult::Ok(())
},
)
.await?;
try_join_all(data.blocked_instances.iter().map(|domain| async {
let instance = Instance::read_or_create(&mut context.pool(), domain.clone()).await?;
let form = InstanceBlockForm::new(person_id, instance.id);
InstanceActions::block(&mut context.pool(), &form).await?;
LemmyResult::Ok(())
}))
.await?;
info!("Settings import completed for {}, the following items failed: {failed_followed_communities}, {failed_saved_posts}, {failed_saved_comments}, {failed_community_blocks}, {failed_user_blocks}",
local_user_view.person.name);
Ok(())
});
Ok(Json(Default::default()))
}
async fn fetch_and_import<Kind, Fut>(
objects: Vec<ObjectId<Kind>>,
context: &Data<LemmyContext>,
import_fn: impl FnMut((ObjectId<Kind>, Data<LemmyContext>)) -> Fut,
) -> LemmyResult<String>
where
Kind: Object + Send + 'static,
for<'de2> <Kind as Object>::Kind: Deserialize<'de2>,
Fut: Future<Output = LemmyResult<()>>,
{
let mut failed_items = vec![];
futures::stream::iter(
objects
.clone()
.into_iter()
// need to reset outgoing request count to avoid running into limit
.map(|s| (s, context.reset_request_count()))
.map(import_fn),
)
.buffer_unordered(PARALLELISM)
.collect::<Vec<_>>()
.await
.into_iter()
.enumerate()
.for_each(|(i, r): (usize, LemmyResult<()>)| {
if r.is_err() {
if let Some(object) = objects.get(i) {
failed_items.push(object.inner().clone());
}
}
});
Ok(failed_items.into_iter().join(","))
}
#[cfg(test)]
#[expect(clippy::indexing_slicing)]
pub(crate) mod tests {
use crate::api::user_settings_backup::{export_settings, import_settings};
use actix_web::web::Json;
use lemmy_api_common::context::LemmyContext;
use lemmy_db_schema::{
source::{
community::{
Community,
CommunityActions,
CommunityFollowerForm,
CommunityFollowerState,
CommunityInsertForm,
},
person::Person,
},
traits::{Crud, Followable},
};
use lemmy_db_views::structs::{CommunityFollowerView, LocalUserView};
use lemmy_utils::error::{LemmyErrorType, LemmyResult};
use serial_test::serial;
use std::time::Duration;
use tokio::time::sleep;
#[tokio::test]
#[serial]
async fn test_settings_export_import() -> LemmyResult<()> {
let context = LemmyContext::init_test_context().await;
let pool = &mut context.pool();
let export_user = LocalUserView::create_test_user(pool, "hanna", "my bio", false).await?;
let community_form = CommunityInsertForm::new(
export_user.person.instance_id,
"testcom".to_string(),
"testcom".to_string(),
"pubkey".to_string(),
);
let community = Community::create(pool, &community_form).await?;
let follower_form = CommunityFollowerForm::new(
community.id,
export_user.person.id,
CommunityFollowerState::Accepted,
);
CommunityActions::follow(pool, &follower_form).await?;
let backup = export_settings(export_user.clone(), context.reset_request_count()).await?;
let import_user =
LocalUserView::create_test_user(pool, "charles", "charles bio", false).await?;
import_settings(backup, import_user.clone(), context.reset_request_count()).await?;
// wait for background task to finish
sleep(Duration::from_millis(1000)).await;
let import_user_updated = LocalUserView::read(pool, import_user.local_user.id).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(pool, import_user.person.id).await?;
assert_eq!(follows.len(), 1);
assert_eq!(follows[0].community.ap_id, community.ap_id);
Person::delete(pool, export_user.person.id).await?;
Person::delete(pool, import_user.person.id).await?;
Ok(())
}
#[tokio::test]
#[serial]
async fn disallow_large_backup() -> LemmyResult<()> {
let context = LemmyContext::init_test_context().await;
let pool = &mut context.pool();
let export_user = LocalUserView::create_test_user(pool, "harry", "harry bio", false).await?;
let mut backup = export_settings(export_user.clone(), context.reset_request_count()).await?;
for _ in 0..2501 {
backup
.followed_communities
.push("http://example.com".parse()?);
backup
.blocked_communities
.push("http://example2.com".parse()?);
backup.saved_posts.push("http://example3.com".parse()?);
backup.saved_comments.push("http://example4.com".parse()?);
}
let import_user = LocalUserView::create_test_user(pool, "sally", "sally bio", false).await?;
let imported =
import_settings(backup, import_user.clone(), context.reset_request_count()).await;
assert_eq!(
imported.err().map(|e| e.error_type),
Some(LemmyErrorType::TooManyItems)
);
Person::delete(pool, export_user.person.id).await?;
Person::delete(pool, import_user.person.id).await?;
Ok(())
}
#[tokio::test]
#[serial]
async fn import_partial_backup() -> LemmyResult<()> {
let context = LemmyContext::init_test_context().await;
let pool = &mut context.pool();
let import_user = LocalUserView::create_test_user(pool, "larry", "larry bio", false).await?;
let backup =
serde_json::from_str("{\"bot_account\": true, \"settings\": {\"theme\": \"my_theme\"}}")?;
import_settings(
Json(backup),
import_user.clone(),
context.reset_request_count(),
)
.await?;
let import_user_updated = LocalUserView::read(pool, import_user.local_user.id).await?;
// mark as bot account
assert!(import_user_updated.person.bot_account);
// dont remove existing bio
assert_eq!(import_user.person.bio, import_user_updated.person.bio);
// local_user can be deserialized without id/person_id fields
assert_eq!("my_theme", import_user_updated.local_user.theme);
Ok(())
}
}