diff --git a/CHANGELOG.md b/CHANGELOG.md index faaf6a1..2e350b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Created API endpoint for adding aliases. - Populate `alsoKnownAs` property on actor object with declared aliases. - Support account migration from Mastodon. +- Created API endpoint for managing client configurations. ### Changed diff --git a/docs/openapi.yaml b/docs/openapi.yaml index a9c432b..f092044 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -780,6 +780,29 @@ paths: type: array items: $ref: '#/components/schemas/Notification' + /api/v1/settings/client_config: + post: + summary: Update client configuration. + security: + - tokenAuth: [] + requestBody: + content: + application/json: + schema: + description: | + Client configuration. + Should contain a single key identifying type of client. + type: object + example: {"mitra-web":{"theme":"dark"}} + responses: + 200: + description: Successful operation. + content: + application/json: + schema: + $ref: '#/components/schemas/CredentialAccount' + 400: + description: Invalid request data. /api/v1/settings/change_password: post: summary: Set or change user's password. @@ -1504,6 +1527,10 @@ components: role: description: The role assigned to the currently authorized user. $ref: '#/components/schemas/Role' + client_config: + description: Client configurations. + type: object + example: {"mitra-web":{"theme":"dark"}} ActivityParameters: type: object properties: diff --git a/mitra-models/migrations/V0053__user_account__client_config.sql b/mitra-models/migrations/V0053__user_account__client_config.sql new file mode 100644 index 0000000..10b2a4b --- /dev/null +++ b/mitra-models/migrations/V0053__user_account__client_config.sql @@ -0,0 +1 @@ +ALTER TABLE user_account ADD COLUMN client_config JSONB NOT NULL DEFAULT '{}'; diff --git a/mitra-models/migrations/schema.sql b/mitra-models/migrations/schema.sql index e616573..9ce38e3 100644 --- a/mitra-models/migrations/schema.sql +++ b/mitra-models/migrations/schema.sql @@ -58,6 +58,7 @@ CREATE TABLE user_account ( private_key TEXT NOT NULL, invite_code VARCHAR(100) UNIQUE REFERENCES user_invite_code (code) ON DELETE SET NULL, user_role SMALLINT NOT NULL, + client_config JSONB NOT NULL DEFAULT '{}', created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now() ); diff --git a/mitra-models/src/users/queries.rs b/mitra-models/src/users/queries.rs index e5f1ad1..7099136 100644 --- a/mitra-models/src/users/queries.rs +++ b/mitra-models/src/users/queries.rs @@ -1,3 +1,4 @@ +use serde_json::{Value as JsonValue}; use uuid::Uuid; use mitra_utils::{ @@ -17,6 +18,8 @@ use crate::profiles::{ }; use super::types::{ + ClientConfig, + DbClientConfig, DbInviteCode, DbUser, Role, @@ -189,6 +192,26 @@ pub async fn set_user_role( Ok(()) } +pub async fn update_client_config( + db_client: &impl DatabaseClient, + user_id: &Uuid, + client_name: &str, + client_config_value: &JsonValue, +) -> Result { + let maybe_row = db_client.query_opt( + " + UPDATE user_account + SET client_config = jsonb_set(client_config, ARRAY[$1], $2, true) + WHERE id = $3 + RETURNING client_config + ", + &[&client_name, &client_config_value, &user_id], + ).await?; + let row = maybe_row.ok_or(DatabaseError::NotFound("user"))?; + let client_config: DbClientConfig = row.try_get("client_config")?; + Ok(client_config.into_inner()) +} + pub async fn get_user_by_id( db_client: &impl DatabaseClient, user_id: &Uuid, @@ -308,6 +331,7 @@ pub async fn get_user_count( #[cfg(test)] mod tests { + use serde_json::json; use serial_test::serial; use crate::database::test_utils::create_test_database; use crate::users::types::Role; @@ -362,4 +386,25 @@ mod tests { let user = get_user_by_id(db_client, &user.id).await.unwrap(); assert_eq!(user.role, Role::ReadOnlyUser); } + + #[tokio::test] + #[serial] + async fn test_update_client_config() { + let db_client = &mut create_test_database().await; + let user_data = UserCreateData::default(); + let user = create_user(db_client, user_data).await.unwrap(); + assert_eq!(user.client_config.is_empty(), true); + let client_name = "test"; + let client_config_value = json!({"a": 1}); + let client_config = update_client_config( + db_client, + &user.id, + client_name, + &client_config_value, + ).await.unwrap(); + assert_eq!( + client_config.get(client_name).unwrap(), + &client_config_value, + ); + } } diff --git a/mitra-models/src/users/types.rs b/mitra-models/src/users/types.rs index 06d8bb2..d945c77 100644 --- a/mitra-models/src/users/types.rs +++ b/mitra-models/src/users/types.rs @@ -1,5 +1,9 @@ +use std::collections::HashMap; + use chrono::{DateTime, Utc}; use postgres_types::FromSql; +use serde::Deserialize; +use serde_json::{Value as JsonValue}; use uuid::Uuid; use mitra_utils::{ @@ -9,6 +13,7 @@ use mitra_utils::{ use crate::database::{ int_enum::{int_enum_from_sql, int_enum_to_sql}, + json_macro::json_from_sql, DatabaseTypeError, }; use crate::profiles::types::DbActorProfile; @@ -100,6 +105,20 @@ impl TryFrom for Role { int_enum_from_sql!(Role); int_enum_to_sql!(Role); +pub type ClientConfig = HashMap; + +#[derive(Deserialize)] +pub struct DbClientConfig(ClientConfig); + +impl DbClientConfig { + pub fn into_inner(self) -> ClientConfig { + let Self(client_config) = self; + client_config + } +} + +json_from_sql!(DbClientConfig); + #[allow(dead_code)] #[derive(FromSql)] #[postgres(name = "user_account")] @@ -110,6 +129,7 @@ pub struct DbUser { private_key: String, invite_code: Option, user_role: Role, + client_config: DbClientConfig, created_at: DateTime, } @@ -122,6 +142,7 @@ pub struct User { pub password_hash: Option, pub private_key: String, pub role: Role, + pub client_config: ClientConfig, pub profile: DbActorProfile, } @@ -137,6 +158,7 @@ impl User { password_hash: db_user.password_hash, private_key: db_user.private_key, role: db_user.user_role, + client_config: db_user.client_config.into_inner(), profile: db_profile, } } diff --git a/src/mastodon_api/accounts/types.rs b/src/mastodon_api/accounts/types.rs index 977aea4..200bc41 100644 --- a/src/mastodon_api/accounts/types.rs +++ b/src/mastodon_api/accounts/types.rs @@ -14,8 +14,9 @@ use mitra_models::{ }, subscriptions::types::Subscription, users::types::{ - Role, + ClientConfig, Permission, + Role, User, }, }; @@ -126,6 +127,7 @@ pub struct Account { // CredentialAccount attributes pub source: Option, pub role: Option, + pub client_config: Option, } impl Account { @@ -220,6 +222,7 @@ impl Account { statuses_count: profile.post_count, source: None, role: None, + client_config: None, } } @@ -248,6 +251,7 @@ impl Account { ); account.source = Some(source); account.role = Some(role); + account.client_config = Some(user.client_config); account } } diff --git a/src/mastodon_api/settings/views.rs b/src/mastodon_api/settings/views.rs index 4595280..311151a 100644 --- a/src/mastodon_api/settings/views.rs +++ b/src/mastodon_api/settings/views.rs @@ -18,7 +18,11 @@ use mitra_models::{ update_profile, }, profiles::types::ProfileUpdateData, - users::queries::set_user_password, + users::queries::{ + set_user_password, + update_client_config, + }, + users::types::ClientConfig, }; use mitra_utils::passwords::hash_password; @@ -48,6 +52,37 @@ use super::types::{ PasswordChangeRequest, }; +// Similar to Pleroma settings store +// https://docs-develop.pleroma.social/backend/development/API/differences_in_mastoapi_responses/#pleroma-settings-store +#[post("/client_config")] +async fn client_config_view( + auth: BearerAuth, + connection_info: ConnectionInfo, + config: web::Data, + db_pool: web::Data, + request_data: web::Json, +) -> Result { + let db_client = &**get_database_client(&db_pool).await?; + let mut current_user = get_current_user(db_client, auth.token()).await?; + if request_data.len() != 1 { + return Err(ValidationError("can't update more than one config").into()); + }; + let (client_name, client_config_value) = + request_data.iter().next().expect("hashmap entry should exist"); + current_user.client_config = update_client_config( + db_client, + ¤t_user.id, + client_name, + client_config_value, + ).await?; + let account = Account::from_user( + &get_request_base_url(connection_info), + &config.instance_url(), + current_user, + ); + Ok(HttpResponse::Ok().json(account)) +} + #[post("/change_password")] async fn change_password_view( auth: BearerAuth, @@ -231,6 +266,7 @@ async fn move_followers( pub fn settings_api_scope() -> Scope { web::scope("/api/v1/settings") + .service(client_config_view) .service(change_password_view) .service(add_alias_view) .service(export_followers_view)