mirror of
https://github.com/modrinth/code.git
synced 2026-08-25 00:55:25 +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
+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"] }
|
||||
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"] }
|
||||
|
||||
@@ -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 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;
|
||||
|
||||
@@ -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 pats;
|
||||
pub mod payouts;
|
||||
pub mod preferences;
|
||||
pub mod projects;
|
||||
pub mod reports;
|
||||
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_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,
|
||||
|
||||
@@ -29,9 +29,13 @@ use crate::{
|
||||
};
|
||||
use actix_web::{HttpRequest, HttpResponse, delete, get, patch, web};
|
||||
use ariadne::ids::UserId;
|
||||
use eyre::eyre;
|
||||
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 +53,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 +373,120 @@ 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<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)]
|
||||
pub struct UserIds {
|
||||
pub ids: String,
|
||||
|
||||
Reference in New Issue
Block a user