From 1a95d5f2b964000398b9a5f621a7d0204ddff8cb Mon Sep 17 00:00:00 2001 From: sychic <47618543+Sychic@users.noreply.github.com> Date: Tue, 4 Aug 2026 22:52:57 -0400 Subject: [PATCH 01/27] feat(labrinth): initial preferences structure --- apps/labrinth/src/models/v3/mod.rs | 1 + apps/labrinth/src/models/v3/preferences.rs | 65 ++++++++++++++++++++++ 2 files changed, 66 insertions(+) create mode 100644 apps/labrinth/src/models/v3/preferences.rs diff --git a/apps/labrinth/src/models/v3/mod.rs b/apps/labrinth/src/models/v3/mod.rs index 777de99443..57d2dd41f2 100644 --- a/apps/labrinth/src/models/v3/mod.rs +++ b/apps/labrinth/src/models/v3/mod.rs @@ -20,3 +20,4 @@ pub mod teams; pub mod threads; pub mod user_limits; pub mod users; +pub mod preferences; diff --git a/apps/labrinth/src/models/v3/preferences.rs b/apps/labrinth/src/models/v3/preferences.rs new file mode 100644 index 0000000000..ad4c17a16a --- /dev/null +++ b/apps/labrinth/src/models/v3/preferences.rs @@ -0,0 +1,65 @@ +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +#[derive(Serialize, Deserialize, ToSchema)] +pub struct UserPreferences { + pub appearance: AppearancePreferences, + pub localization: LocalizationPreferences, + pub layouts: LayoutPreferences, + pub sidebars: SidebarPreferences, + pub social: SocialPreferences +} + +#[derive(Serialize, Deserialize, ToSchema)] +pub struct AppearancePreferences { + pub theme: Theme +} + +#[derive(Serialize, Deserialize, ToSchema)] +pub enum Theme { + Light, Dark, Oled, Retro +} + +#[derive(Serialize, Deserialize, ToSchema)] +pub struct LocalizationPreferences { + pub locale: String // FIXME: validate input +} + +#[derive(Serialize, Deserialize, ToSchema)] +pub struct LayoutPreferences { + pub mods: LayoutOption, + pub plugins: LayoutOption, + pub datapacks: LayoutOption, + pub shaders: LayoutOption, + pub resourcepacks: LayoutOption, + pub modpacks: LayoutOption, + pub servers: LayoutOption, + pub users: LayoutOption, +} + +#[derive(Serialize, Deserialize, ToSchema)] +pub enum LayoutOption { + Grid, Rows +} + +#[derive(Serialize, Deserialize, ToSchema)] +pub struct SidebarPreferences { + pub right_aligned_search: bool, + pub left_aligned_content: bool, +} + +#[derive(Serialize, Deserialize, ToSchema)] +pub struct SocialPreferences { + pub friend_privacy: FriendPrivacy, + pub shared_instances_privacy: SharedInstancesPrivacy +} + +#[derive(Serialize, Deserialize, ToSchema)] +pub enum FriendPrivacy { + None, Mutual, Everyone +} + +#[derive(Serialize, Deserialize, ToSchema)] +pub enum SharedInstancesPrivacy { + None, Friends, Everyone +} From a18a4e796b90db6cfe9fe800d3de97f327722304 Mon Sep 17 00:00:00 2001 From: sychic <47618543+Sychic@users.noreply.github.com> Date: Thu, 6 Aug 2026 09:57:25 -0400 Subject: [PATCH 02/27] feat(labrinth): use partially to derive partial structs --- Cargo.lock | 22 +++++++++++++++++++++ Cargo.toml | 1 + apps/labrinth/Cargo.toml | 1 + apps/labrinth/src/models/v3/preferences.rs | 23 ++++++++++++++-------- 4 files changed, 39 insertions(+), 8 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0772a3a9c1..32821a02a2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5506,6 +5506,7 @@ dependencies = [ "muralpay", "murmur2", "neverbounce", + "partially", "paste", "path-util", "postcard", @@ -7168,6 +7169,27 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "487f2ccd1e17ce8c1bfab3a65c89525af41cfad4c8659021a1e9a2aacd73b89b" +[[package]] +name = "partially" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c662358b50ce030ff0bf5c174541e37b2e29564732c34504e8ffb952389e3c1" +dependencies = [ + "partially_derive", +] + +[[package]] +name = "partially_derive" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e53059790c7b28bb3a618a75dbfa0c6e569515d0b8d3a4df94baca76d4e14b38" +dependencies = [ + "darling 0.20.11", + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "password-hash" version = "0.5.0" diff --git a/Cargo.toml b/Cargo.toml index ab510f52d7..d8b8cd8800 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -144,6 +144,7 @@ notify-debouncer-mini = { version = "0.7.0", default-features = false } objc2-app-kit = { version = "0.3.2", default-features = false } p256 = "0.13.2" parking_lot = "0.12.5" +partially = { version = "0.2.1", features = ["derive"] } paste = "1.0.15" path-util = { path = "packages/path-util" } phf = { version = "0.13.1", features = ["macros"] } diff --git a/apps/labrinth/Cargo.toml b/apps/labrinth/Cargo.toml index fbfe31b0bf..be818e4bc2 100644 --- a/apps/labrinth/Cargo.toml +++ b/apps/labrinth/Cargo.toml @@ -78,6 +78,7 @@ modrinth-util = { workspace = true, features = ["decimal", "sentry", "utoipa"] } muralpay = { workspace = true, features = ["client", "mock", "utoipa"] } murmur2 = { workspace = true } neverbounce = { workspace = true } +partially = { workspace = true } paste = { workspace = true } path-util = { workspace = true } postcard = { workspace = true } diff --git a/apps/labrinth/src/models/v3/preferences.rs b/apps/labrinth/src/models/v3/preferences.rs index ad4c17a16a..a255d51798 100644 --- a/apps/labrinth/src/models/v3/preferences.rs +++ b/apps/labrinth/src/models/v3/preferences.rs @@ -1,16 +1,19 @@ +use partially::Partial; use serde::{Deserialize, Serialize}; use utoipa::ToSchema; -#[derive(Serialize, Deserialize, ToSchema)] +#[derive(Serialize, Deserialize, ToSchema, Partial)] +#[partially(skip_attributes, derive(Deserialize, ToSchema))] pub struct UserPreferences { pub appearance: AppearancePreferences, pub localization: LocalizationPreferences, pub layouts: LayoutPreferences, pub sidebars: SidebarPreferences, - pub social: SocialPreferences + pub social: SocialPreferences, } -#[derive(Serialize, Deserialize, ToSchema)] +#[derive(Serialize, Deserialize, ToSchema, Partial)] +#[partially(skip_attributes, derive(Deserialize, ToSchema))] pub struct AppearancePreferences { pub theme: Theme } @@ -20,12 +23,14 @@ pub enum Theme { Light, Dark, Oled, Retro } -#[derive(Serialize, Deserialize, ToSchema)] +#[derive(Serialize, Deserialize, ToSchema, Partial)] +#[partially(skip_attributes, derive(Deserialize, ToSchema))] pub struct LocalizationPreferences { - pub locale: String // FIXME: validate input + pub locale: String } -#[derive(Serialize, Deserialize, ToSchema)] +#[derive(Serialize, Deserialize, ToSchema, Partial)] +#[partially(skip_attributes, derive(Deserialize, ToSchema))] pub struct LayoutPreferences { pub mods: LayoutOption, pub plugins: LayoutOption, @@ -42,13 +47,15 @@ pub enum LayoutOption { Grid, Rows } -#[derive(Serialize, Deserialize, ToSchema)] +#[derive(Serialize, Deserialize, ToSchema, Partial)] +#[partially(skip_attributes, derive(Deserialize, ToSchema))] pub struct SidebarPreferences { pub right_aligned_search: bool, pub left_aligned_content: bool, } -#[derive(Serialize, Deserialize, ToSchema)] +#[derive(Serialize, Deserialize, ToSchema, Partial)] +#[partially(skip_attributes, derive(Deserialize, ToSchema))] pub struct SocialPreferences { pub friend_privacy: FriendPrivacy, pub shared_instances_privacy: SharedInstancesPrivacy From b8250965278db3a17e8771111ac87be20dbf015f Mon Sep 17 00:00:00 2001 From: sychic <47618543+Sychic@users.noreply.github.com> Date: Thu, 6 Aug 2026 20:55:47 -0400 Subject: [PATCH 03/27] feat(labrinth): preference defaults --- apps/labrinth/src/models/v3/preferences.rs | 53 ++++++++++++++++++---- 1 file changed, 43 insertions(+), 10 deletions(-) diff --git a/apps/labrinth/src/models/v3/preferences.rs b/apps/labrinth/src/models/v3/preferences.rs index a255d51798..2fc2276bbd 100644 --- a/apps/labrinth/src/models/v3/preferences.rs +++ b/apps/labrinth/src/models/v3/preferences.rs @@ -2,7 +2,7 @@ use partially::Partial; use serde::{Deserialize, Serialize}; use utoipa::ToSchema; -#[derive(Serialize, Deserialize, ToSchema, Partial)] +#[derive(Serialize, Deserialize, ToSchema, Partial, Default)] #[partially(skip_attributes, derive(Deserialize, ToSchema))] pub struct UserPreferences { pub appearance: AppearancePreferences, @@ -12,15 +12,19 @@ pub struct UserPreferences { pub social: SocialPreferences, } -#[derive(Serialize, Deserialize, ToSchema, Partial)] +#[derive(Serialize, Deserialize, ToSchema, Partial, Default)] #[partially(skip_attributes, derive(Deserialize, ToSchema))] pub struct AppearancePreferences { pub theme: Theme } -#[derive(Serialize, Deserialize, ToSchema)] +#[derive(Serialize, Deserialize, ToSchema, Default)] pub enum Theme { - Light, Dark, Oled, Retro + Light, + #[default] + Dark, + Oled, + Retro, } #[derive(Serialize, Deserialize, ToSchema, Partial)] @@ -29,6 +33,14 @@ pub struct LocalizationPreferences { pub locale: String } +impl Default for LocalizationPreferences { + fn default() -> Self { + Self { + locale: "en-US".to_owned(), + } + } +} + #[derive(Serialize, Deserialize, ToSchema, Partial)] #[partially(skip_attributes, derive(Deserialize, ToSchema))] pub struct LayoutPreferences { @@ -42,31 +54,52 @@ pub struct LayoutPreferences { pub users: LayoutOption, } +impl Default for LayoutPreferences { + fn default() -> Self { + Self { + mods: LayoutOption::Rows, + plugins: LayoutOption::Rows, + datapacks: LayoutOption::Rows, + shaders: LayoutOption::Grid, + resourcepacks: LayoutOption::Grid, + modpacks: LayoutOption::Rows, + servers: LayoutOption::Rows, + users: LayoutOption::Rows, + } + } +} + #[derive(Serialize, Deserialize, ToSchema)] pub enum LayoutOption { Grid, Rows } -#[derive(Serialize, Deserialize, ToSchema, Partial)] +#[derive(Serialize, Deserialize, ToSchema, Partial, Default)] #[partially(skip_attributes, derive(Deserialize, ToSchema))] pub struct SidebarPreferences { pub right_aligned_search: bool, pub left_aligned_content: bool, } -#[derive(Serialize, Deserialize, ToSchema, Partial)] +#[derive(Serialize, Deserialize, ToSchema, Partial, Default)] #[partially(skip_attributes, derive(Deserialize, ToSchema))] pub struct SocialPreferences { pub friend_privacy: FriendPrivacy, pub shared_instances_privacy: SharedInstancesPrivacy } -#[derive(Serialize, Deserialize, ToSchema)] +#[derive(Serialize, Deserialize, ToSchema, Default)] pub enum FriendPrivacy { - None, Mutual, Everyone + None, + Mutual, + #[default] + Everyone, } -#[derive(Serialize, Deserialize, ToSchema)] +#[derive(Serialize, Deserialize, ToSchema, Default)] pub enum SharedInstancesPrivacy { - None, Friends, Everyone + None, + Friends, + #[default] + Everyone, } From 5aa40c1e9bbe81275864d374a2ff5b58f00b1bab Mon Sep 17 00:00:00 2001 From: sychic <47618543+Sychic@users.noreply.github.com> Date: Fri, 7 Aug 2026 10:22:53 -0400 Subject: [PATCH 04/27] fix(labrinth): derive debug --- apps/labrinth/src/models/v3/preferences.rs | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/apps/labrinth/src/models/v3/preferences.rs b/apps/labrinth/src/models/v3/preferences.rs index 2fc2276bbd..3431603621 100644 --- a/apps/labrinth/src/models/v3/preferences.rs +++ b/apps/labrinth/src/models/v3/preferences.rs @@ -2,7 +2,7 @@ use partially::Partial; use serde::{Deserialize, Serialize}; use utoipa::ToSchema; -#[derive(Serialize, Deserialize, ToSchema, Partial, Default)] +#[derive(Debug, Serialize, Deserialize, ToSchema, Partial, Default)] #[partially(skip_attributes, derive(Deserialize, ToSchema))] pub struct UserPreferences { pub appearance: AppearancePreferences, @@ -12,13 +12,13 @@ pub struct UserPreferences { pub social: SocialPreferences, } -#[derive(Serialize, Deserialize, ToSchema, Partial, Default)] +#[derive(Debug, Serialize, Deserialize, ToSchema, Partial, Default)] #[partially(skip_attributes, derive(Deserialize, ToSchema))] pub struct AppearancePreferences { pub theme: Theme } -#[derive(Serialize, Deserialize, ToSchema, Default)] +#[derive(Debug, Serialize, Deserialize, ToSchema, Default)] pub enum Theme { Light, #[default] @@ -27,7 +27,7 @@ pub enum Theme { Retro, } -#[derive(Serialize, Deserialize, ToSchema, Partial)] +#[derive(Debug, Serialize, Deserialize, ToSchema, Partial)] #[partially(skip_attributes, derive(Deserialize, ToSchema))] pub struct LocalizationPreferences { pub locale: String @@ -41,7 +41,7 @@ impl Default for LocalizationPreferences { } } -#[derive(Serialize, Deserialize, ToSchema, Partial)] +#[derive(Debug, Serialize, Deserialize, ToSchema, Partial)] #[partially(skip_attributes, derive(Deserialize, ToSchema))] pub struct LayoutPreferences { pub mods: LayoutOption, @@ -69,26 +69,26 @@ impl Default for LayoutPreferences { } } -#[derive(Serialize, Deserialize, ToSchema)] +#[derive(Debug, Serialize, Deserialize, ToSchema)] pub enum LayoutOption { Grid, Rows } -#[derive(Serialize, Deserialize, ToSchema, Partial, Default)] +#[derive(Debug, Serialize, Deserialize, ToSchema, Partial, Default)] #[partially(skip_attributes, derive(Deserialize, ToSchema))] pub struct SidebarPreferences { pub right_aligned_search: bool, pub left_aligned_content: bool, } -#[derive(Serialize, Deserialize, ToSchema, Partial, Default)] +#[derive(Debug, Serialize, Deserialize, ToSchema, Partial, Default)] #[partially(skip_attributes, derive(Deserialize, ToSchema))] pub struct SocialPreferences { pub friend_privacy: FriendPrivacy, pub shared_instances_privacy: SharedInstancesPrivacy } -#[derive(Serialize, Deserialize, ToSchema, Default)] +#[derive(Debug, Serialize, Deserialize, ToSchema, Default)] pub enum FriendPrivacy { None, Mutual, @@ -96,7 +96,7 @@ pub enum FriendPrivacy { Everyone, } -#[derive(Serialize, Deserialize, ToSchema, Default)] +#[derive(Debug, Serialize, Deserialize, ToSchema, Default)] pub enum SharedInstancesPrivacy { None, Friends, From b58a03d313e07dd03f5e051b229d74f68c00a9f4 Mon Sep 17 00:00:00 2001 From: sychic <47618543+Sychic@users.noreply.github.com> Date: Fri, 7 Aug 2026 10:33:56 -0400 Subject: [PATCH 05/27] feat(labrinth): user preferences db setup --- ...823cd02c215c4b219859724928ebdb99e493f.json | 15 ++++++ ...1e53f2285d77505d7c340c366203934e366b0.json | 22 ++++++++ .../20260806120000_user-preferences.sql | 4 ++ apps/labrinth/src/database/models/mod.rs | 1 + .../database/models/user_preferences_item.rs | 53 +++++++++++++++++++ 5 files changed, 95 insertions(+) create mode 100644 apps/labrinth/.sqlx/query-b301f61d9e57ba351cc12f47066823cd02c215c4b219859724928ebdb99e493f.json create mode 100644 apps/labrinth/.sqlx/query-e4820523f8cb324aa104dc905251e53f2285d77505d7c340c366203934e366b0.json create mode 100644 apps/labrinth/migrations/20260806120000_user-preferences.sql create mode 100644 apps/labrinth/src/database/models/user_preferences_item.rs diff --git a/apps/labrinth/.sqlx/query-b301f61d9e57ba351cc12f47066823cd02c215c4b219859724928ebdb99e493f.json b/apps/labrinth/.sqlx/query-b301f61d9e57ba351cc12f47066823cd02c215c4b219859724928ebdb99e493f.json new file mode 100644 index 0000000000..d27a0354eb --- /dev/null +++ b/apps/labrinth/.sqlx/query-b301f61d9e57ba351cc12f47066823cd02c215c4b219859724928ebdb99e493f.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO user_preferences (user_id, preferences)\n VALUES ($1, $2)\n ON CONFLICT (user_id) DO UPDATE\n SET preferences = EXCLUDED.preferences\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Jsonb" + ] + }, + "nullable": [] + }, + "hash": "b301f61d9e57ba351cc12f47066823cd02c215c4b219859724928ebdb99e493f" +} diff --git a/apps/labrinth/.sqlx/query-e4820523f8cb324aa104dc905251e53f2285d77505d7c340c366203934e366b0.json b/apps/labrinth/.sqlx/query-e4820523f8cb324aa104dc905251e53f2285d77505d7c340c366203934e366b0.json new file mode 100644 index 0000000000..7a15c67054 --- /dev/null +++ b/apps/labrinth/.sqlx/query-e4820523f8cb324aa104dc905251e53f2285d77505d7c340c366203934e366b0.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT preferences AS \"preferences: Json\"\n FROM user_preferences\n WHERE user_id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "preferences: Json", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false + ] + }, + "hash": "e4820523f8cb324aa104dc905251e53f2285d77505d7c340c366203934e366b0" +} diff --git a/apps/labrinth/migrations/20260806120000_user-preferences.sql b/apps/labrinth/migrations/20260806120000_user-preferences.sql new file mode 100644 index 0000000000..ed9409d52b --- /dev/null +++ b/apps/labrinth/migrations/20260806120000_user-preferences.sql @@ -0,0 +1,4 @@ +CREATE TABLE user_preferences ( + user_id BIGINT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + preferences JSONB NOT NULL +); diff --git a/apps/labrinth/src/database/models/mod.rs b/apps/labrinth/src/database/models/mod.rs index 896791e7d3..e647428669 100644 --- a/apps/labrinth/src/database/models/mod.rs +++ b/apps/labrinth/src/database/models/mod.rs @@ -38,6 +38,7 @@ pub mod team_item; pub mod thread_item; pub mod user_item; pub mod user_limits; +pub mod user_preferences_item; pub mod user_subscription_item; pub mod users_compliance; pub mod users_notifications_preferences_item; diff --git a/apps/labrinth/src/database/models/user_preferences_item.rs b/apps/labrinth/src/database/models/user_preferences_item.rs new file mode 100644 index 0000000000..a2949f0044 --- /dev/null +++ b/apps/labrinth/src/database/models/user_preferences_item.rs @@ -0,0 +1,53 @@ +use sqlx::types::Json; +use crate::database::Executor; +use crate::database::models::DBUserId; +use crate::models::v3::preferences::UserPreferences; + +pub struct DBUserPreferences; + +impl DBUserPreferences { + pub async fn get<'a, E>( + user_id: DBUserId, + exec: E, + ) -> Result, sqlx::Error> + where + E: Executor<'a, Database = sqlx::Postgres>, + { + let row = sqlx::query!( + r#" + SELECT preferences AS "preferences: Json" + FROM user_preferences + WHERE user_id = $1 + "#, + user_id.0, + ) + .fetch_optional(exec) + .await?; + + Ok(row.map(|row| row.preferences.0)) + } + + pub async fn upsert<'a, E>( + user_id: DBUserId, + preferences: &UserPreferences, + exec: E, + ) -> Result<(), sqlx::Error> + where + E: Executor<'a, Database = sqlx::Postgres>, + { + sqlx::query!( + r#" + INSERT INTO user_preferences (user_id, preferences) + VALUES ($1, $2) + ON CONFLICT (user_id) DO UPDATE + SET preferences = EXCLUDED.preferences + "#, + user_id.0, + Json(preferences) as Json<&UserPreferences>, + ) + .execute(exec) + .await?; + + Ok(()) + } +} From ac9f35a246c118a0be859d86af8cfcc8cf9a2a6f Mon Sep 17 00:00:00 2001 From: sychic <47618543+Sychic@users.noreply.github.com> Date: Fri, 7 Aug 2026 11:26:46 -0400 Subject: [PATCH 06/27] feat(labrinth): user preferences routes --- apps/labrinth/src/routes/v3/users.rs | 96 ++++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) diff --git a/apps/labrinth/src/routes/v3/users.rs b/apps/labrinth/src/routes/v3/users.rs index ce5215d4ce..bcc725e695 100644 --- a/apps/labrinth/src/routes/v3/users.rs +++ b/apps/labrinth/src/routes/v3/users.rs @@ -29,9 +29,16 @@ use crate::{ }; use actix_web::{HttpRequest, HttpResponse, delete, get, patch, web}; use ariadne::ids::UserId; +use eyre::eyre; +use partially::Partial; use serde::{Deserialize, Serialize}; use validator::Validate; +use crate::database::models::user_preferences_item::DBUserPreferences; +use crate::models::v3::preferences::{ + PartialUserPreferences, UserPreferences, +}; + pub fn config(cfg: &mut actix_web::web::ServiceConfig) { cfg.service(user_auth_get_route) .service(users_get_route) @@ -49,6 +56,8 @@ pub fn config(cfg: &mut actix_web::web::ServiceConfig) { .service(user_delete_route) .service(user_follows_route) .service(user_notifications_route) + .service(get_user_preferences) + .service(edit_user_preferences) .service(get_user_clients); } @@ -367,6 +376,93 @@ pub async fn user_auth_get( Ok(HttpResponse::Ok().json(user)) } +#[utoipa::path(tag = "users", responses((status = OK, body = UserPreferences)))] +#[get("/user/{id}/preferences")] +pub async fn get_user_preferences( + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result, ApiError> { + let (_, requester) = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Scopes::USER_READ, + ) + .await?; + + let target = DBUser::get(&info.into_inner().0, &**pool, &redis) + .await? + .ok_or(ApiError::NotFound)?; + + if requester.id != target.id.into() && !requester.role.is_mod() { + return Err(ApiError::Auth(eyre!( + "you do not have permission to access this user's preferences" + ))); + } + + let preferences = DBUserPreferences::get(target.id, &**pool) + .await + .wrap_internal_err("failed to fetch user preferences")? + .unwrap_or_default(); + + Ok(web::Json(preferences)) +} + +#[utoipa::path( + tag = "users", + request_body = PartialUserPreferences, + responses((status = OK, body = UserPreferences)) +)] +#[patch("/user/{id}/preferences")] +pub async fn edit_user_preferences( + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, + body: web::Json, +) -> Result, ApiError> { + let (_, requester) = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Scopes::USER_WRITE, + ) + .await?; + + let target = DBUser::get(&info.into_inner().0, &**pool, &redis) + .await? + .ok_or(ApiError::NotFound)?; + + if requester.id != target.id.into() && !requester.role.is_mod() { + return Err(ApiError::Auth(eyre!( + "you do not have permission to access this user's preferences" + ))); + } + + let mut txn = pool.begin().await?; + + let mut preferences = DBUserPreferences::get(target.id, &mut txn) + .await + .wrap_internal_err("failed to fetch user preferences")? + .unwrap_or_default(); + + preferences.apply_some(body.into_inner()); + + DBUserPreferences::upsert(target.id, &preferences, &mut txn) + .await + .wrap_internal_err("failed to update user preferences")?; + + txn.commit().await?; + + Ok(web::Json(preferences)) +} + #[derive(Serialize, Deserialize)] pub struct UserIds { pub ids: String, From 5a0ac93ed16d1393b17201f9ee6ad7dce219cce9 Mon Sep 17 00:00:00 2001 From: sychic <47618543+Sychic@users.noreply.github.com> Date: Fri, 7 Aug 2026 12:04:48 -0400 Subject: [PATCH 07/27] fix(labrinth): nested partial structs --- apps/labrinth/src/models/v3/preferences.rs | 57 +++++++++++++++++----- 1 file changed, 46 insertions(+), 11 deletions(-) diff --git a/apps/labrinth/src/models/v3/preferences.rs b/apps/labrinth/src/models/v3/preferences.rs index 3431603621..c4dadd72b6 100644 --- a/apps/labrinth/src/models/v3/preferences.rs +++ b/apps/labrinth/src/models/v3/preferences.rs @@ -2,8 +2,7 @@ use partially::Partial; use serde::{Deserialize, Serialize}; use utoipa::ToSchema; -#[derive(Debug, Serialize, Deserialize, ToSchema, Partial, Default)] -#[partially(skip_attributes, derive(Deserialize, ToSchema))] +#[derive(Debug, Serialize, Deserialize, ToSchema, Default)] pub struct UserPreferences { pub appearance: AppearancePreferences, pub localization: LocalizationPreferences, @@ -12,10 +11,45 @@ pub struct UserPreferences { pub social: SocialPreferences, } +#[derive(Debug, Deserialize, ToSchema, Default)] +pub struct PartialUserPreferences { + pub appearance: Option, + pub localization: Option, + pub layouts: Option, + pub sidebars: Option, + pub social: Option, +} + +impl Partial for UserPreferences { + type Item = PartialUserPreferences; + + fn apply_some(&mut self, partial: Self::Item) -> bool { + let mut applied = false; + + if let Some(appearance) = partial.appearance { + applied |= self.appearance.apply_some(appearance); + } + if let Some(localization) = partial.localization { + applied |= self.localization.apply_some(localization); + } + if let Some(layouts) = partial.layouts { + applied |= self.layouts.apply_some(layouts); + } + if let Some(sidebars) = partial.sidebars { + applied |= self.sidebars.apply_some(sidebars); + } + if let Some(social) = partial.social { + applied |= self.social.apply_some(social); + } + + applied + } +} + #[derive(Debug, Serialize, Deserialize, ToSchema, Partial, Default)] -#[partially(skip_attributes, derive(Deserialize, ToSchema))] +#[partially(skip_attributes, derive(Debug, Deserialize, ToSchema))] pub struct AppearancePreferences { - pub theme: Theme + pub theme: Theme, } #[derive(Debug, Serialize, Deserialize, ToSchema, Default)] @@ -28,9 +62,9 @@ pub enum Theme { } #[derive(Debug, Serialize, Deserialize, ToSchema, Partial)] -#[partially(skip_attributes, derive(Deserialize, ToSchema))] +#[partially(skip_attributes, derive(Debug, Deserialize, ToSchema))] pub struct LocalizationPreferences { - pub locale: String + pub locale: String, } impl Default for LocalizationPreferences { @@ -42,7 +76,7 @@ impl Default for LocalizationPreferences { } #[derive(Debug, Serialize, Deserialize, ToSchema, Partial)] -#[partially(skip_attributes, derive(Deserialize, ToSchema))] +#[partially(skip_attributes, derive(Debug, Deserialize, ToSchema))] pub struct LayoutPreferences { pub mods: LayoutOption, pub plugins: LayoutOption, @@ -71,21 +105,22 @@ impl Default for LayoutPreferences { #[derive(Debug, Serialize, Deserialize, ToSchema)] pub enum LayoutOption { - Grid, Rows + Grid, + Rows, } #[derive(Debug, Serialize, Deserialize, ToSchema, Partial, Default)] -#[partially(skip_attributes, derive(Deserialize, ToSchema))] +#[partially(skip_attributes, derive(Debug, Deserialize, ToSchema))] pub struct SidebarPreferences { pub right_aligned_search: bool, pub left_aligned_content: bool, } #[derive(Debug, Serialize, Deserialize, ToSchema, Partial, Default)] -#[partially(skip_attributes, derive(Deserialize, ToSchema))] +#[partially(skip_attributes, derive(Debug, Deserialize, ToSchema))] pub struct SocialPreferences { pub friend_privacy: FriendPrivacy, - pub shared_instances_privacy: SharedInstancesPrivacy + pub shared_instances_privacy: SharedInstancesPrivacy, } #[derive(Debug, Serialize, Deserialize, ToSchema, Default)] From 973b07253a98f41a7c130b1571447da6d28cd29a Mon Sep 17 00:00:00 2001 From: sychic <47618543+Sychic@users.noreply.github.com> Date: Fri, 7 Aug 2026 12:05:15 -0400 Subject: [PATCH 08/27] fix(labrinth): serialize enums as snake case --- apps/labrinth/src/models/v3/preferences.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/labrinth/src/models/v3/preferences.rs b/apps/labrinth/src/models/v3/preferences.rs index c4dadd72b6..39fc6a1d9b 100644 --- a/apps/labrinth/src/models/v3/preferences.rs +++ b/apps/labrinth/src/models/v3/preferences.rs @@ -53,6 +53,7 @@ pub struct AppearancePreferences { } #[derive(Debug, Serialize, Deserialize, ToSchema, Default)] +#[serde(rename_all = "snake_case")] pub enum Theme { Light, #[default] @@ -104,6 +105,7 @@ impl Default for LayoutPreferences { } #[derive(Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "snake_case")] pub enum LayoutOption { Grid, Rows, @@ -124,6 +126,7 @@ pub struct SocialPreferences { } #[derive(Debug, Serialize, Deserialize, ToSchema, Default)] +#[serde(rename_all = "snake_case")] pub enum FriendPrivacy { None, Mutual, @@ -132,6 +135,7 @@ pub enum FriendPrivacy { } #[derive(Debug, Serialize, Deserialize, ToSchema, Default)] +#[serde(rename_all = "snake_case")] pub enum SharedInstancesPrivacy { None, Friends, From 3d68c9359a3b88ff03129f8f053187ea85d841ea Mon Sep 17 00:00:00 2001 From: sychic <47618543+Sychic@users.noreply.github.com> Date: Fri, 14 Aug 2026 12:11:49 -0400 Subject: [PATCH 09/27] refactor(labrinth-derive): rename to component-derive --- .idea/code.iml | 1 + Cargo.lock | 20 +++++++++---------- Cargo.toml | 2 +- .../Cargo.toml | 2 +- .../src/component.rs | 0 .../src/lib.rs | 0 6 files changed, 13 insertions(+), 12 deletions(-) rename packages/{labrinth-derive => component-derive}/Cargo.toml (91%) rename packages/{labrinth-derive => component-derive}/src/component.rs (100%) rename packages/{labrinth-derive => component-derive}/src/lib.rs (100%) diff --git a/.idea/code.iml b/.idea/code.iml index 3861923c60..a1d7c854b4 100644 --- a/.idea/code.iml +++ b/.idea/code.iml @@ -28,6 +28,7 @@ + diff --git a/Cargo.lock b/Cargo.lock index 32821a02a2..16ef966ff0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2223,6 +2223,16 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "component-derive" +version = "0.0.0" +dependencies = [ + "darling 0.23.0", + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "compression-codecs" version = "0.4.31" @@ -5558,16 +5568,6 @@ dependencies = [ "zxcvbn", ] -[[package]] -name = "labrinth-derive" -version = "0.0.0" -dependencies = [ - "darling 0.23.0", - "proc-macro2", - "quote", - "syn 2.0.106", -] - [[package]] name = "language-tags" version = "0.3.2" diff --git a/Cargo.toml b/Cargo.toml index d8b8cd8800..18743e1d15 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,8 +7,8 @@ members = [ "apps/labrinth", "packages/app-lib", "packages/ariadne", + "packages/component-derive", "packages/daedalus", - "packages/labrinth-derive", "packages/modrinth-content-management", "packages/modrinth-log", "packages/modrinth-maxmind", diff --git a/packages/labrinth-derive/Cargo.toml b/packages/component-derive/Cargo.toml similarity index 91% rename from packages/labrinth-derive/Cargo.toml rename to packages/component-derive/Cargo.toml index c3a231868b..5b7b3f6e51 100644 --- a/packages/labrinth-derive/Cargo.toml +++ b/packages/component-derive/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "labrinth-derive" +name = "component-derive" edition.workspace = true rust-version.workspace = true repository.workspace = true diff --git a/packages/labrinth-derive/src/component.rs b/packages/component-derive/src/component.rs similarity index 100% rename from packages/labrinth-derive/src/component.rs rename to packages/component-derive/src/component.rs diff --git a/packages/labrinth-derive/src/lib.rs b/packages/component-derive/src/lib.rs similarity index 100% rename from packages/labrinth-derive/src/lib.rs rename to packages/component-derive/src/lib.rs From 78fdf0f9c6e9d0d3ced51ac8568cef7e168fb08b Mon Sep 17 00:00:00 2001 From: sychic <47618543+Sychic@users.noreply.github.com> Date: Fri, 14 Aug 2026 16:42:05 -0400 Subject: [PATCH 10/27] feat(component-derive): nested components --- packages/component-derive/src/component.rs | 90 ++++++++++++++++++---- 1 file changed, 74 insertions(+), 16 deletions(-) diff --git a/packages/component-derive/src/component.rs b/packages/component-derive/src/component.rs index f76ca8748e..f8e789f96f 100644 --- a/packages/component-derive/src/component.rs +++ b/packages/component-derive/src/component.rs @@ -1,7 +1,7 @@ use darling::{FromDeriveInput, FromField}; use proc_macro2::TokenStream; use quote::{format_ident, quote}; -use syn::{Attribute, DeriveInput, Ident, Result, Type, Visibility}; +use syn::{Attribute, DeriveInput, Error, Ident, Result, Type, Visibility}; #[derive(Debug, FromDeriveInput)] #[darling(supports(struct_named))] @@ -20,6 +20,8 @@ struct ComponentField { attrs: Vec, #[darling(default)] synthetic: bool, + #[darling(default)] + nested: bool, } pub fn derive(input: &DeriveInput) -> Result { @@ -32,7 +34,16 @@ pub fn derive(input: &DeriveInput) -> Result { let struct_serial = struct_serial(&vis, &ident, fields)?; let struct_edit = struct_edit(&vis, &ident, fields)?; + // `#[validate(nested)]` needs `Validate` in scope; `as _` avoids a name clash + let validate_import = if fields.iter().any(|field| field.nested) { + quote! { use validator::Validate as _; } + } else { + quote! {} + }; + Ok(quote! { + #validate_import + #struct_serial #struct_edit }) @@ -60,12 +71,23 @@ fn struct_serial( let ty = &field.ty; let attrs = &field.attrs; - Some(quote! { + let (field_ty, validate_attr) = if field.nested { + let field_ty = match nested_type(ty, "Serial") { + Ok(field_ty) => field_ty, + Err(err) => return Some(Err(err)), + }; + (field_ty, quote! { #[validate(nested)] }) + } else { + (quote! { #ty }, quote! {}) + }; + + Some(Ok(quote! { #(#attrs)* - #vis #ident: #ty - }) + #validate_attr + #vis #ident: #field_ty + })) }) - .collect::>(); + .collect::>>()?; Ok(quote! { #[derive( @@ -104,9 +126,31 @@ fn struct_edit( let ty = &field.ty; let attrs = &field.attrs; - let serde_attr = if let Type::Path(path) = ty - && let Some(root_ident) = path.path.segments.first() - && root_ident.ident == "Option" + let (inner_ty, apply_value, validate_attr) = if field.nested { + let inner_ty = match nested_type(ty, "Edit") { + Ok(inner_ty) => inner_ty, + Err(err) => return Some(Err(err)), + }; + ( + inner_ty, + quote! { t.apply_to(&mut component.#ident) }, + quote! { #[validate(nested)] }, + ) + } else { + ( + quote! { #ty }, + quote! { component.#ident = t }, + quote! {}, + ) + }; + + let serde_attr = if !field.nested + && let Type::Path(path) = ty + && path + .path + .segments + .first() + .is_some_and(|segment| segment.ident == "Option") { quote! { #[serde( @@ -116,25 +160,24 @@ fn struct_edit( )] } } else { - quote! { - #[serde(default)] - } + quote! { #[serde(default)] } }; - Some(( + Some(Ok(( quote! { #(#attrs)* + #validate_attr #serde_attr - #vis #ident: ::core::option::Option<#ty> + #vis #ident: ::core::option::Option<#inner_ty> }, quote! { if let Some(t) = self.#ident { - component.#ident = t; + #apply_value; } }, - )) + ))) }) - .unzip(); + .collect::, Vec<_>)>>()?; Ok(quote! { #[derive( @@ -159,3 +202,18 @@ fn struct_edit( } }) } + +fn nested_type(ty: &Type, suffix: &str) -> Result { + if let Type::Path(path) = ty + && let Some(segment) = path.path.segments.last() + { + // FIXME: Validate that nested type also derives component, prob by checking for component impl + let nested = format_ident!("{}{}", segment.ident, suffix); + Ok(quote! { #nested }) + } else { + Err(Error::new_spanned( + ty, + "nested component fields must be a named path type", + )) + } +} From 794d3f6646858eebddcfaf4f5f20f08c48b785fe Mon Sep 17 00:00:00 2001 From: sychic <47618543+Sychic@users.noreply.github.com> Date: Sat, 15 Aug 2026 10:17:03 -0400 Subject: [PATCH 11/27] refactor(component-derive): change suffixes to prefixes --- packages/component-derive/src/component.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/component-derive/src/component.rs b/packages/component-derive/src/component.rs index f8e789f96f..5e5ff76239 100644 --- a/packages/component-derive/src/component.rs +++ b/packages/component-derive/src/component.rs @@ -54,7 +54,7 @@ fn struct_serial( ident: &Ident, fields: &[ComponentField], ) -> Result { - let ident_serial = format_ident!("{ident}Serial"); + let ident_serial = format_ident!("Serial{ident}"); let fields = fields .iter() @@ -109,7 +109,7 @@ fn struct_edit( ident: &Ident, fields: &[ComponentField], ) -> Result { - let ident_edit = format_ident!("{ident}Edit"); + let ident_edit = format_ident!("Edit{ident}"); let (fields, apply_fields): (Vec<_>, Vec<_>) = fields .iter() @@ -203,12 +203,12 @@ fn struct_edit( }) } -fn nested_type(ty: &Type, suffix: &str) -> Result { +fn nested_type(ty: &Type, prefix: &str) -> Result { if let Type::Path(path) = ty && let Some(segment) = path.path.segments.last() { // FIXME: Validate that nested type also derives component, prob by checking for component impl - let nested = format_ident!("{}{}", segment.ident, suffix); + let nested = format_ident!("{}{}", prefix, segment.ident); Ok(quote! { #nested }) } else { Err(Error::new_spanned( From e406c56fa77a0f325d672ed135740c0fd197a4c1 Mon Sep 17 00:00:00 2001 From: sychic <47618543+Sychic@users.noreply.github.com> Date: Sat, 15 Aug 2026 10:28:56 -0400 Subject: [PATCH 12/27] refactor(component-derive): rename edit to partial --- packages/component-derive/src/component.rs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/component-derive/src/component.rs b/packages/component-derive/src/component.rs index 5e5ff76239..7896e36124 100644 --- a/packages/component-derive/src/component.rs +++ b/packages/component-derive/src/component.rs @@ -32,7 +32,7 @@ pub fn derive(input: &DeriveInput) -> Result { let fields = &fields.fields; let struct_serial = struct_serial(&vis, &ident, fields)?; - let struct_edit = struct_edit(&vis, &ident, fields)?; + let struct_partial = struct_partial(&vis, &ident, fields)?; // `#[validate(nested)]` needs `Validate` in scope; `as _` avoids a name clash let validate_import = if fields.iter().any(|field| field.nested) { @@ -45,7 +45,7 @@ pub fn derive(input: &DeriveInput) -> Result { #validate_import #struct_serial - #struct_edit + #struct_partial }) } @@ -104,12 +104,12 @@ fn struct_serial( }) } -fn struct_edit( +fn struct_partial( vis: &Visibility, ident: &Ident, fields: &[ComponentField], ) -> Result { - let ident_edit = format_ident!("Edit{ident}"); + let ident_partial = format_ident!("Partial{ident}"); let (fields, apply_fields): (Vec<_>, Vec<_>) = fields .iter() @@ -127,7 +127,7 @@ fn struct_edit( let attrs = &field.attrs; let (inner_ty, apply_value, validate_attr) = if field.nested { - let inner_ty = match nested_type(ty, "Edit") { + let inner_ty = match nested_type(ty, "Partial") { Ok(inner_ty) => inner_ty, Err(err) => return Some(Err(err)), }; @@ -188,11 +188,11 @@ fn struct_edit( ::validator::Validate, ::utoipa::ToSchema, )] - #vis struct #ident_edit { + #vis struct #ident_partial { #(#fields),* } - impl #ident_edit { + impl #ident_partial { pub fn apply_to( self, component: &mut #ident, From acec9f3c867320309fc2cb5b59f4a7cda8087484 Mon Sep 17 00:00:00 2001 From: sychic <47618543+Sychic@users.noreply.github.com> Date: Sat, 15 Aug 2026 11:09:46 -0400 Subject: [PATCH 13/27] feat(component-derive): skip serializing empty option --- packages/component-derive/src/component.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/component-derive/src/component.rs b/packages/component-derive/src/component.rs index 7896e36124..6d220767a0 100644 --- a/packages/component-derive/src/component.rs +++ b/packages/component-derive/src/component.rs @@ -160,7 +160,7 @@ fn struct_partial( )] } } else { - quote! { #[serde(default)] } + quote! { #[serde(default, skip_serializing_if = "::core::option::Option::is_none")] } }; Some(Ok(( From 25823a6104beed3c67e4f5a6b39c50fa25912a13 Mon Sep 17 00:00:00 2001 From: sychic <47618543+Sychic@users.noreply.github.com> Date: Sat, 15 Aug 2026 11:47:01 -0400 Subject: [PATCH 14/27] refactor(labrinth): wrap errors --- apps/labrinth/src/routes/v3/users.rs | 25 +++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/apps/labrinth/src/routes/v3/users.rs b/apps/labrinth/src/routes/v3/users.rs index bcc725e695..c159dbbe0f 100644 --- a/apps/labrinth/src/routes/v3/users.rs +++ b/apps/labrinth/src/routes/v3/users.rs @@ -392,11 +392,13 @@ pub async fn get_user_preferences( &session_queue, Scopes::USER_READ, ) - .await?; + .await + .wrap_auth_err("authenticating API request")?; let target = DBUser::get(&info.into_inner().0, &**pool, &redis) - .await? - .ok_or(ApiError::NotFound)?; + .await + .wrap_internal_err("fetching user from database")? + .wrap_not_found_err("resource not found")?; if requester.id != target.id.into() && !requester.role.is_mod() { return Err(ApiError::Auth(eyre!( @@ -433,11 +435,13 @@ pub async fn edit_user_preferences( &session_queue, Scopes::USER_WRITE, ) - .await?; + .await + .wrap_auth_err("authenticating API request")?; let target = DBUser::get(&info.into_inner().0, &**pool, &redis) - .await? - .ok_or(ApiError::NotFound)?; + .await + .wrap_internal_err("fetching user from database")? + .wrap_not_found_err("resource not found")?; if requester.id != target.id.into() && !requester.role.is_mod() { return Err(ApiError::Auth(eyre!( @@ -445,7 +449,10 @@ pub async fn edit_user_preferences( ))); } - let mut txn = pool.begin().await?; + let mut txn = pool + .begin() + .await + .wrap_internal_err("starting database transaction")?; let mut preferences = DBUserPreferences::get(target.id, &mut txn) .await @@ -458,7 +465,9 @@ pub async fn edit_user_preferences( .await .wrap_internal_err("failed to update user preferences")?; - txn.commit().await?; + txn.commit() + .await + .wrap_internal_err("committing database transaction")?; Ok(web::Json(preferences)) } From 64270b9577f350d2c4f044d5ef6274adfe0e2967 Mon Sep 17 00:00:00 2001 From: sychic <47618543+Sychic@users.noreply.github.com> Date: Sun, 16 Aug 2026 16:48:08 -0400 Subject: [PATCH 15/27] feat(component-derive): diff function --- packages/component-derive/src/component.rs | 146 ++++++++++++--------- 1 file changed, 82 insertions(+), 64 deletions(-) diff --git a/packages/component-derive/src/component.rs b/packages/component-derive/src/component.rs index 6d220767a0..3f242dbad9 100644 --- a/packages/component-derive/src/component.rs +++ b/packages/component-derive/src/component.rs @@ -111,73 +111,83 @@ fn struct_partial( ) -> Result { let ident_partial = format_ident!("Partial{ident}"); - let (fields, apply_fields): (Vec<_>, Vec<_>) = fields - .iter() - .filter_map(|field| { - if field.synthetic { - return None; - } - - let ident = &field - .ident - .as_ref() - .expect("macro only works on structs with named fields"); - let vis = &field.vis; - let ty = &field.ty; - let attrs = &field.attrs; - - let (inner_ty, apply_value, validate_attr) = if field.nested { - let inner_ty = match nested_type(ty, "Partial") { - Ok(inner_ty) => inner_ty, - Err(err) => return Some(Err(err)), - }; - ( - inner_ty, - quote! { t.apply_to(&mut component.#ident) }, - quote! { #[validate(nested)] }, - ) - } else { - ( - quote! { #ty }, - quote! { component.#ident = t }, - quote! {}, - ) - }; - - let serde_attr = if !field.nested - && let Type::Path(path) = ty - && path - .path - .segments - .first() - .is_some_and(|segment| segment.ident == "Option") - { - quote! { - #[serde( - default, - skip_serializing_if = "::core::option::Option::is_none", - with = "::serde_with::rust::double_option" - )] + let (fields, (apply_fields, diff_fields)): (Vec<_>, (Vec<_>, Vec<_>)) = + fields + .iter() + .filter_map(|field| { + if field.synthetic { + return None; } - } else { - quote! { #[serde(default, skip_serializing_if = "::core::option::Option::is_none")] } - }; - Some(Ok(( - quote! { - #(#attrs)* - #validate_attr - #serde_attr - #vis #ident: ::core::option::Option<#inner_ty> - }, - quote! { - if let Some(t) = self.#ident { - #apply_value; + let ident = &field + .ident + .as_ref() + .expect("macro only works on structs with named fields"); + let vis = &field.vis; + let ty = &field.ty; + let attrs = &field.attrs; + + let (inner_ty, apply_value, diff_value, validate_attr) = + if field.nested { + let inner_ty = match nested_type(ty, "Partial") { + Ok(inner_ty) => inner_ty, + Err(err) => return Some(Err(err)), + }; + ( + inner_ty, + quote! { t.apply_to(&mut component.#ident) }, + quote! { self.#ident.into_diff_from(&base.#ident) }, + quote! { #[validate(nested)] }, + ) + } else { + ( + quote! { #ty }, + quote! { component.#ident = t }, + quote! { self.#ident }, + quote! {}, + ) + }; + + let serde_attr = if !field.nested + && let Type::Path(path) = ty + && path + .path + .segments + .first() + .is_some_and(|segment| segment.ident == "Option") + { + quote! { + #[serde( + default, + skip_serializing_if = "::core::option::Option::is_none", + with = "::serde_with::rust::double_option" + )] } - }, - ))) - }) - .collect::, Vec<_>)>>()?; + } else { + quote! { #[serde(default, skip_serializing_if = "::core::option::Option::is_none")] } + }; + + Some(Ok(( + quote! { + #(#attrs)* + #validate_attr + #serde_attr + #vis #ident: ::core::option::Option<#inner_ty> + }, + ( + quote! { + if let Some(t) = self.#ident { + #apply_value; + } + }, + quote! { + #ident: (self.#ident != base.#ident) + .then(|| #diff_value) + }, + ), + ))) + }) + .collect::, (Vec<_>, Vec<_>))>>()?; Ok(quote! { #[derive( @@ -200,6 +210,14 @@ fn struct_partial( #(#apply_fields)* } } + + impl #ident { + pub fn into_diff_from(self, base: &Self) -> #ident_partial { + #ident_partial { + #(#diff_fields),* + } + } + } }) } From fab49e458c4527e5fb72e74c44c2a9cfbb01317e Mon Sep 17 00:00:00 2001 From: sychic <47618543+Sychic@users.noreply.github.com> Date: Sun, 16 Aug 2026 17:20:45 -0400 Subject: [PATCH 16/27] style(labrinth): fmt --- apps/labrinth/src/models/v3/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/labrinth/src/models/v3/mod.rs b/apps/labrinth/src/models/v3/mod.rs index 57d2dd41f2..be3ce755f7 100644 --- a/apps/labrinth/src/models/v3/mod.rs +++ b/apps/labrinth/src/models/v3/mod.rs @@ -13,6 +13,7 @@ pub mod organizations; pub mod pack; pub mod pats; pub mod payouts; +pub mod preferences; pub mod projects; pub mod reports; pub mod sessions; @@ -20,4 +21,3 @@ pub mod teams; pub mod threads; pub mod user_limits; pub mod users; -pub mod preferences; From cff880d55ddaa469478da43585f83dc1fa1ba70b Mon Sep 17 00:00:00 2001 From: sychic <47618543+Sychic@users.noreply.github.com> Date: Sun, 16 Aug 2026 17:46:34 -0400 Subject: [PATCH 17/27] refactor(labrinth): use component derive and only store overrides --- Cargo.lock | 1 + Cargo.toml | 1 + apps/labrinth/Cargo.toml | 1 + .../database/models/user_preferences_item.rs | 12 +-- apps/labrinth/src/models/v3/preferences.rs | 79 +++++++------------ apps/labrinth/src/routes/v3/users.rs | 33 +++++--- 6 files changed, 59 insertions(+), 68 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 16ef966ff0..ccab397365 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5490,6 +5490,7 @@ dependencies = [ "clickhouse", "color-eyre", "color-thief", + "component-derive", "const_format", "dashmap", "derive_more 2.1.1", diff --git a/Cargo.toml b/Cargo.toml index 18743e1d15..3b8f0dc5ee 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -68,6 +68,7 @@ clap = "4.5.48" clickhouse = "0.14.0" color-eyre = "0.6.5" color-thief = "0.2.2" +component-derive = { path = "packages/component-derive" } const_format = "0.2.34" core-foundation = "0.10.1" core-graphics = "0.24.0" diff --git a/apps/labrinth/Cargo.toml b/apps/labrinth/Cargo.toml index be818e4bc2..00ea0d1f35 100644 --- a/apps/labrinth/Cargo.toml +++ b/apps/labrinth/Cargo.toml @@ -40,6 +40,7 @@ clap = { workspace = true, features = ["derive"] } clickhouse = { workspace = true, features = ["time", "uuid"] } color-eyre = { workspace = true } color-thief = { workspace = true } +component-derive = { workspace = true } const_format = { workspace = true } dashmap = { workspace = true } derive_more = { workspace = true, features = ["deref", "deref_mut"] } diff --git a/apps/labrinth/src/database/models/user_preferences_item.rs b/apps/labrinth/src/database/models/user_preferences_item.rs index a2949f0044..6966fd9350 100644 --- a/apps/labrinth/src/database/models/user_preferences_item.rs +++ b/apps/labrinth/src/database/models/user_preferences_item.rs @@ -1,7 +1,7 @@ -use sqlx::types::Json; use crate::database::Executor; use crate::database::models::DBUserId; -use crate::models::v3::preferences::UserPreferences; +use crate::models::v3::preferences::PartialUserPreferences; +use sqlx::types::Json; pub struct DBUserPreferences; @@ -9,13 +9,13 @@ impl DBUserPreferences { pub async fn get<'a, E>( user_id: DBUserId, exec: E, - ) -> Result, sqlx::Error> + ) -> Result, sqlx::Error> where E: Executor<'a, Database = sqlx::Postgres>, { let row = sqlx::query!( r#" - SELECT preferences AS "preferences: Json" + SELECT preferences AS "preferences: Json" FROM user_preferences WHERE user_id = $1 "#, @@ -29,7 +29,7 @@ impl DBUserPreferences { pub async fn upsert<'a, E>( user_id: DBUserId, - preferences: &UserPreferences, + preferences: &PartialUserPreferences, exec: E, ) -> Result<(), sqlx::Error> where @@ -43,7 +43,7 @@ impl DBUserPreferences { SET preferences = EXCLUDED.preferences "#, user_id.0, - Json(preferences) as Json<&UserPreferences>, + Json(preferences) as Json<&PartialUserPreferences>, ) .execute(exec) .await?; diff --git a/apps/labrinth/src/models/v3/preferences.rs b/apps/labrinth/src/models/v3/preferences.rs index 39fc6a1d9b..59073a6678 100644 --- a/apps/labrinth/src/models/v3/preferences.rs +++ b/apps/labrinth/src/models/v3/preferences.rs @@ -1,58 +1,31 @@ -use partially::Partial; +use component_derive::Component; use serde::{Deserialize, Serialize}; use utoipa::ToSchema; -#[derive(Debug, Serialize, Deserialize, ToSchema, Default)] +#[derive(Debug, Serialize, Deserialize, ToSchema, Default, Component)] pub struct UserPreferences { + #[component(nested)] pub appearance: AppearancePreferences, + #[component(nested)] pub localization: LocalizationPreferences, + #[component(nested)] pub layouts: LayoutPreferences, + #[component(nested)] pub sidebars: SidebarPreferences, + #[component(nested)] pub social: SocialPreferences, } -#[derive(Debug, Deserialize, ToSchema, Default)] -pub struct PartialUserPreferences { - pub appearance: Option, - pub localization: Option, - pub layouts: Option, - pub sidebars: Option, - pub social: Option, -} - -impl Partial for UserPreferences { - type Item = PartialUserPreferences; - - fn apply_some(&mut self, partial: Self::Item) -> bool { - let mut applied = false; - - if let Some(appearance) = partial.appearance { - applied |= self.appearance.apply_some(appearance); - } - if let Some(localization) = partial.localization { - applied |= self.localization.apply_some(localization); - } - if let Some(layouts) = partial.layouts { - applied |= self.layouts.apply_some(layouts); - } - if let Some(sidebars) = partial.sidebars { - applied |= self.sidebars.apply_some(sidebars); - } - if let Some(social) = partial.social { - applied |= self.social.apply_some(social); - } - - applied - } -} - -#[derive(Debug, Serialize, Deserialize, ToSchema, Partial, Default)] -#[partially(skip_attributes, derive(Debug, Deserialize, ToSchema))] +#[derive( + Debug, Serialize, Deserialize, ToSchema, Default, PartialEq, Component, +)] pub struct AppearancePreferences { pub theme: Theme, } -#[derive(Debug, Serialize, Deserialize, ToSchema, Default)] +#[derive( + Debug, Serialize, Deserialize, ToSchema, Default, Clone, PartialEq, +)] #[serde(rename_all = "snake_case")] pub enum Theme { Light, @@ -62,8 +35,7 @@ pub enum Theme { Retro, } -#[derive(Debug, Serialize, Deserialize, ToSchema, Partial)] -#[partially(skip_attributes, derive(Debug, Deserialize, ToSchema))] +#[derive(Debug, Serialize, Deserialize, ToSchema, PartialEq, Component)] pub struct LocalizationPreferences { pub locale: String, } @@ -76,8 +48,7 @@ impl Default for LocalizationPreferences { } } -#[derive(Debug, Serialize, Deserialize, ToSchema, Partial)] -#[partially(skip_attributes, derive(Debug, Deserialize, ToSchema))] +#[derive(Debug, Serialize, Deserialize, ToSchema, PartialEq, Component)] pub struct LayoutPreferences { pub mods: LayoutOption, pub plugins: LayoutOption, @@ -104,28 +75,32 @@ impl Default for LayoutPreferences { } } -#[derive(Debug, Serialize, Deserialize, ToSchema)] +#[derive(Debug, Serialize, Deserialize, ToSchema, Clone, PartialEq)] #[serde(rename_all = "snake_case")] pub enum LayoutOption { Grid, Rows, } -#[derive(Debug, Serialize, Deserialize, ToSchema, Partial, Default)] -#[partially(skip_attributes, derive(Debug, Deserialize, ToSchema))] +#[derive( + Debug, Serialize, Deserialize, ToSchema, Default, PartialEq, Component, +)] pub struct SidebarPreferences { pub right_aligned_search: bool, pub left_aligned_content: bool, } -#[derive(Debug, Serialize, Deserialize, ToSchema, Partial, Default)] -#[partially(skip_attributes, derive(Debug, Deserialize, ToSchema))] +#[derive( + Debug, Serialize, Deserialize, ToSchema, Default, PartialEq, Component, +)] pub struct SocialPreferences { pub friend_privacy: FriendPrivacy, pub shared_instances_privacy: SharedInstancesPrivacy, } -#[derive(Debug, Serialize, Deserialize, ToSchema, Default)] +#[derive( + Debug, Serialize, Deserialize, ToSchema, Default, Clone, PartialEq, +)] #[serde(rename_all = "snake_case")] pub enum FriendPrivacy { None, @@ -134,7 +109,9 @@ pub enum FriendPrivacy { Everyone, } -#[derive(Debug, Serialize, Deserialize, ToSchema, Default)] +#[derive( + Debug, Serialize, Deserialize, ToSchema, Default, Clone, PartialEq, +)] #[serde(rename_all = "snake_case")] pub enum SharedInstancesPrivacy { None, diff --git a/apps/labrinth/src/routes/v3/users.rs b/apps/labrinth/src/routes/v3/users.rs index c159dbbe0f..dd2c646eaf 100644 --- a/apps/labrinth/src/routes/v3/users.rs +++ b/apps/labrinth/src/routes/v3/users.rs @@ -30,14 +30,11 @@ use crate::{ use actix_web::{HttpRequest, HttpResponse, delete, get, patch, web}; use ariadne::ids::UserId; use eyre::eyre; -use partially::Partial; use serde::{Deserialize, Serialize}; use validator::Validate; use crate::database::models::user_preferences_item::DBUserPreferences; -use crate::models::v3::preferences::{ - PartialUserPreferences, UserPreferences, -}; +use crate::models::v3::preferences::{PartialUserPreferences, UserPreferences}; pub fn config(cfg: &mut actix_web::web::ServiceConfig) { cfg.service(user_auth_get_route) @@ -406,9 +403,16 @@ pub async fn get_user_preferences( ))); } - let preferences = DBUserPreferences::get(target.id, &**pool) + let preference_overrides = DBUserPreferences::get(target.id, &**pool) .await - .wrap_internal_err("failed to fetch user preferences")? + .wrap_internal_err("failed to fetch user preferences")?; + + let preferences = preference_overrides + .map(|overrides| { + let mut preferences = UserPreferences::default(); + overrides.apply_to(&mut preferences); + preferences + }) .unwrap_or_default(); Ok(web::Json(preferences)) @@ -454,14 +458,18 @@ pub async fn edit_user_preferences( .await .wrap_internal_err("starting database transaction")?; - let mut preferences = DBUserPreferences::get(target.id, &mut txn) + let stored = DBUserPreferences::get(target.id, &mut txn) .await - .wrap_internal_err("failed to fetch user preferences")? - .unwrap_or_default(); + .wrap_internal_err("failed to fetch user preferences")?; - preferences.apply_some(body.into_inner()); + let mut preferences = UserPreferences::default(); + if let Some(stored) = stored { + stored.apply_to(&mut preferences); + } + body.into_inner().apply_to(&mut preferences); - DBUserPreferences::upsert(target.id, &preferences, &mut txn) + let overrides = preferences.into_diff_from(&UserPreferences::default()); + DBUserPreferences::upsert(target.id, &overrides, &mut txn) .await .wrap_internal_err("failed to update user preferences")?; @@ -469,6 +477,9 @@ pub async fn edit_user_preferences( .await .wrap_internal_err("committing database transaction")?; + let mut preferences = UserPreferences::default(); + overrides.apply_to(&mut preferences); + Ok(web::Json(preferences)) } From 733bc4ecd0ace43cae81e50afe16d0ffbdd553d0 Mon Sep 17 00:00:00 2001 From: sychic <47618543+Sychic@users.noreply.github.com> Date: Sun, 16 Aug 2026 17:58:18 -0400 Subject: [PATCH 18/27] chore: update query cache --- ...c10c329d17504267e8328df9846d35c4df149752098a750caa.json} | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) rename apps/labrinth/.sqlx/{query-e4820523f8cb324aa104dc905251e53f2285d77505d7c340c366203934e366b0.json => query-9c97cb31c02777c10c329d17504267e8328df9846d35c4df149752098a750caa.json} (55%) diff --git a/apps/labrinth/.sqlx/query-e4820523f8cb324aa104dc905251e53f2285d77505d7c340c366203934e366b0.json b/apps/labrinth/.sqlx/query-9c97cb31c02777c10c329d17504267e8328df9846d35c4df149752098a750caa.json similarity index 55% rename from apps/labrinth/.sqlx/query-e4820523f8cb324aa104dc905251e53f2285d77505d7c340c366203934e366b0.json rename to apps/labrinth/.sqlx/query-9c97cb31c02777c10c329d17504267e8328df9846d35c4df149752098a750caa.json index 7a15c67054..431861125a 100644 --- a/apps/labrinth/.sqlx/query-e4820523f8cb324aa104dc905251e53f2285d77505d7c340c366203934e366b0.json +++ b/apps/labrinth/.sqlx/query-9c97cb31c02777c10c329d17504267e8328df9846d35c4df149752098a750caa.json @@ -1,11 +1,11 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT preferences AS \"preferences: Json\"\n FROM user_preferences\n WHERE user_id = $1\n ", + "query": "\n SELECT preferences AS \"preferences: Json\"\n FROM user_preferences\n WHERE user_id = $1\n ", "describe": { "columns": [ { "ordinal": 0, - "name": "preferences: Json", + "name": "preferences: Json", "type_info": "Jsonb" } ], @@ -18,5 +18,5 @@ false ] }, - "hash": "e4820523f8cb324aa104dc905251e53f2285d77505d7c340c366203934e366b0" + "hash": "9c97cb31c02777c10c329d17504267e8328df9846d35c4df149752098a750caa" } From c215624d1c235ae099992027cf929724ba60fccc Mon Sep 17 00:00:00 2001 From: sychic <47618543+Sychic@users.noreply.github.com> Date: Sun, 16 Aug 2026 18:01:11 -0400 Subject: [PATCH 19/27] docs(labrinth): add preferences to openapi --- apps/labrinth/src/routes/v3/mod.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/labrinth/src/routes/v3/mod.rs b/apps/labrinth/src/routes/v3/mod.rs index fd2e087039..1b3d952881 100644 --- a/apps/labrinth/src/routes/v3/mod.rs +++ b/apps/labrinth/src/routes/v3/mod.rs @@ -217,6 +217,8 @@ pub fn config(cfg: &mut web::ServiceConfig) { users::user_delete_route, users::user_follows_route, users::user_notifications_route, + users::get_user_preferences, + users::edit_user_preferences, version_creation::version_create_route, version_creation::upload_file_to_version_route, version_file::get_version_from_hash_route, From b6009441e89fdaa7ebd26ad38dfe961c1a65c8c2 Mon Sep 17 00:00:00 2001 From: sychic <47618543+Sychic@users.noreply.github.com> Date: Sun, 16 Aug 2026 18:39:29 -0400 Subject: [PATCH 20/27] fix(labrinth): lock row for update --- ...69f43909750257d6b4f86cfecc89568b86cb9.json | 22 +++++++++++++++++++ .../database/models/user_preferences_item.rs | 22 +++++++++++++++++++ apps/labrinth/src/routes/v3/users.rs | 2 +- 3 files changed, 45 insertions(+), 1 deletion(-) create mode 100644 apps/labrinth/.sqlx/query-829fa6bb9dd88f401abc4b5164d69f43909750257d6b4f86cfecc89568b86cb9.json diff --git a/apps/labrinth/.sqlx/query-829fa6bb9dd88f401abc4b5164d69f43909750257d6b4f86cfecc89568b86cb9.json b/apps/labrinth/.sqlx/query-829fa6bb9dd88f401abc4b5164d69f43909750257d6b4f86cfecc89568b86cb9.json new file mode 100644 index 0000000000..7aa05f7bab --- /dev/null +++ b/apps/labrinth/.sqlx/query-829fa6bb9dd88f401abc4b5164d69f43909750257d6b4f86cfecc89568b86cb9.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT preferences AS \"preferences: Json\"\n FROM user_preferences\n WHERE user_id = $1\n FOR UPDATE\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "preferences: Json", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false + ] + }, + "hash": "829fa6bb9dd88f401abc4b5164d69f43909750257d6b4f86cfecc89568b86cb9" +} diff --git a/apps/labrinth/src/database/models/user_preferences_item.rs b/apps/labrinth/src/database/models/user_preferences_item.rs index 6966fd9350..853a6397e2 100644 --- a/apps/labrinth/src/database/models/user_preferences_item.rs +++ b/apps/labrinth/src/database/models/user_preferences_item.rs @@ -27,6 +27,28 @@ impl DBUserPreferences { Ok(row.map(|row| row.preferences.0)) } + pub async fn get_for_update<'a, E>( + user_id: DBUserId, + exec: E, + ) -> Result, sqlx::Error> + where + E: Executor<'a, Database = sqlx::Postgres>, + { + let row = sqlx::query!( + r#" + SELECT preferences AS "preferences: Json" + FROM user_preferences + WHERE user_id = $1 + FOR UPDATE + "#, + user_id.0, + ) + .fetch_optional(exec) + .await?; + + Ok(row.map(|row| row.preferences.0)) + } + pub async fn upsert<'a, E>( user_id: DBUserId, preferences: &PartialUserPreferences, diff --git a/apps/labrinth/src/routes/v3/users.rs b/apps/labrinth/src/routes/v3/users.rs index dd2c646eaf..9b7a93124b 100644 --- a/apps/labrinth/src/routes/v3/users.rs +++ b/apps/labrinth/src/routes/v3/users.rs @@ -458,7 +458,7 @@ pub async fn edit_user_preferences( .await .wrap_internal_err("starting database transaction")?; - let stored = DBUserPreferences::get(target.id, &mut txn) + let stored = DBUserPreferences::get_for_update(target.id, &mut txn) .await .wrap_internal_err("failed to fetch user preferences")?; From 769527825653d64b91de85b697165b3ee254589f Mon Sep 17 00:00:00 2001 From: sychic <47618543+Sychic@users.noreply.github.com> Date: Sun, 16 Aug 2026 18:59:51 -0400 Subject: [PATCH 21/27] remove: partially --- Cargo.lock | 22 ---------------------- Cargo.toml | 1 - apps/labrinth/Cargo.toml | 1 - 3 files changed, 24 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ccab397365..89ee75a3b7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5517,7 +5517,6 @@ dependencies = [ "muralpay", "murmur2", "neverbounce", - "partially", "paste", "path-util", "postcard", @@ -7170,27 +7169,6 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "487f2ccd1e17ce8c1bfab3a65c89525af41cfad4c8659021a1e9a2aacd73b89b" -[[package]] -name = "partially" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c662358b50ce030ff0bf5c174541e37b2e29564732c34504e8ffb952389e3c1" -dependencies = [ - "partially_derive", -] - -[[package]] -name = "partially_derive" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e53059790c7b28bb3a618a75dbfa0c6e569515d0b8d3a4df94baca76d4e14b38" -dependencies = [ - "darling 0.20.11", - "proc-macro2", - "quote", - "syn 2.0.106", -] - [[package]] name = "password-hash" version = "0.5.0" diff --git a/Cargo.toml b/Cargo.toml index 3b8f0dc5ee..783f73631a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -145,7 +145,6 @@ notify-debouncer-mini = { version = "0.7.0", default-features = false } objc2-app-kit = { version = "0.3.2", default-features = false } p256 = "0.13.2" parking_lot = "0.12.5" -partially = { version = "0.2.1", features = ["derive"] } paste = "1.0.15" path-util = { path = "packages/path-util" } phf = { version = "0.13.1", features = ["macros"] } diff --git a/apps/labrinth/Cargo.toml b/apps/labrinth/Cargo.toml index 00ea0d1f35..6c3ab65446 100644 --- a/apps/labrinth/Cargo.toml +++ b/apps/labrinth/Cargo.toml @@ -79,7 +79,6 @@ modrinth-util = { workspace = true, features = ["decimal", "sentry", "utoipa"] } muralpay = { workspace = true, features = ["client", "mock", "utoipa"] } murmur2 = { workspace = true } neverbounce = { workspace = true } -partially = { workspace = true } paste = { workspace = true } path-util = { workspace = true } postcard = { workspace = true } From 0efd585a5e58dcdf1f98315c15445a0479b2dcbf Mon Sep 17 00:00:00 2001 From: sychic <47618543+Sychic@users.noreply.github.com> Date: Mon, 17 Aug 2026 09:27:38 -0400 Subject: [PATCH 22/27] refactor(component-derive): split impls to separate functions --- packages/component-derive/src/component.rs | 195 ++++++++++++--------- 1 file changed, 116 insertions(+), 79 deletions(-) diff --git a/packages/component-derive/src/component.rs b/packages/component-derive/src/component.rs index 3f242dbad9..a0f3a04bc9 100644 --- a/packages/component-derive/src/component.rs +++ b/packages/component-derive/src/component.rs @@ -33,6 +33,8 @@ pub fn derive(input: &DeriveInput) -> Result { let fields = &fields.fields; let struct_serial = struct_serial(&vis, &ident, fields)?; let struct_partial = struct_partial(&vis, &ident, fields)?; + let impl_apply_to = impl_apply_to(&ident, fields); + let impl_into_diff_from = impl_into_diff_from(&ident, fields); // `#[validate(nested)]` needs `Validate` in scope; `as _` avoids a name clash let validate_import = if fields.iter().any(|field| field.nested) { @@ -46,6 +48,9 @@ pub fn derive(input: &DeriveInput) -> Result { #struct_serial #struct_partial + + #impl_apply_to + #impl_into_diff_from }) } @@ -111,83 +116,58 @@ fn struct_partial( ) -> Result { let ident_partial = format_ident!("Partial{ident}"); - let (fields, (apply_fields, diff_fields)): (Vec<_>, (Vec<_>, Vec<_>)) = - fields - .iter() - .filter_map(|field| { - if field.synthetic { - return None; - } + let fields = fields + .iter() + .filter_map(|field| { + if field.synthetic { + return None; + } + + let ident = &field + .ident + .as_ref() + .expect("macro only works on structs with named fields"); + let vis = &field.vis; + let ty = &field.ty; + let attrs = &field.attrs; - let ident = &field - .ident - .as_ref() - .expect("macro only works on structs with named fields"); - let vis = &field.vis; - let ty = &field.ty; - let attrs = &field.attrs; - - let (inner_ty, apply_value, diff_value, validate_attr) = - if field.nested { - let inner_ty = match nested_type(ty, "Partial") { - Ok(inner_ty) => inner_ty, - Err(err) => return Some(Err(err)), - }; - ( - inner_ty, - quote! { t.apply_to(&mut component.#ident) }, - quote! { self.#ident.into_diff_from(&base.#ident) }, - quote! { #[validate(nested)] }, - ) - } else { - ( - quote! { #ty }, - quote! { component.#ident = t }, - quote! { self.#ident }, - quote! {}, - ) - }; - - let serde_attr = if !field.nested - && let Type::Path(path) = ty - && path - .path - .segments - .first() - .is_some_and(|segment| segment.ident == "Option") - { - quote! { - #[serde( - default, - skip_serializing_if = "::core::option::Option::is_none", - with = "::serde_with::rust::double_option" - )] - } - } else { - quote! { #[serde(default, skip_serializing_if = "::core::option::Option::is_none")] } + let (inner_ty, validate_attr) = if field.nested { + let inner_ty = match nested_type(ty, "Partial") { + Ok(inner_ty) => inner_ty, + Err(err) => return Some(Err(err)), }; + (inner_ty, quote! { #[validate(nested)] }) + } else { + (quote! { #ty }, quote! {}) + }; - Some(Ok(( - quote! { - #(#attrs)* - #validate_attr - #serde_attr - #vis #ident: ::core::option::Option<#inner_ty> - }, - ( - quote! { - if let Some(t) = self.#ident { - #apply_value; - } - }, - quote! { - #ident: (self.#ident != base.#ident) - .then(|| #diff_value) - }, - ), - ))) - }) - .collect::, (Vec<_>, Vec<_>))>>()?; + let serde_attr = if !field.nested + && let Type::Path(path) = ty + && path + .path + .segments + .first() + .is_some_and(|segment| segment.ident == "Option") + { + quote! { + #[serde( + default, + skip_serializing_if = "::core::option::Option::is_none", + with = "::serde_with::rust::double_option" + )] + } + } else { + quote! { #[serde(default, skip_serializing_if = "::core::option::Option::is_none")] } + }; + + Some(Ok(quote! { + #(#attrs)* + #validate_attr + #serde_attr + #vis #ident: ::core::option::Option<#inner_ty> + })) + }) + .collect::>>()?; Ok(quote! { #[derive( @@ -201,16 +181,73 @@ fn struct_partial( #vis struct #ident_partial { #(#fields),* } + }) +} + +fn impl_apply_to(ident: &Ident, fields: &[ComponentField]) -> TokenStream { + let ident_partial = format_ident!("Partial{ident}"); + + let apply_fields = fields + .iter() + .filter_map(|field| { + if field.synthetic { + return None; + } + + let ident = field + .ident + .as_ref() + .expect("macro only works on structs with named fields"); + let apply_value = if field.nested { + quote! { t.apply_to(&mut component.#ident) } + } else { + quote! { component.#ident = t } + }; + Some(quote! { + if let Some(t) = self.#ident { + #apply_value; + } + }) + }) + .collect::>(); + + quote! { impl #ident_partial { - pub fn apply_to( - self, - component: &mut #ident, - ) { + pub fn apply_to(self, component: &mut #ident) { #(#apply_fields)* } } + } +} + +fn impl_into_diff_from(ident: &Ident, fields: &[ComponentField]) -> TokenStream { + let ident_partial = format_ident!("Partial{ident}"); + + let diff_fields = fields + .iter() + .filter_map(|field| { + if field.synthetic { + return None; + } + + let ident = field + .ident + .as_ref() + .expect("macro only works on structs with named fields"); + let diff_value = if field.nested { + quote! { self.#ident.into_diff_from(&base.#ident) } + } else { + quote! { self.#ident } + }; + Some(quote! { + #ident: (self.#ident != base.#ident).then(|| #diff_value) + }) + }) + .collect::>(); + + quote! { impl #ident { pub fn into_diff_from(self, base: &Self) -> #ident_partial { #ident_partial { @@ -218,7 +255,7 @@ fn struct_partial( } } } - }) + } } fn nested_type(ty: &Type, prefix: &str) -> Result { From b301bbf945d62efeb0fcfab46233b2ff32799278 Mon Sep 17 00:00:00 2001 From: sychic <47618543+Sychic@users.noreply.github.com> Date: Mon, 17 Aug 2026 09:36:45 -0400 Subject: [PATCH 23/27] feat(component-derive): wrap impls to isolate naming --- packages/component-derive/src/component.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/component-derive/src/component.rs b/packages/component-derive/src/component.rs index a0f3a04bc9..85cedbcf84 100644 --- a/packages/component-derive/src/component.rs +++ b/packages/component-derive/src/component.rs @@ -49,8 +49,10 @@ pub fn derive(input: &DeriveInput) -> Result { #struct_serial #struct_partial - #impl_apply_to - #impl_into_diff_from + const _: () = { + #impl_apply_to + #impl_into_diff_from + }; }) } From c8a9ca5ace7219bbc5badd878d326163c41892d8 Mon Sep 17 00:00:00 2001 From: sychic <47618543+Sychic@users.noreply.github.com> Date: Mon, 17 Aug 2026 09:53:59 -0400 Subject: [PATCH 24/27] refactor(labrinth): split out auth conditions --- apps/labrinth/src/routes/v3/users.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/labrinth/src/routes/v3/users.rs b/apps/labrinth/src/routes/v3/users.rs index 9b7a93124b..3ad73b19c6 100644 --- a/apps/labrinth/src/routes/v3/users.rs +++ b/apps/labrinth/src/routes/v3/users.rs @@ -397,7 +397,8 @@ pub async fn get_user_preferences( .wrap_internal_err("fetching user from database")? .wrap_not_found_err("resource not found")?; - if requester.id != target.id.into() && !requester.role.is_mod() { + let can_access = requester.id == target.id.into() || requester.role.is_mod(); + if !can_access { return Err(ApiError::Auth(eyre!( "you do not have permission to access this user's preferences" ))); @@ -447,7 +448,8 @@ pub async fn edit_user_preferences( .wrap_internal_err("fetching user from database")? .wrap_not_found_err("resource not found")?; - if requester.id != target.id.into() && !requester.role.is_mod() { + let can_access = requester.id == target.id.into() || requester.role.is_mod(); + if !can_access { return Err(ApiError::Auth(eyre!( "you do not have permission to access this user's preferences" ))); From 1866e29c9c61cb020c742264c436c3f903a1d396 Mon Sep 17 00:00:00 2001 From: sychic <47618543+Sychic@users.noreply.github.com> Date: Mon, 17 Aug 2026 09:56:47 -0400 Subject: [PATCH 25/27] feat(labrinth): store auto --- apps/labrinth/src/models/v3/preferences.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/labrinth/src/models/v3/preferences.rs b/apps/labrinth/src/models/v3/preferences.rs index 59073a6678..9f6c15ec32 100644 --- a/apps/labrinth/src/models/v3/preferences.rs +++ b/apps/labrinth/src/models/v3/preferences.rs @@ -20,6 +20,7 @@ pub struct UserPreferences { Debug, Serialize, Deserialize, ToSchema, Default, PartialEq, Component, )] pub struct AppearancePreferences { + pub auto: bool, pub theme: Theme, } From 72c6168b9ce9026a7a6dc92f19869723ec58a6ec Mon Sep 17 00:00:00 2001 From: sychic <47618543+Sychic@users.noreply.github.com> Date: Mon, 17 Aug 2026 09:58:21 -0400 Subject: [PATCH 26/27] feat(labrinth): store hosting privacy --- apps/labrinth/src/models/v3/preferences.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/labrinth/src/models/v3/preferences.rs b/apps/labrinth/src/models/v3/preferences.rs index 9f6c15ec32..9afbde2ed8 100644 --- a/apps/labrinth/src/models/v3/preferences.rs +++ b/apps/labrinth/src/models/v3/preferences.rs @@ -96,7 +96,8 @@ pub struct SidebarPreferences { )] pub struct SocialPreferences { pub friend_privacy: FriendPrivacy, - pub shared_instances_privacy: SharedInstancesPrivacy, + pub shared_instances_privacy: InvitePrivacy, + pub hosting_access_privacy: InvitePrivacy, } #[derive( @@ -114,7 +115,7 @@ pub enum FriendPrivacy { Debug, Serialize, Deserialize, ToSchema, Default, Clone, PartialEq, )] #[serde(rename_all = "snake_case")] -pub enum SharedInstancesPrivacy { +pub enum InvitePrivacy { None, Friends, #[default] From 1b8229d69bba6986b96d8b85680c27925acec927 Mon Sep 17 00:00:00 2001 From: sychic <47618543+Sychic@users.noreply.github.com> Date: Mon, 17 Aug 2026 13:32:33 -0400 Subject: [PATCH 27/27] style(labrinth): cargo fmt --- apps/labrinth/src/routes/v3/users.rs | 6 ++++-- packages/component-derive/src/component.rs | 5 ++++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/apps/labrinth/src/routes/v3/users.rs b/apps/labrinth/src/routes/v3/users.rs index 3ad73b19c6..840fb9e5fd 100644 --- a/apps/labrinth/src/routes/v3/users.rs +++ b/apps/labrinth/src/routes/v3/users.rs @@ -397,7 +397,8 @@ pub async fn get_user_preferences( .wrap_internal_err("fetching user from database")? .wrap_not_found_err("resource not found")?; - let can_access = requester.id == target.id.into() || requester.role.is_mod(); + let can_access = + requester.id == target.id.into() || requester.role.is_mod(); if !can_access { return Err(ApiError::Auth(eyre!( "you do not have permission to access this user's preferences" @@ -448,7 +449,8 @@ pub async fn edit_user_preferences( .wrap_internal_err("fetching user from database")? .wrap_not_found_err("resource not found")?; - let can_access = requester.id == target.id.into() || requester.role.is_mod(); + let can_access = + requester.id == target.id.into() || requester.role.is_mod(); if !can_access { return Err(ApiError::Auth(eyre!( "you do not have permission to access this user's preferences" diff --git a/packages/component-derive/src/component.rs b/packages/component-derive/src/component.rs index 85cedbcf84..17f87fde58 100644 --- a/packages/component-derive/src/component.rs +++ b/packages/component-derive/src/component.rs @@ -223,7 +223,10 @@ fn impl_apply_to(ident: &Ident, fields: &[ComponentField]) -> TokenStream { } } -fn impl_into_diff_from(ident: &Ident, fields: &[ComponentField]) -> TokenStream { +fn impl_into_diff_from( + ident: &Ident, + fields: &[ComponentField], +) -> TokenStream { let ident_partial = format_ident!("Partial{ident}"); let diff_fields = fields