mirror of
https://github.com/modrinth/code.git
synced 2026-08-24 16:44:51 +00:00
feat(labrinth): user preferences (#7177)
* feat(labrinth): initial preferences structure * feat(labrinth): use partially to derive partial structs * feat(labrinth): preference defaults * fix(labrinth): derive debug * feat(labrinth): user preferences db setup * feat(labrinth): user preferences routes * fix(labrinth): nested partial structs * fix(labrinth): serialize enums as snake case * refactor(labrinth-derive): rename to component-derive * feat(component-derive): nested components * refactor(component-derive): change suffixes to prefixes * refactor(component-derive): rename edit to partial * feat(component-derive): skip serializing empty option * refactor(labrinth): wrap errors * feat(component-derive): diff function * style(labrinth): fmt * refactor(labrinth): use component derive and only store overrides * chore: update query cache * docs(labrinth): add preferences to openapi * fix(labrinth): lock row for update * remove: partially * refactor(component-derive): split impls to separate functions * feat(component-derive): wrap impls to isolate naming * refactor(labrinth): split out auth conditions * feat(labrinth): store auto * feat(labrinth): store hosting privacy * style(labrinth): cargo fmt
This commit is contained in:
Generated
+1
@@ -28,6 +28,7 @@
|
|||||||
<sourceFolder url="file://$MODULE_DIR$/packages/sqlx-tracing/src" isTestSource="false" />
|
<sourceFolder url="file://$MODULE_DIR$/packages/sqlx-tracing/src" isTestSource="false" />
|
||||||
<sourceFolder url="file://$MODULE_DIR$/packages/sqlx-tracing/tests" isTestSource="true" />
|
<sourceFolder url="file://$MODULE_DIR$/packages/sqlx-tracing/tests" isTestSource="true" />
|
||||||
<sourceFolder url="file://$MODULE_DIR$/packages/xredis/src" isTestSource="false" />
|
<sourceFolder url="file://$MODULE_DIR$/packages/xredis/src" isTestSource="false" />
|
||||||
|
<sourceFolder url="file://$MODULE_DIR$/packages/component-derive/src" isTestSource="false" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/target" />
|
<excludeFolder url="file://$MODULE_DIR$/target" />
|
||||||
</content>
|
</content>
|
||||||
<orderEntry type="inheritedJdk" />
|
<orderEntry type="inheritedJdk" />
|
||||||
|
|||||||
Generated
+11
-10
@@ -2223,6 +2223,16 @@ dependencies = [
|
|||||||
"static_assertions",
|
"static_assertions",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "component-derive"
|
||||||
|
version = "0.0.0"
|
||||||
|
dependencies = [
|
||||||
|
"darling 0.23.0",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.106",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "compression-codecs"
|
name = "compression-codecs"
|
||||||
version = "0.4.31"
|
version = "0.4.31"
|
||||||
@@ -5480,6 +5490,7 @@ dependencies = [
|
|||||||
"clickhouse",
|
"clickhouse",
|
||||||
"color-eyre",
|
"color-eyre",
|
||||||
"color-thief",
|
"color-thief",
|
||||||
|
"component-derive",
|
||||||
"const_format",
|
"const_format",
|
||||||
"dashmap",
|
"dashmap",
|
||||||
"derive_more 2.1.1",
|
"derive_more 2.1.1",
|
||||||
@@ -5557,16 +5568,6 @@ dependencies = [
|
|||||||
"zxcvbn",
|
"zxcvbn",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "labrinth-derive"
|
|
||||||
version = "0.0.0"
|
|
||||||
dependencies = [
|
|
||||||
"darling 0.23.0",
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"syn 2.0.106",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "language-tags"
|
name = "language-tags"
|
||||||
version = "0.3.2"
|
version = "0.3.2"
|
||||||
|
|||||||
+2
-1
@@ -7,8 +7,8 @@ members = [
|
|||||||
"apps/labrinth",
|
"apps/labrinth",
|
||||||
"packages/app-lib",
|
"packages/app-lib",
|
||||||
"packages/ariadne",
|
"packages/ariadne",
|
||||||
|
"packages/component-derive",
|
||||||
"packages/daedalus",
|
"packages/daedalus",
|
||||||
"packages/labrinth-derive",
|
|
||||||
"packages/modrinth-content-management",
|
"packages/modrinth-content-management",
|
||||||
"packages/modrinth-log",
|
"packages/modrinth-log",
|
||||||
"packages/modrinth-maxmind",
|
"packages/modrinth-maxmind",
|
||||||
@@ -68,6 +68,7 @@ clap = "4.5.48"
|
|||||||
clickhouse = "0.14.0"
|
clickhouse = "0.14.0"
|
||||||
color-eyre = "0.6.5"
|
color-eyre = "0.6.5"
|
||||||
color-thief = "0.2.2"
|
color-thief = "0.2.2"
|
||||||
|
component-derive = { path = "packages/component-derive" }
|
||||||
const_format = "0.2.34"
|
const_format = "0.2.34"
|
||||||
core-foundation = "0.10.1"
|
core-foundation = "0.10.1"
|
||||||
core-graphics = "0.24.0"
|
core-graphics = "0.24.0"
|
||||||
|
|||||||
Generated
+22
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "\n SELECT preferences AS \"preferences: Json<PartialUserPreferences>\"\n FROM user_preferences\n WHERE user_id = $1\n FOR UPDATE\n ",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "preferences: Json<PartialUserPreferences>",
|
||||||
|
"type_info": "Jsonb"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Int8"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "829fa6bb9dd88f401abc4b5164d69f43909750257d6b4f86cfecc89568b86cb9"
|
||||||
|
}
|
||||||
Generated
+22
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "\n SELECT preferences AS \"preferences: Json<PartialUserPreferences>\"\n FROM user_preferences\n WHERE user_id = $1\n ",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "preferences: Json<PartialUserPreferences>",
|
||||||
|
"type_info": "Jsonb"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Int8"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "9c97cb31c02777c10c329d17504267e8328df9846d35c4df149752098a750caa"
|
||||||
|
}
|
||||||
Generated
+15
@@ -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"
|
||||||
|
}
|
||||||
@@ -40,6 +40,7 @@ clap = { workspace = true, features = ["derive"] }
|
|||||||
clickhouse = { workspace = true, features = ["time", "uuid"] }
|
clickhouse = { workspace = true, features = ["time", "uuid"] }
|
||||||
color-eyre = { workspace = true }
|
color-eyre = { workspace = true }
|
||||||
color-thief = { workspace = true }
|
color-thief = { workspace = true }
|
||||||
|
component-derive = { workspace = true }
|
||||||
const_format = { workspace = true }
|
const_format = { workspace = true }
|
||||||
dashmap = { workspace = true }
|
dashmap = { workspace = true }
|
||||||
derive_more = { workspace = true, features = ["deref", "deref_mut"] }
|
derive_more = { workspace = true, features = ["deref", "deref_mut"] }
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
CREATE TABLE user_preferences (
|
||||||
|
user_id BIGINT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
preferences JSONB NOT NULL
|
||||||
|
);
|
||||||
@@ -38,6 +38,7 @@ pub mod team_item;
|
|||||||
pub mod thread_item;
|
pub mod thread_item;
|
||||||
pub mod user_item;
|
pub mod user_item;
|
||||||
pub mod user_limits;
|
pub mod user_limits;
|
||||||
|
pub mod user_preferences_item;
|
||||||
pub mod user_subscription_item;
|
pub mod user_subscription_item;
|
||||||
pub mod users_compliance;
|
pub mod users_compliance;
|
||||||
pub mod users_notifications_preferences_item;
|
pub mod users_notifications_preferences_item;
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
use crate::database::Executor;
|
||||||
|
use crate::database::models::DBUserId;
|
||||||
|
use crate::models::v3::preferences::PartialUserPreferences;
|
||||||
|
use sqlx::types::Json;
|
||||||
|
|
||||||
|
pub struct DBUserPreferences;
|
||||||
|
|
||||||
|
impl DBUserPreferences {
|
||||||
|
pub async fn get<'a, E>(
|
||||||
|
user_id: DBUserId,
|
||||||
|
exec: E,
|
||||||
|
) -> Result<Option<PartialUserPreferences>, sqlx::Error>
|
||||||
|
where
|
||||||
|
E: Executor<'a, Database = sqlx::Postgres>,
|
||||||
|
{
|
||||||
|
let row = sqlx::query!(
|
||||||
|
r#"
|
||||||
|
SELECT preferences AS "preferences: Json<PartialUserPreferences>"
|
||||||
|
FROM user_preferences
|
||||||
|
WHERE user_id = $1
|
||||||
|
"#,
|
||||||
|
user_id.0,
|
||||||
|
)
|
||||||
|
.fetch_optional(exec)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(row.map(|row| row.preferences.0))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_for_update<'a, E>(
|
||||||
|
user_id: DBUserId,
|
||||||
|
exec: E,
|
||||||
|
) -> Result<Option<PartialUserPreferences>, sqlx::Error>
|
||||||
|
where
|
||||||
|
E: Executor<'a, Database = sqlx::Postgres>,
|
||||||
|
{
|
||||||
|
let row = sqlx::query!(
|
||||||
|
r#"
|
||||||
|
SELECT preferences AS "preferences: Json<PartialUserPreferences>"
|
||||||
|
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,
|
||||||
|
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<&PartialUserPreferences>,
|
||||||
|
)
|
||||||
|
.execute(exec)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ pub mod organizations;
|
|||||||
pub mod pack;
|
pub mod pack;
|
||||||
pub mod pats;
|
pub mod pats;
|
||||||
pub mod payouts;
|
pub mod payouts;
|
||||||
|
pub mod preferences;
|
||||||
pub mod projects;
|
pub mod projects;
|
||||||
pub mod reports;
|
pub mod reports;
|
||||||
pub mod sessions;
|
pub mod sessions;
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
use component_derive::Component;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use utoipa::ToSchema;
|
||||||
|
|
||||||
|
#[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, Serialize, Deserialize, ToSchema, Default, PartialEq, Component,
|
||||||
|
)]
|
||||||
|
pub struct AppearancePreferences {
|
||||||
|
pub auto: bool,
|
||||||
|
pub theme: Theme,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(
|
||||||
|
Debug, Serialize, Deserialize, ToSchema, Default, Clone, PartialEq,
|
||||||
|
)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum Theme {
|
||||||
|
Light,
|
||||||
|
#[default]
|
||||||
|
Dark,
|
||||||
|
Oled,
|
||||||
|
Retro,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, ToSchema, PartialEq, Component)]
|
||||||
|
pub struct LocalizationPreferences {
|
||||||
|
pub locale: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for LocalizationPreferences {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
locale: "en-US".to_owned(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, ToSchema, PartialEq, Component)]
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
|
||||||
|
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(Debug, Serialize, Deserialize, ToSchema, Clone, PartialEq)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum LayoutOption {
|
||||||
|
Grid,
|
||||||
|
Rows,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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, Default, PartialEq, Component,
|
||||||
|
)]
|
||||||
|
pub struct SocialPreferences {
|
||||||
|
pub friend_privacy: FriendPrivacy,
|
||||||
|
pub shared_instances_privacy: InvitePrivacy,
|
||||||
|
pub hosting_access_privacy: InvitePrivacy,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(
|
||||||
|
Debug, Serialize, Deserialize, ToSchema, Default, Clone, PartialEq,
|
||||||
|
)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum FriendPrivacy {
|
||||||
|
None,
|
||||||
|
Mutual,
|
||||||
|
#[default]
|
||||||
|
Everyone,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(
|
||||||
|
Debug, Serialize, Deserialize, ToSchema, Default, Clone, PartialEq,
|
||||||
|
)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum InvitePrivacy {
|
||||||
|
None,
|
||||||
|
Friends,
|
||||||
|
#[default]
|
||||||
|
Everyone,
|
||||||
|
}
|
||||||
@@ -217,6 +217,8 @@ pub fn config(cfg: &mut web::ServiceConfig) {
|
|||||||
users::user_delete_route,
|
users::user_delete_route,
|
||||||
users::user_follows_route,
|
users::user_follows_route,
|
||||||
users::user_notifications_route,
|
users::user_notifications_route,
|
||||||
|
users::get_user_preferences,
|
||||||
|
users::edit_user_preferences,
|
||||||
version_creation::version_create_route,
|
version_creation::version_create_route,
|
||||||
version_creation::upload_file_to_version_route,
|
version_creation::upload_file_to_version_route,
|
||||||
version_file::get_version_from_hash_route,
|
version_file::get_version_from_hash_route,
|
||||||
|
|||||||
@@ -29,9 +29,13 @@ use crate::{
|
|||||||
};
|
};
|
||||||
use actix_web::{HttpRequest, HttpResponse, delete, get, patch, web};
|
use actix_web::{HttpRequest, HttpResponse, delete, get, patch, web};
|
||||||
use ariadne::ids::UserId;
|
use ariadne::ids::UserId;
|
||||||
|
use eyre::eyre;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use validator::Validate;
|
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) {
|
pub fn config(cfg: &mut actix_web::web::ServiceConfig) {
|
||||||
cfg.service(user_auth_get_route)
|
cfg.service(user_auth_get_route)
|
||||||
.service(users_get_route)
|
.service(users_get_route)
|
||||||
@@ -49,6 +53,8 @@ pub fn config(cfg: &mut actix_web::web::ServiceConfig) {
|
|||||||
.service(user_delete_route)
|
.service(user_delete_route)
|
||||||
.service(user_follows_route)
|
.service(user_follows_route)
|
||||||
.service(user_notifications_route)
|
.service(user_notifications_route)
|
||||||
|
.service(get_user_preferences)
|
||||||
|
.service(edit_user_preferences)
|
||||||
.service(get_user_clients);
|
.service(get_user_clients);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -367,6 +373,120 @@ pub async fn user_auth_get(
|
|||||||
Ok(HttpResponse::Ok().json(user))
|
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<PgPool>,
|
||||||
|
redis: web::Data<RedisPool>,
|
||||||
|
session_queue: web::Data<AuthQueue>,
|
||||||
|
) -> Result<web::Json<UserPreferences>, ApiError> {
|
||||||
|
let (_, requester) = get_user_from_headers(
|
||||||
|
&req,
|
||||||
|
&**pool,
|
||||||
|
&redis,
|
||||||
|
&session_queue,
|
||||||
|
Scopes::USER_READ,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.wrap_auth_err("authenticating API request")?;
|
||||||
|
|
||||||
|
let target = DBUser::get(&info.into_inner().0, &**pool, &redis)
|
||||||
|
.await
|
||||||
|
.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();
|
||||||
|
if !can_access {
|
||||||
|
return Err(ApiError::Auth(eyre!(
|
||||||
|
"you do not have permission to access this user's preferences"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let preference_overrides = DBUserPreferences::get(target.id, &**pool)
|
||||||
|
.await
|
||||||
|
.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))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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<PgPool>,
|
||||||
|
redis: web::Data<RedisPool>,
|
||||||
|
session_queue: web::Data<AuthQueue>,
|
||||||
|
body: web::Json<PartialUserPreferences>,
|
||||||
|
) -> Result<web::Json<UserPreferences>, ApiError> {
|
||||||
|
let (_, requester) = get_user_from_headers(
|
||||||
|
&req,
|
||||||
|
&**pool,
|
||||||
|
&redis,
|
||||||
|
&session_queue,
|
||||||
|
Scopes::USER_WRITE,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.wrap_auth_err("authenticating API request")?;
|
||||||
|
|
||||||
|
let target = DBUser::get(&info.into_inner().0, &**pool, &redis)
|
||||||
|
.await
|
||||||
|
.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();
|
||||||
|
if !can_access {
|
||||||
|
return Err(ApiError::Auth(eyre!(
|
||||||
|
"you do not have permission to access this user's preferences"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut txn = pool
|
||||||
|
.begin()
|
||||||
|
.await
|
||||||
|
.wrap_internal_err("starting database transaction")?;
|
||||||
|
|
||||||
|
let stored = DBUserPreferences::get_for_update(target.id, &mut txn)
|
||||||
|
.await
|
||||||
|
.wrap_internal_err("failed to fetch user preferences")?;
|
||||||
|
|
||||||
|
let mut preferences = UserPreferences::default();
|
||||||
|
if let Some(stored) = stored {
|
||||||
|
stored.apply_to(&mut preferences);
|
||||||
|
}
|
||||||
|
body.into_inner().apply_to(&mut preferences);
|
||||||
|
|
||||||
|
let overrides = preferences.into_diff_from(&UserPreferences::default());
|
||||||
|
DBUserPreferences::upsert(target.id, &overrides, &mut txn)
|
||||||
|
.await
|
||||||
|
.wrap_internal_err("failed to update user preferences")?;
|
||||||
|
|
||||||
|
txn.commit()
|
||||||
|
.await
|
||||||
|
.wrap_internal_err("committing database transaction")?;
|
||||||
|
|
||||||
|
let mut preferences = UserPreferences::default();
|
||||||
|
overrides.apply_to(&mut preferences);
|
||||||
|
|
||||||
|
Ok(web::Json(preferences))
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize)]
|
#[derive(Serialize, Deserialize)]
|
||||||
pub struct UserIds {
|
pub struct UserIds {
|
||||||
pub ids: String,
|
pub ids: String,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "labrinth-derive"
|
name = "component-derive"
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
rust-version.workspace = true
|
rust-version.workspace = true
|
||||||
repository.workspace = true
|
repository.workspace = true
|
||||||
@@ -0,0 +1,279 @@
|
|||||||
|
use darling::{FromDeriveInput, FromField};
|
||||||
|
use proc_macro2::TokenStream;
|
||||||
|
use quote::{format_ident, quote};
|
||||||
|
use syn::{Attribute, DeriveInput, Error, Ident, Result, Type, Visibility};
|
||||||
|
|
||||||
|
#[derive(Debug, FromDeriveInput)]
|
||||||
|
#[darling(supports(struct_named))]
|
||||||
|
struct Component {
|
||||||
|
ident: Ident,
|
||||||
|
vis: Visibility,
|
||||||
|
data: darling::ast::Data<(), ComponentField>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, FromField)]
|
||||||
|
#[darling(attributes(component), forward_attrs)]
|
||||||
|
struct ComponentField {
|
||||||
|
ident: Option<Ident>,
|
||||||
|
vis: Visibility,
|
||||||
|
ty: Type,
|
||||||
|
attrs: Vec<Attribute>,
|
||||||
|
#[darling(default)]
|
||||||
|
synthetic: bool,
|
||||||
|
#[darling(default)]
|
||||||
|
nested: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn derive(input: &DeriveInput) -> Result<TokenStream> {
|
||||||
|
let Component { ident, vis, data } = Component::from_derive_input(input)?;
|
||||||
|
let fields = data
|
||||||
|
.take_struct()
|
||||||
|
.expect("macro only works on structs with named fields");
|
||||||
|
|
||||||
|
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) {
|
||||||
|
quote! { use validator::Validate as _; }
|
||||||
|
} else {
|
||||||
|
quote! {}
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(quote! {
|
||||||
|
#validate_import
|
||||||
|
|
||||||
|
#struct_serial
|
||||||
|
#struct_partial
|
||||||
|
|
||||||
|
const _: () = {
|
||||||
|
#impl_apply_to
|
||||||
|
#impl_into_diff_from
|
||||||
|
};
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn struct_serial(
|
||||||
|
vis: &Visibility,
|
||||||
|
ident: &Ident,
|
||||||
|
fields: &[ComponentField],
|
||||||
|
) -> Result<TokenStream> {
|
||||||
|
let ident_serial = format_ident!("Serial{ident}");
|
||||||
|
|
||||||
|
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 (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)*
|
||||||
|
#validate_attr
|
||||||
|
#vis #ident: #field_ty
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
.collect::<Result<Vec<_>>>()?;
|
||||||
|
|
||||||
|
Ok(quote! {
|
||||||
|
#[derive(
|
||||||
|
Debug,
|
||||||
|
Clone,
|
||||||
|
::serde::Serialize,
|
||||||
|
::serde::Deserialize,
|
||||||
|
::validator::Validate,
|
||||||
|
::utoipa::ToSchema,
|
||||||
|
)]
|
||||||
|
#vis struct #ident_serial {
|
||||||
|
#(#fields),*
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn struct_partial(
|
||||||
|
vis: &Visibility,
|
||||||
|
ident: &Ident,
|
||||||
|
fields: &[ComponentField],
|
||||||
|
) -> Result<TokenStream> {
|
||||||
|
let ident_partial = format_ident!("Partial{ident}");
|
||||||
|
|
||||||
|
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 (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! {})
|
||||||
|
};
|
||||||
|
|
||||||
|
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::<Result<Vec<_>>>()?;
|
||||||
|
|
||||||
|
Ok(quote! {
|
||||||
|
#[derive(
|
||||||
|
Debug,
|
||||||
|
Clone,
|
||||||
|
::serde::Serialize,
|
||||||
|
::serde::Deserialize,
|
||||||
|
::validator::Validate,
|
||||||
|
::utoipa::ToSchema,
|
||||||
|
)]
|
||||||
|
#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::<Vec<_>>();
|
||||||
|
|
||||||
|
quote! {
|
||||||
|
impl #ident_partial {
|
||||||
|
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::<Vec<_>>();
|
||||||
|
|
||||||
|
quote! {
|
||||||
|
impl #ident {
|
||||||
|
pub fn into_diff_from(self, base: &Self) -> #ident_partial {
|
||||||
|
#ident_partial {
|
||||||
|
#(#diff_fields),*
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn nested_type(ty: &Type, prefix: &str) -> Result<TokenStream> {
|
||||||
|
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!("{}{}", prefix, segment.ident);
|
||||||
|
Ok(quote! { #nested })
|
||||||
|
} else {
|
||||||
|
Err(Error::new_spanned(
|
||||||
|
ty,
|
||||||
|
"nested component fields must be a named path type",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,161 +0,0 @@
|
|||||||
use darling::{FromDeriveInput, FromField};
|
|
||||||
use proc_macro2::TokenStream;
|
|
||||||
use quote::{format_ident, quote};
|
|
||||||
use syn::{Attribute, DeriveInput, Ident, Result, Type, Visibility};
|
|
||||||
|
|
||||||
#[derive(Debug, FromDeriveInput)]
|
|
||||||
#[darling(supports(struct_named))]
|
|
||||||
struct Component {
|
|
||||||
ident: Ident,
|
|
||||||
vis: Visibility,
|
|
||||||
data: darling::ast::Data<(), ComponentField>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, FromField)]
|
|
||||||
#[darling(attributes(component), forward_attrs)]
|
|
||||||
struct ComponentField {
|
|
||||||
ident: Option<Ident>,
|
|
||||||
vis: Visibility,
|
|
||||||
ty: Type,
|
|
||||||
attrs: Vec<Attribute>,
|
|
||||||
#[darling(default)]
|
|
||||||
synthetic: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn derive(input: &DeriveInput) -> Result<TokenStream> {
|
|
||||||
let Component { ident, vis, data } = Component::from_derive_input(input)?;
|
|
||||||
let fields = data
|
|
||||||
.take_struct()
|
|
||||||
.expect("macro only works on structs with named fields");
|
|
||||||
|
|
||||||
let fields = &fields.fields;
|
|
||||||
let struct_serial = struct_serial(&vis, &ident, fields)?;
|
|
||||||
let struct_edit = struct_edit(&vis, &ident, fields)?;
|
|
||||||
|
|
||||||
Ok(quote! {
|
|
||||||
#struct_serial
|
|
||||||
#struct_edit
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn struct_serial(
|
|
||||||
vis: &Visibility,
|
|
||||||
ident: &Ident,
|
|
||||||
fields: &[ComponentField],
|
|
||||||
) -> Result<TokenStream> {
|
|
||||||
let ident_serial = format_ident!("{ident}Serial");
|
|
||||||
|
|
||||||
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;
|
|
||||||
|
|
||||||
Some(quote! {
|
|
||||||
#(#attrs)*
|
|
||||||
#vis #ident: #ty
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
|
|
||||||
Ok(quote! {
|
|
||||||
#[derive(
|
|
||||||
Debug,
|
|
||||||
Clone,
|
|
||||||
::serde::Serialize,
|
|
||||||
::serde::Deserialize,
|
|
||||||
::validator::Validate,
|
|
||||||
::utoipa::ToSchema,
|
|
||||||
)]
|
|
||||||
#vis struct #ident_serial {
|
|
||||||
#(#fields),*
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn struct_edit(
|
|
||||||
vis: &Visibility,
|
|
||||||
ident: &Ident,
|
|
||||||
fields: &[ComponentField],
|
|
||||||
) -> Result<TokenStream> {
|
|
||||||
let ident_edit = format_ident!("{ident}Edit");
|
|
||||||
|
|
||||||
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 serde_attr = if let Type::Path(path) = ty
|
|
||||||
&& let Some(root_ident) = path.path.segments.first()
|
|
||||||
&& root_ident.ident == "Option"
|
|
||||||
{
|
|
||||||
quote! {
|
|
||||||
#[serde(
|
|
||||||
default,
|
|
||||||
skip_serializing_if = "::core::option::Option::is_none",
|
|
||||||
with = "::serde_with::rust::double_option"
|
|
||||||
)]
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
quote! {
|
|
||||||
#[serde(default)]
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
Some((
|
|
||||||
quote! {
|
|
||||||
#(#attrs)*
|
|
||||||
#serde_attr
|
|
||||||
#vis #ident: ::core::option::Option<#ty>
|
|
||||||
},
|
|
||||||
quote! {
|
|
||||||
if let Some(t) = self.#ident {
|
|
||||||
component.#ident = t;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
))
|
|
||||||
})
|
|
||||||
.unzip();
|
|
||||||
|
|
||||||
Ok(quote! {
|
|
||||||
#[derive(
|
|
||||||
Debug,
|
|
||||||
Clone,
|
|
||||||
::serde::Serialize,
|
|
||||||
::serde::Deserialize,
|
|
||||||
::validator::Validate,
|
|
||||||
::utoipa::ToSchema,
|
|
||||||
)]
|
|
||||||
#vis struct #ident_edit {
|
|
||||||
#(#fields),*
|
|
||||||
}
|
|
||||||
|
|
||||||
impl #ident_edit {
|
|
||||||
pub fn apply_to(
|
|
||||||
self,
|
|
||||||
component: &mut #ident,
|
|
||||||
) {
|
|
||||||
#(#apply_fields)*
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user