Compare commits

...
Author SHA1 Message Date
Calum H. (IMB11) 6a835c1b71 feat(labrinth): user preferences api draft 2026-08-03 08:07:50 +01:00
14 changed files with 656 additions and 0 deletions
Generated
+1
View File
@@ -5490,6 +5490,7 @@ dependencies = [
"jemalloc_pprof",
"json-patch 4.1.0",
"labrinth",
"language-tags",
"lettre",
"meilisearch-sdk",
"modrinth-content-management",
+1
View File
@@ -110,6 +110,7 @@ image = { version = "0.25.8", default-features = false, features = ["rayon"] }
indexmap = "2.11.4"
indicatif = "0.18.0"
itertools = "0.14.0"
language-tags = "0.3.2"
jemalloc_pprof = "0.8.1"
json-patch = { version = "4.1.0", default-features = false }
lettre = { version = "0.11.19", default-features = false, features = [
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "\n\t\t\tINSERT INTO user_preferences (user_id, preferences)\n\t\t\tVALUES ($1, $2)\n\t\t\tON CONFLICT (user_id) DO UPDATE\n\t\t\tSET preferences = EXCLUDED.preferences\n\t\t\t",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8",
"Jsonb"
]
},
"nullable": []
},
"hash": "121602731987ccfa487f46bb86bb62cd913c5760c6ac0e6d1abda818673e83f6"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "\n\t\t\tINSERT INTO user_preferences (user_id, preferences)\n\t\t\tVALUES ($1, $2)\n\t\t\tON CONFLICT (user_id) DO NOTHING\n\t\t\t",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8",
"Jsonb"
]
},
"nullable": []
},
"hash": "20616b23f6948c1efdce94e44922d09daa809cb04f6de1824c3e9b924f193ba7"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "\n\t\t\tSELECT preferences AS \"preferences: Json<UserPreferences>\"\n\t\t\tFROM user_preferences\n\t\t\tWHERE user_id = $1\n\t\t\tFOR UPDATE\n\t\t\t",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "preferences: Json<UserPreferences>",
"type_info": "Jsonb"
}
],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": [
false
]
},
"hash": "59034736065e7bec7ed838a870a5d389108e90adfee265792395af483b84091a"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "\n\t\t\tSELECT preferences AS \"preferences: Json<UserPreferences>\"\n\t\t\tFROM user_preferences\n\t\t\tWHERE user_id = $1\n\t\t\t",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "preferences: Json<UserPreferences>",
"type_info": "Jsonb"
}
],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": [
false
]
},
"hash": "674d05dc957468ad195d8704dfc66e035f5812244a7e69d81af1fa0989ffe9eb"
}
+1
View File
@@ -71,6 +71,7 @@ image = { workspace = true, features = [
"webp",
] }
itertools = { workspace = true }
language-tags = { workspace = true }
json-patch = { workspace = true }
lettre = { workspace = true }
meilisearch-sdk = { workspace = true, features = ["reqwest"] }
@@ -0,0 +1,7 @@
CREATE TABLE user_preferences (
user_id BIGINT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
preferences JSONB NOT NULL,
CONSTRAINT user_preferences_object CHECK (
jsonb_typeof(preferences) = 'object'
)
);
+2
View File
@@ -37,6 +37,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;
@@ -58,6 +59,7 @@ pub use team_item::DBTeam;
pub use team_item::DBTeamMember;
pub use thread_item::{DBThread, DBThreadMessage};
pub use user_item::DBUser;
pub use user_preferences_item::DBUserPreferences;
pub use version_item::DBVersion;
pub use moderation_lock_item::{DBModerationLock, ModerationLockWithUser};
@@ -0,0 +1,97 @@
use sqlx::types::Json;
use crate::{
database::{Executor, PgTransaction},
models::v3::user_preferences::UserPreferences,
};
use super::DBUserId;
pub struct DBUserPreferences;
impl DBUserPreferences {
pub async fn get<'a, E>(
user_id: DBUserId,
exec: E,
) -> Result<Option<UserPreferences>, sqlx::Error>
where
E: Executor<'a, Database = sqlx::Postgres>,
{
let preferences = sqlx::query_scalar!(
r#"
SELECT preferences AS "preferences: Json<UserPreferences>"
FROM user_preferences
WHERE user_id = $1
"#,
user_id.0,
)
.fetch_optional(exec)
.await?
.map(|preferences| preferences.0);
Ok(preferences)
}
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(())
}
pub async fn insert_if_absent(
user_id: DBUserId,
preferences: &UserPreferences,
transaction: &mut PgTransaction<'_>,
) -> Result<(), sqlx::Error> {
sqlx::query!(
r#"
INSERT INTO user_preferences (user_id, preferences)
VALUES ($1, $2)
ON CONFLICT (user_id) DO NOTHING
"#,
user_id.0,
Json(preferences) as Json<&UserPreferences>,
)
.execute(&mut *transaction)
.await?;
Ok(())
}
pub async fn get_for_update(
user_id: DBUserId,
transaction: &mut PgTransaction<'_>,
) -> Result<UserPreferences, sqlx::Error> {
let preferences = sqlx::query_scalar!(
r#"
SELECT preferences AS "preferences: Json<UserPreferences>"
FROM user_preferences
WHERE user_id = $1
FOR UPDATE
"#,
user_id.0,
)
.fetch_one(&mut *transaction)
.await?;
Ok(preferences.0)
}
}
+1
View File
@@ -18,4 +18,5 @@ pub mod sessions;
pub mod teams;
pub mod threads;
pub mod user_limits;
pub mod user_preferences;
pub mod users;
@@ -0,0 +1,327 @@
use eyre::{Result, eyre};
use language_tags::LanguageTag;
use serde::{Deserialize, Serialize};
#[derive(
Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema,
)]
#[serde(rename_all = "lowercase")]
pub enum ThemePreferenceValue {
Auto,
Light,
Dark,
Oled,
Retro,
}
#[derive(
Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema,
)]
#[serde(rename_all = "lowercase")]
pub enum LayoutPreferenceValue {
Grid,
Rows,
}
#[derive(
Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema,
)]
#[serde(rename_all = "lowercase")]
pub enum FriendRequestSource {
None,
Mutuals,
Everyone,
}
#[derive(
Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema,
)]
#[serde(rename_all = "lowercase")]
pub enum SharedInstanceSource {
None,
Friends,
Everyone,
}
#[derive(
Debug, Clone, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema,
)]
#[serde(deny_unknown_fields)]
pub struct ThemePreference {
pub value: ThemePreferenceValue,
pub sync: bool,
}
impl Default for ThemePreference {
fn default() -> Self {
Self {
value: ThemePreferenceValue::Auto,
sync: false,
}
}
}
#[derive(
Debug, Clone, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema,
)]
#[serde(deny_unknown_fields)]
pub struct LanguagePreference {
pub value: String,
pub sync: bool,
}
impl Default for LanguagePreference {
fn default() -> Self {
Self {
value: "en-US".to_string(),
sync: false,
}
}
}
#[derive(
Debug, Clone, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema,
)]
#[serde(deny_unknown_fields)]
pub struct LayoutPreferences {
pub sync: bool,
pub mods: LayoutPreferenceValue,
pub plugins: LayoutPreferenceValue,
pub datapacks: LayoutPreferenceValue,
pub shaders: LayoutPreferenceValue,
pub resourcepacks: LayoutPreferenceValue,
pub modpacks: LayoutPreferenceValue,
pub servers: LayoutPreferenceValue,
pub users: LayoutPreferenceValue,
}
impl Default for LayoutPreferences {
fn default() -> Self {
Self {
sync: true,
mods: LayoutPreferenceValue::Rows,
plugins: LayoutPreferenceValue::Rows,
datapacks: LayoutPreferenceValue::Rows,
shaders: LayoutPreferenceValue::Grid,
resourcepacks: LayoutPreferenceValue::Grid,
modpacks: LayoutPreferenceValue::Rows,
servers: LayoutPreferenceValue::Rows,
users: LayoutPreferenceValue::Rows,
}
}
}
#[derive(
Debug, Clone, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema,
)]
#[serde(deny_unknown_fields)]
pub struct SidebarPreferences {
pub sync: bool,
pub right_aligned_search: bool,
pub left_aligned_content: bool,
}
impl Default for SidebarPreferences {
fn default() -> Self {
Self {
sync: false,
right_aligned_search: false,
left_aligned_content: false,
}
}
}
#[derive(
Debug, Clone, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema,
)]
#[serde(deny_unknown_fields)]
pub struct SocialPreferences {
pub friend_request_sources: FriendRequestSource,
pub shared_instance_sources: SharedInstanceSource,
}
impl Default for SocialPreferences {
fn default() -> Self {
Self {
friend_request_sources: FriendRequestSource::Everyone,
shared_instance_sources: SharedInstanceSource::Friends,
}
}
}
#[derive(
Debug, Clone, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema,
)]
#[serde(deny_unknown_fields)]
pub struct UserPreferences {
pub theme: ThemePreference,
pub language: LanguagePreference,
pub layouts: LayoutPreferences,
pub sidebars: SidebarPreferences,
pub social: SocialPreferences,
}
impl Default for UserPreferences {
fn default() -> Self {
Self {
theme: ThemePreference::default(),
language: LanguagePreference::default(),
layouts: LayoutPreferences::default(),
sidebars: SidebarPreferences::default(),
social: SocialPreferences::default(),
}
}
}
impl UserPreferences {
pub fn validate(&self) -> Result<()> {
validate_language(&self.language.value)
}
pub fn apply_patch(&mut self, patch: UserPreferencesPatch) {
if let Some(theme) = patch.theme {
if let Some(value) = theme.value {
self.theme.value = value;
}
if let Some(sync) = theme.sync {
self.theme.sync = sync;
}
}
if let Some(language) = patch.language {
if let Some(value) = language.value {
self.language.value = value;
}
if let Some(sync) = language.sync {
self.language.sync = sync;
}
}
if let Some(layouts) = patch.layouts {
if let Some(sync) = layouts.sync {
self.layouts.sync = sync;
}
if let Some(mods) = layouts.mods {
self.layouts.mods = mods;
}
if let Some(plugins) = layouts.plugins {
self.layouts.plugins = plugins;
}
if let Some(datapacks) = layouts.datapacks {
self.layouts.datapacks = datapacks;
}
if let Some(shaders) = layouts.shaders {
self.layouts.shaders = shaders;
}
if let Some(resourcepacks) = layouts.resourcepacks {
self.layouts.resourcepacks = resourcepacks;
}
if let Some(modpacks) = layouts.modpacks {
self.layouts.modpacks = modpacks;
}
if let Some(servers) = layouts.servers {
self.layouts.servers = servers;
}
if let Some(users) = layouts.users {
self.layouts.users = users;
}
}
if let Some(sidebars) = patch.sidebars {
if let Some(sync) = sidebars.sync {
self.sidebars.sync = sync;
}
if let Some(right_aligned_search) = sidebars.right_aligned_search {
self.sidebars.right_aligned_search = right_aligned_search;
}
if let Some(left_aligned_content) = sidebars.left_aligned_content {
self.sidebars.left_aligned_content = left_aligned_content;
}
}
if let Some(social) = patch.social {
if let Some(friend_request_sources) = social.friend_request_sources
{
self.social.friend_request_sources = friend_request_sources;
}
if let Some(shared_instance_sources) =
social.shared_instance_sources
{
self.social.shared_instance_sources = shared_instance_sources;
}
}
}
}
#[derive(Debug, Clone, Default, Deserialize, utoipa::ToSchema)]
#[serde(deny_unknown_fields)]
pub struct UserPreferencesPatch {
pub theme: Option<ThemePreferencePatch>,
pub language: Option<LanguagePreferencePatch>,
pub layouts: Option<LayoutPreferencesPatch>,
pub sidebars: Option<SidebarPreferencesPatch>,
pub social: Option<SocialPreferencesPatch>,
}
impl UserPreferencesPatch {
pub fn validate(&self) -> Result<()> {
if let Some(language) = self
.language
.as_ref()
.and_then(|language| language.value.as_ref())
{
validate_language(language)?;
}
Ok(())
}
}
#[derive(Debug, Clone, Default, Deserialize, utoipa::ToSchema)]
#[serde(deny_unknown_fields)]
pub struct ThemePreferencePatch {
pub value: Option<ThemePreferenceValue>,
pub sync: Option<bool>,
}
#[derive(Debug, Clone, Default, Deserialize, utoipa::ToSchema)]
#[serde(deny_unknown_fields)]
pub struct LanguagePreferencePatch {
pub value: Option<String>,
pub sync: Option<bool>,
}
#[derive(Debug, Clone, Default, Deserialize, utoipa::ToSchema)]
#[serde(deny_unknown_fields)]
pub struct LayoutPreferencesPatch {
pub sync: Option<bool>,
pub mods: Option<LayoutPreferenceValue>,
pub plugins: Option<LayoutPreferenceValue>,
pub datapacks: Option<LayoutPreferenceValue>,
pub shaders: Option<LayoutPreferenceValue>,
pub resourcepacks: Option<LayoutPreferenceValue>,
pub modpacks: Option<LayoutPreferenceValue>,
pub servers: Option<LayoutPreferenceValue>,
pub users: Option<LayoutPreferenceValue>,
}
#[derive(Debug, Clone, Default, Deserialize, utoipa::ToSchema)]
#[serde(deny_unknown_fields)]
pub struct SidebarPreferencesPatch {
pub sync: Option<bool>,
pub right_aligned_search: Option<bool>,
pub left_aligned_content: Option<bool>,
}
#[derive(Debug, Clone, Default, Deserialize, utoipa::ToSchema)]
#[serde(deny_unknown_fields)]
pub struct SocialPreferencesPatch {
pub friend_request_sources: Option<FriendRequestSource>,
pub shared_instance_sources: Option<SharedInstanceSource>,
}
fn validate_language(language: &str) -> Result<()> {
LanguageTag::parse(language).map(|_| ()).map_err(|_| {
eyre!("`language.value` must be a valid BCP 47 language tag")
})
}
+5
View File
@@ -22,6 +22,7 @@ pub mod statistics;
pub mod tags;
pub mod teams;
pub mod threads;
pub mod user_preferences;
pub mod users;
pub mod version_creation;
pub mod version_file;
@@ -64,6 +65,7 @@ pub fn config(cfg: &mut web::ServiceConfig) {
.configure(tags::config)
.configure(teams::config)
.configure(threads::config)
.configure(user_preferences::config)
.configure(users::config)
.configure(version_file::config)
.configure(versions::config)
@@ -197,6 +199,9 @@ pub fn config(cfg: &mut web::ServiceConfig) {
threads::threads_get_route,
threads::thread_send_message_route,
threads::message_delete_route,
user_preferences::get_user_preferences,
user_preferences::put_user_preferences,
user_preferences::patch_user_preferences,
users::all_projects,
users::admin_user_email,
users::projects_list_route,
@@ -0,0 +1,140 @@
use actix_web::{HttpRequest, get, patch, put, web};
use xredis::RedisPool;
use crate::{
auth::get_user_from_headers,
database::{PgPool, ReadOnlyPgPool, models::DBUserPreferences},
models::{
pats::Scopes,
v3::user_preferences::{UserPreferences, UserPreferencesPatch},
},
queue::session::AuthQueue,
util::error::Context,
};
use super::ApiError;
pub fn config(cfg: &mut actix_web::web::ServiceConfig) {
cfg.service(get_user_preferences)
.service(put_user_preferences)
.service(patch_user_preferences);
}
#[utoipa::path(
tag = "users",
responses((status = OK, body = UserPreferences))
)]
#[get("/user/preferences")]
pub async fn get_user_preferences(
req: HttpRequest,
pool: web::Data<PgPool>,
ro_pool: web::Data<ReadOnlyPgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<web::Json<UserPreferences>, ApiError> {
let user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Scopes::USER_READ,
)
.await?
.1;
let preferences = DBUserPreferences::get(user.id.into(), &***ro_pool)
.await
.wrap_internal_err("failed to fetch user preferences")?
.unwrap_or_default();
Ok(web::Json(preferences))
}
#[utoipa::path(
tag = "users",
request_body = UserPreferences,
responses((status = OK, body = UserPreferences))
)]
#[put("/user/preferences")]
pub async fn put_user_preferences(
req: HttpRequest,
web::Json(preferences): web::Json<UserPreferences>,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<web::Json<UserPreferences>, ApiError> {
preferences.validate().map_err(ApiError::Request)?;
let user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Scopes::USER_WRITE,
)
.await?
.1;
DBUserPreferences::upsert(user.id.into(), &preferences, &**pool)
.await
.wrap_internal_err("failed to store user preferences")?;
Ok(web::Json(preferences))
}
#[utoipa::path(
tag = "users",
request_body = UserPreferencesPatch,
responses((status = OK, body = UserPreferences))
)]
#[patch("/user/preferences")]
pub async fn patch_user_preferences(
req: HttpRequest,
web::Json(patch): web::Json<UserPreferencesPatch>,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<web::Json<UserPreferences>, ApiError> {
patch.validate().map_err(ApiError::Request)?;
let user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Scopes::USER_WRITE,
)
.await?
.1;
let user_id = user.id.into();
let mut transaction = pool
.begin()
.await
.wrap_internal_err("failed to begin user preferences transaction")?;
DBUserPreferences::insert_if_absent(
user_id,
&UserPreferences::default(),
&mut transaction,
)
.await
.wrap_internal_err("failed to initialize user preferences")?;
let mut preferences =
DBUserPreferences::get_for_update(user_id, &mut transaction)
.await
.wrap_internal_err("failed to fetch user preferences for update")?;
preferences.apply_patch(patch);
DBUserPreferences::upsert(user_id, &preferences, &mut transaction)
.await
.wrap_internal_err("failed to store user preferences")?;
transaction
.commit()
.await
.wrap_internal_err("failed to commit user preferences transaction")?;
Ok(web::Json(preferences))
}