Adding GDPR-style data export (#5801)

* Adding GDPR-style data export

- Fixes #4540

Still need to:

- [ ] Figure out limits

* Fixing format

* Adding no_limit overrides.

* Slimming down export.

* Cleaning up types some more.

* Addressing PR comments.

* Embedding settings in backup.

* Fixing comment.
This commit is contained in:
Dessalines 2025-06-27 13:14:59 -04:00 committed by GitHub
parent e3d36d4f9a
commit 899c87f21f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 293 additions and 79 deletions

1
Cargo.lock generated
View file

@ -3149,6 +3149,7 @@ dependencies = [
"lemmy_db_views_local_user",
"lemmy_db_views_modlog_combined",
"lemmy_db_views_person",
"lemmy_db_views_person_content_combined",
"lemmy_db_views_person_liked_combined",
"lemmy_db_views_person_saved_combined",
"lemmy_db_views_post",

View file

@ -34,6 +34,9 @@ lemmy_db_views_inbox_combined = { workspace = true, features = ["full"] }
lemmy_db_views_modlog_combined = { workspace = true, features = ["full"] }
lemmy_db_views_person_saved_combined = { workspace = true, features = ["full"] }
lemmy_db_views_person_liked_combined = { workspace = true, features = ["full"] }
lemmy_db_views_person_content_combined = { workspace = true, features = [
"full",
] }
lemmy_db_views_report_combined = { workspace = true, features = ["full"] }
lemmy_db_views_site = { workspace = true, features = ["full"] }
lemmy_db_views_registration_applications = { workspace = true, features = [

View file

@ -0,0 +1,103 @@
use activitypub_federation::config::Data;
use actix_web::web::Json;
use lemmy_api_utils::context::LemmyContext;
use lemmy_db_schema::source::local_user::LocalUser;
use lemmy_db_views_community_moderator::CommunityModeratorView;
use lemmy_db_views_inbox_combined::{impls::InboxCombinedQuery, InboxCombinedView};
use lemmy_db_views_local_user::LocalUserView;
use lemmy_db_views_person_content_combined::{
impls::PersonContentCombinedQuery,
PersonContentCombinedView,
};
use lemmy_db_views_person_liked_combined::{
impls::PersonLikedCombinedQuery,
PersonLikedCombinedView,
};
use lemmy_db_views_post::PostView;
use lemmy_db_views_site::{
api::{ExportDataResponse, PostOrCommentOrPrivateMessage},
impls::user_backup_list_to_user_settings_backup,
};
use lemmy_utils::{self, error::LemmyResult};
pub async fn export_data(
context: Data<LemmyContext>,
local_user_view: LocalUserView,
) -> LemmyResult<Json<ExportDataResponse>> {
use PostOrCommentOrPrivateMessage::*;
let local_instance_id = local_user_view.person.instance_id;
let my_person_id = local_user_view.person.id;
let my_person = &local_user_view.person;
let local_user = &local_user_view.local_user;
let pool = &mut context.pool();
let content = PersonContentCombinedQuery {
no_limit: Some(true),
..PersonContentCombinedQuery::new(my_person_id)
}
.list(pool, Some(&local_user_view), local_instance_id)
.await?
.into_iter()
.map(|u| match u {
PersonContentCombinedView::Post(pv) => Post(pv.post),
PersonContentCombinedView::Comment(cv) => Comment(cv.comment),
})
.collect();
let inbox = InboxCombinedQuery {
no_limit: Some(true),
..InboxCombinedQuery::default()
}
.list(pool, my_person_id, local_instance_id)
.await?
.into_iter()
.map(|u| match u {
InboxCombinedView::CommentReply(cr) => Comment(cr.comment),
InboxCombinedView::CommentMention(cm) => Comment(cm.comment),
InboxCombinedView::PostMention(pm) => Post(pm.post),
InboxCombinedView::PrivateMessage(pm) => PrivateMessage(pm.private_message),
})
.collect();
let liked = PersonLikedCombinedQuery {
no_limit: Some(true),
..PersonLikedCombinedQuery::default()
}
.list(pool, &local_user_view)
.await?
.into_iter()
.map(|u| {
match u {
PersonLikedCombinedView::Post(pv) => pv.post.ap_id,
PersonLikedCombinedView::Comment(cv) => cv.comment.ap_id,
}
.into()
})
.collect();
let read_posts = PostView::list_read(pool, my_person, None, None, None, Some(true))
.await?
.into_iter()
.map(|pv| pv.post.ap_id.into())
.collect();
let moderates = CommunityModeratorView::for_person(pool, my_person_id, Some(local_user))
.await?
.into_iter()
.map(|cv| cv.community.ap_id.into())
.collect();
let lists = LocalUser::export_backup(pool, local_user_view.person.id).await?;
let settings = user_backup_list_to_user_settings_backup(local_user_view, lists);
Ok(Json(ExportDataResponse {
inbox,
content,
liked,
read_posts,
moderates,
settings,
}))
}

View file

@ -24,6 +24,7 @@ pub async fn list_person_hidden(
cursor_data,
data.page_back,
data.limit,
None,
)
.await?;

View file

@ -28,6 +28,7 @@ pub async fn list_person_liked(
cursor_data,
page_back: data.page_back,
limit: data.limit,
no_limit: None,
}
.list(&mut context.pool(), &local_user_view)
.await?;

View file

@ -24,6 +24,7 @@ pub async fn list_person_read(
cursor_data,
data.page_back,
data.limit,
None,
)
.await?;

View file

@ -32,6 +32,7 @@ pub async fn list_person_saved(
cursor_data,
page_back: data.page_back,
limit: data.limit,
no_limit: None,
}
.list(&mut context.pool(), &local_user_view)
.await?;

View file

@ -4,6 +4,7 @@ pub mod block;
pub mod change_password;
pub mod change_password_after_reset;
pub mod donation_dialog_shown;
pub mod export_data;
pub mod generate_totp_secret;
pub mod get_captcha;
pub mod list_hidden;

View file

@ -31,6 +31,7 @@ pub async fn list_inbox(
cursor_data,
page_back: data.page_back,
limit: data.limit,
no_limit: None,
}
.list(&mut context.pool(), person_id, local_instance_id)
.await?;

View file

@ -44,8 +44,13 @@ pub async fn list_person_content(
cursor_data,
page_back: data.page_back,
limit: data.limit,
no_limit: None,
}
.list(&mut context.pool(), &local_user_view, local_instance_id)
.list(
&mut context.pool(),
local_user_view.as_ref(),
local_instance_id,
)
.await?;
let next_page = content.last().map(PaginationCursorBuilder::to_cursor);

View file

@ -10,7 +10,6 @@ use lemmy_apub_objects::objects::{
post::ApubPost,
};
use lemmy_db_schema::{
newtypes::DbUrl,
source::{
comment::{CommentActions, CommentSavedForm},
community::{CommunityActions, CommunityBlockForm, CommunityFollowerForm},
@ -23,73 +22,29 @@ use lemmy_db_schema::{
};
use lemmy_db_schema_file::enums::CommunityFollowerState;
use lemmy_db_views_local_user::LocalUserView;
use lemmy_db_views_site::api::SuccessResponse;
use lemmy_db_views_site::{
api::{SuccessResponse, UserSettingsBackup},
impls::user_backup_list_to_user_settings_backup,
};
use lemmy_utils::{
error::LemmyResult,
spawn_try_task,
utils::validation::check_api_elements_count,
};
use serde::{Deserialize, Serialize};
use serde::Deserialize;
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 settings = user_backup_list_to_user_settings_backup(local_user_view, lists);
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(),
}))
Ok(Json(settings))
}
pub async fn import_settings(
@ -157,7 +112,12 @@ pub async fn import_settings(
);
let failed_followed_communities = fetch_and_import(
data.followed_communities.clone(),
data
.followed_communities
.clone()
.into_iter()
.map(Into::into)
.collect::<Vec<ObjectId<ApubCommunity>>>(),
&context,
|(followed, context)| async move {
let community = followed.dereference(&context).await?;
@ -170,7 +130,12 @@ pub async fn import_settings(
.await?;
let failed_saved_posts = fetch_and_import(
data.saved_posts.clone(),
data
.saved_posts
.clone()
.into_iter()
.map(Into::into)
.collect::<Vec<ObjectId<ApubPost>>>(),
&context,
|(saved, context)| async move {
let post = saved.dereference(&context).await?;
@ -182,7 +147,12 @@ pub async fn import_settings(
.await?;
let failed_saved_comments = fetch_and_import(
data.saved_comments.clone(),
data
.saved_comments
.clone()
.into_iter()
.map(Into::into)
.collect::<Vec<ObjectId<ApubComment>>>(),
&context,
|(saved, context)| async move {
let comment = saved.dereference(&context).await?;
@ -194,7 +164,12 @@ pub async fn import_settings(
.await?;
let failed_community_blocks = fetch_and_import(
data.blocked_communities.clone(),
data
.blocked_communities
.clone()
.into_iter()
.map(Into::into)
.collect::<Vec<ObjectId<ApubCommunity>>>(),
&context,
|(blocked, context)| async move {
let community = blocked.dereference(&context).await?;
@ -206,7 +181,12 @@ pub async fn import_settings(
.await?;
let failed_user_blocks = fetch_and_import(
data.blocked_users.clone(),
data
.blocked_users
.clone()
.into_iter()
.map(Into::into)
.collect::<Vec<ObjectId<ApubPerson>>>(),
&context,
|(blocked, context)| async move {
let target = blocked.dereference(&context).await?;

View file

@ -240,6 +240,7 @@ pub struct InboxCombinedQuery {
pub cursor_data: Option<InboxCombined>,
pub page_back: Option<bool>,
pub limit: Option<i64>,
pub no_limit: Option<bool>,
}
impl InboxCombinedQuery {
@ -250,16 +251,19 @@ impl InboxCombinedQuery {
local_instance_id: InstanceId,
) -> LemmyResult<Vec<InboxCombinedView>> {
let conn = &mut get_conn(pool).await?;
let limit = limit_fetch(self.limit)?;
let item_creator = person::id;
let recipient_person = aliases::person1.field(person::id);
let mut query = InboxCombinedViewInternal::joins(my_person_id, local_instance_id)
.select(InboxCombinedViewInternal::as_select())
.limit(limit)
.into_boxed();
if !self.no_limit.unwrap_or_default() {
let limit = limit_fetch(self.limit)?;
query = query.limit(limit);
}
// Filters
if self.unread_only.unwrap_or_default() {
query = query

View file

@ -160,20 +160,21 @@ pub struct PersonContentCombinedQuery {
pub page_back: Option<bool>,
#[new(default)]
pub limit: Option<i64>,
#[new(default)]
pub no_limit: Option<bool>,
}
impl PersonContentCombinedQuery {
pub async fn list(
self,
pool: &mut DbPool<'_>,
user: &Option<LocalUserView>,
user: Option<&LocalUserView>,
local_instance_id: InstanceId,
) -> LemmyResult<Vec<PersonContentCombinedView>> {
let my_person_id = user.as_ref().map(|u| u.local_user.person_id);
let item_creator = person::id;
let conn = &mut get_conn(pool).await?;
let limit = limit_fetch(self.limit)?;
// Notes: since the post_id and comment_id are optional columns,
// many joins must use an OR condition.
@ -184,9 +185,13 @@ impl PersonContentCombinedQuery {
// The creator id filter
.filter(item_creator.eq(self.creator_id))
.select(PersonContentCombinedViewInternal::as_select())
.limit(limit)
.into_boxed();
if !self.no_limit.unwrap_or_default() {
let limit = limit_fetch(self.limit)?;
query = query.limit(limit);
}
if let Some(type_) = self.type_ {
query = match type_ {
PersonContentType::All => query,
@ -366,7 +371,7 @@ mod tests {
// Do a batch read of timmy
let timmy_content = PersonContentCombinedQuery::new(data.timmy.id)
.list(pool, &None, data.instance.id)
.list(pool, None, data.instance.id)
.await?;
assert_eq!(3, timmy_content.len());
@ -392,7 +397,7 @@ mod tests {
// Do a batch read of sara
let sara_content = PersonContentCombinedQuery::new(data.sara.id)
.list(pool, &None, data.instance.id)
.list(pool, None, data.instance.id)
.await?;
assert_eq!(3, sara_content.len());

View file

@ -53,6 +53,7 @@ pub struct PersonLikedCombinedQuery {
pub cursor_data: Option<PersonLikedCombined>,
pub page_back: Option<bool>,
pub limit: Option<i64>,
pub no_limit: Option<bool>,
}
impl PaginationCursorBuilder for PersonLikedCombinedView {
@ -161,14 +162,17 @@ impl PersonLikedCombinedQuery {
let local_instance_id = user.person.instance_id;
let conn = &mut get_conn(pool).await?;
let limit = limit_fetch(self.limit)?;
let mut query = PersonLikedCombinedViewInternal::joins(my_person_id, local_instance_id)
.filter(person_liked_combined::person_id.eq(my_person_id))
.select(PersonLikedCombinedViewInternal::as_select())
.limit(limit)
.into_boxed();
if !self.no_limit.unwrap_or_default() {
let limit = limit_fetch(self.limit)?;
query = query.limit(limit);
}
if let Some(type_) = self.type_ {
query = match type_ {
PersonContentType::All => query,

View file

@ -51,6 +51,7 @@ pub struct PersonSavedCombinedQuery {
pub cursor_data: Option<PersonSavedCombined>,
pub page_back: Option<bool>,
pub limit: Option<i64>,
pub no_limit: Option<bool>,
}
impl PaginationCursorBuilder for PersonSavedCombinedView {
@ -159,14 +160,17 @@ impl PersonSavedCombinedQuery {
let local_instance_id = user.person.instance_id;
let conn = &mut get_conn(pool).await?;
let limit = limit_fetch(self.limit)?;
let mut query = PersonSavedCombinedViewInternal::joins(my_person_id, local_instance_id)
.filter(person_saved_combined::person_id.eq(my_person_id))
.select(PersonSavedCombinedViewInternal::as_select())
.limit(limit)
.into_boxed();
if !self.no_limit.unwrap_or_default() {
let limit = limit_fetch(self.limit)?;
query = query.limit(limit);
}
if let Some(type_) = self.type_ {
query = match type_ {
PersonContentType::All => query,

View file

@ -182,18 +182,22 @@ impl PostView {
cursor_data: Option<PostActions>,
page_back: Option<bool>,
limit: Option<i64>,
no_limit: Option<bool>,
) -> LemmyResult<Vec<PostView>> {
let conn = &mut get_conn(pool).await?;
let limit = limit_fetch(limit)?;
let query = PostView::joins(Some(my_person.id), my_person.instance_id)
let mut query = PostView::joins(Some(my_person.id), my_person.instance_id)
.filter(post_actions::person_id.eq(my_person.id))
.filter(post_actions::read_at.is_not_null())
.filter(filter_blocked())
.select(PostView::as_select())
.limit(limit)
.into_boxed();
if !no_limit.unwrap_or_default() {
let limit = limit_fetch(limit)?;
query = query.limit(limit);
}
// Sorting by the read date
let paginated_query = paginate(query, SortDirection::Desc, cursor_data, None, page_back)
.then_order_by(pa_key::read_at)
@ -213,18 +217,22 @@ impl PostView {
cursor_data: Option<PostActions>,
page_back: Option<bool>,
limit: Option<i64>,
no_limit: Option<bool>,
) -> LemmyResult<Vec<PostView>> {
let conn = &mut get_conn(pool).await?;
let limit = limit_fetch(limit)?;
let query = PostView::joins(Some(my_person.id), my_person.instance_id)
let mut query = PostView::joins(Some(my_person.id), my_person.instance_id)
.filter(post_actions::person_id.eq(my_person.id))
.filter(post_actions::hidden_at.is_not_null())
.filter(filter_blocked())
.select(PostView::as_select())
.limit(limit)
.into_boxed();
if !no_limit.unwrap_or_default() {
let limit = limit_fetch(limit)?;
query = query.limit(limit);
}
// Sorting by the hidden date
let paginated_query = paginate(query, SortDirection::Desc, cursor_data, None, page_back)
.then_order_by(pa_key::hidden_at)
@ -1223,7 +1231,7 @@ mod tests {
PostActions::mark_as_read(pool, &tag_post_read_form).await?;
let read_read_post_listing =
PostView::list_read(pool, &data.tegan.person, None, None, None).await?;
PostView::list_read(pool, &data.tegan.person, None, None, None, None).await?;
// This should be ordered from most recently read
assert_eq!(
@ -1802,7 +1810,8 @@ mod tests {
.is_some_and(|a| a.hidden_at.is_some())));
// Make sure only that one comes back for list_hidden
let list_hidden = PostView::list_hidden(pool, &data.tegan.person, None, None, None).await?;
let list_hidden =
PostView::list_hidden(pool, &data.tegan.person, None, None, None, None).await?;
assert_eq!(vec![POST_BY_BOT], names(&list_hidden));
Ok(())

View file

@ -11,13 +11,17 @@ use lemmy_db_schema::{
},
sensitive::SensitiveString,
source::{
comment::Comment,
community::Community,
instance::Instance,
language::Language,
local_site_url_blocklist::LocalSiteUrlBlocklist,
local_user::LocalUser,
login_token::LoginToken,
oauth_provider::{OAuthProvider, PublicOAuthProvider},
person::Person,
post::Post,
private_message::PrivateMessage,
tagline::Tagline,
},
};
@ -691,6 +695,60 @@ pub struct ResolveObject {
pub q: String,
}
#[derive(Debug, PartialEq, Serialize, Deserialize, Clone)]
pub enum PostOrCommentOrPrivateMessage {
Post(Post),
Comment(Comment),
PrivateMessage(PrivateMessage),
}
/// 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<Url>,
pub banner: Option<Url>,
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<Url>,
#[serde(default)]
pub saved_posts: Vec<Url>,
#[serde(default)]
pub saved_comments: Vec<Url>,
#[serde(default)]
pub blocked_communities: Vec<Url>,
#[serde(default)]
pub blocked_users: Vec<Url>,
#[serde(default)]
pub blocked_instances: Vec<String>,
}
#[skip_serializing_none]
#[derive(Debug, Serialize, Deserialize, Clone)]
#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
#[cfg_attr(feature = "ts-rs", ts(optional_fields, export))]
/// Your exported data.
pub struct ExportDataResponse {
pub inbox: Vec<PostOrCommentOrPrivateMessage>,
pub content: Vec<PostOrCommentOrPrivateMessage>,
pub read_posts: Vec<Url>,
pub liked: Vec<Url>,
pub moderates: Vec<Url>,
pub settings: UserSettingsBackup,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[cfg_attr(feature = "ts-rs", derive(ts_rs::TS))]
#[cfg_attr(feature = "ts-rs", ts(optional_fields, export))]

View file

@ -1,12 +1,14 @@
use crate::SiteView;
use crate::{api::UserSettingsBackup, SiteView};
use diesel::{ExpressionMethods, JoinOnDsl, OptionalExtension, QueryDsl, SelectableHelper};
use diesel_async::RunQueryDsl;
use lemmy_db_schema::{
impls::local_user::UserBackupLists,
source::person::Person,
traits::Crud,
utils::{get_conn, DbPool},
};
use lemmy_db_schema_file::schema::{instance, local_site, local_site_rate_limit, site};
use lemmy_db_views_local_user::LocalUserView;
use lemmy_utils::{
build_cache,
error::{LemmyError, LemmyErrorType, LemmyResult},
@ -43,3 +45,26 @@ impl SiteView {
Person::read(pool, site_view.local_site.multi_comm_follower).await
}
}
pub fn user_backup_list_to_user_settings_backup(
local_user_view: LocalUserView,
lists: UserBackupLists,
) -> UserSettingsBackup {
let vec_into = |vec: Vec<_>| vec.into_iter().map(Into::into).collect();
UserSettingsBackup {
display_name: local_user_view.person.display_name,
bio: local_user_view.person.bio,
avatar: local_user_view.person.avatar.map(Into::into),
banner: local_user_view.person.banner.map(Into::into),
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(),
}
}

View file

@ -215,8 +215,9 @@ async fn get_feed_user(
cursor_data: None,
page_back: None,
limit: (Some(*limit)),
no_limit: None,
}
.list(&mut context.pool(), &None, site_view.site.instance_id)
.list(&mut context.pool(), None, site_view.site.instance_id)
.await?;
let posts = content

View file

@ -28,6 +28,7 @@ use lemmy_api::{
change_password::change_password,
change_password_after_reset::change_password_after_reset,
donation_dialog_shown::donation_dialog_shown,
export_data::export_data,
generate_totp_secret::generate_totp_secret,
get_captcha::get_captcha,
list_hidden::list_person_hidden,
@ -400,6 +401,11 @@ pub fn config(cfg: &mut ServiceConfig, rate_limit: &RateLimit) {
.wrap(rate_limit.import_user_settings())
.route("/export", get().to(export_settings))
.route("/import", post().to(import_settings)),
)
.service(
resource("/data/export")
.wrap(rate_limit.import_user_settings())
.route(get().to(export_data)),
),
)
// User actions