diff --git a/Cargo.toml b/Cargo.toml index 5d7203f..5ee67a4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,7 +18,7 @@ mime_guess = "2.0.5" password-hash = "0.5.0" serde = { version = "1.0.219", features = ["derive"] } serde_json = "1.0.143" -sqlx = { version = "0.8.6", features = ["postgres", "runtime-tokio-native-tls", "macros", "uuid", "chrono"] } +sqlx = { version = "0.8.6", features = ["postgres", "runtime-tokio-native-tls", "macros", "uuid", "chrono", "json"] } tokio = { version = "1.47.1", features = ["rt-multi-thread", "macros"] } tower = "0.5.3" tower-http = { version = "0.6.6", features = ["cors", "limit", "trace", "fs"] } diff --git a/db/init.sql b/db/init.sql index 6a27583..639792d 100644 --- a/db/init.sql +++ b/db/init.sql @@ -1,72 +1,77 @@ CREATE TABLE IF NOT EXISTS user_ ( - id SERIAL PRIMARY KEY, - uuid UUID UNIQUE, - email TEXT UNIQUE, - username TEXT NOT NULL UNIQUE, - avatar_url TEXT, - password_hash TEXT NOT NULL + id SERIAL PRIMARY KEY, + uuid UUID UNIQUE, + email TEXT UNIQUE, + username TEXT NOT NULL UNIQUE, + avatar_url TEXT, + password_hash TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS user_config_ ( + user_id INT PRIMARY KEY REFERENCES user_(id), + config JSON DEFAULT '{}' ); CREATE TABLE IF NOT EXISTS friendship_ ( - user_first INT NOT NULL REFERENCES user_(id) ON DELETE CASCADE, - user_second INT NOT NULL REFERENCES user_(id) ON DELETE CASCADE, - created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - PRIMARY KEY (user_first, user_second) + user_first INT NOT NULL REFERENCES user_(id) ON DELETE CASCADE, + user_second INT NOT NULL REFERENCES user_(id) ON DELETE CASCADE, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (user_first, user_second) ); CREATE TABLE IF NOT EXISTS friend_request_ ( - sender INT NOT NULL REFERENCES user_(id) ON DELETE CASCADE, - receiver INT NOT NULL REFERENCES user_(id) ON DELETE CASCADE, - sent_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - PRIMARY KEY (sender, receiver), - CHECK (sender <> receiver) + sender INT NOT NULL REFERENCES user_(id) ON DELETE CASCADE, + receiver INT NOT NULL REFERENCES user_(id) ON DELETE CASCADE, + sent_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (sender, receiver), + CHECK (sender <> receiver) ); CREATE TABLE IF NOT EXISTS room_ ( - id SERIAL PRIMARY KEY, - uuid UUID UNIQUE, - owner INT NOT NULL REFERENCES user_(id), - name TEXT NOT NULL, - global BOOLEAN NOT NULL DEFAULT false + id SERIAL PRIMARY KEY, + uuid UUID UNIQUE, + owner INT NOT NULL REFERENCES user_(id), + name TEXT NOT NULL, + global BOOLEAN NOT NULL DEFAULT false ); CREATE TABLE IF NOT EXISTS membership_ ( - user_id INT REFERENCES user_(id), - room INT REFERENCES room_(id), - last_read_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - PRIMARY KEY (user_id, room) + user_id INT REFERENCES user_(id), + room INT REFERENCES room_(id), + last_read_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (user_id, room) ); CREATE TABLE IF NOT EXISTS room_invite_ ( - sender INT NOT NULL REFERENCES user_(id) ON DELETE CASCADE, - receiver INT NOT NULL REFERENCES user_(id) ON DELETE CASCADE, - room INT NOT NULL, - sent_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - PRIMARY KEY (sender, receiver), - CHECK (sender <> receiver) + sender INT NOT NULL REFERENCES user_(id) ON DELETE CASCADE, + receiver INT NOT NULL REFERENCES user_(id) ON DELETE CASCADE, + room INT NOT NULL, + sent_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (sender, receiver), + CHECK (sender <> receiver) ); CREATE TABLE IF NOT EXISTS message_ ( - id BIGSERIAL PRIMARY KEY, - uuid UUID UNIQUE NOT NULL, - sender INT REFERENCES user_(id) NOT NULL, - room INT REFERENCES room_(id) NOT NULL, - message_type VARCHAR(32) NOT NULL, - content TEXT NOT NULL, - sent_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + id BIGSERIAL PRIMARY KEY, + uuid UUID UNIQUE NOT NULL, + sender INT REFERENCES user_(id) NOT NULL, + room INT REFERENCES room_(id) NOT NULL, + message_type VARCHAR(32) NOT NULL, + content TEXT NOT NULL, + sent_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS message_attachment_ ( - id SERIAL PRIMARY KEY, - uuid UUID NOT NULL UNIQUE, - message_uuid UUID NOT NULL REFERENCES message_(uuid), - file_type VARCHAR(20) NOT NULL, - file_name TEXT NOT NULL + id SERIAL PRIMARY KEY, + uuid UUID NOT NULL UNIQUE, + message_uuid UUID NOT NULL REFERENCES message_(uuid), + file_type VARCHAR(20) NOT NULL, + file_name TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS ws_token_ ( - token TEXT PRIMARY KEY, - expires_at TIMESTAMPTZ NOT NULL + token TEXT PRIMARY KEY, + expires_at TIMESTAMPTZ NOT NULL ); -- ==== INDICES ==== diff --git a/db/mock_data.sql b/db/mock_data.sql index e29d508..e05be7a 100644 --- a/db/mock_data.sql +++ b/db/mock_data.sql @@ -3,6 +3,11 @@ INSERT INTO user_ (username, email, uuid, password_hash) VALUES ('bob', 'bob@example.com', '019b1e36-3b8c-7f82-b845-6bfeb72466ce', '$argon2id$v=19$m=19456,t=2,p=1$mzO6Qx8ZH4/wrj14ZgKiuA$7bxNWCgsIVEfPgtueFbjbi8mDjbAHMYAHOGpxTJnEpQ'), ('carol', 'carol@example.com', '019b1e36-7706-76e2-b9ce-b37916ddfc99', '$argon2id$v=19$m=19456,t=2,p=1$5rw/7uIJIKMnyqNrYQt92Q$DJVEfgbaZtkflsmDEkSoR3uDQmujI4T73cWq9hOBgVI'); +INSERT INTO user_config_ (user_id, config) VALUES +(1, '{"language": "French", "theme": "Catppuccin Mocha"}'), +(2, '{"language": "English", "theme": "Nordic", "compact_layout": true}'), +(3, '{"language": "English", "theme": "Tokyo Night", "compact_layout": false}'); + INSERT INTO room_ (owner, name, global, uuid) VALUES (1, 'General Discussion', true, '5dc599ee-1f5c-40c2-a22a-e40780d2d960'), (2, 'Tech Talk', false, '6b14fe7b-2171-4464-95af-4888062b1b6d'), diff --git a/src/lib.rs b/src/lib.rs index e9dc5f7..887fa52 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -97,6 +97,8 @@ pub struct Cli { routes::users::update_user, routes::users::upload_avatar, routes::users::get_avatar, + routes::users::get_config, + routes::users::save_config, routes::voice::voice_ws_handler, routes::ws::issue_ws_token, ), @@ -125,6 +127,7 @@ pub struct Cli { routes::users::NewUserPayload, routes::users::UpdateUserPayoad, routes::users::UpdateUserResponse, + routes::users::UserConfig, routes::ws::WsAuthQuery, ) ), diff --git a/src/routes/users.rs b/src/routes/users.rs index ecf06d7..383aef8 100644 --- a/src/routes/users.rs +++ b/src/routes/users.rs @@ -3,9 +3,10 @@ use axum::{ extract::{Path, Request}, http::{HeaderMap, StatusCode, header}, middleware::Next, - response::Response, + response::{IntoResponse, Response}, routing::{get, post, put}, }; +use serde::{Deserialize, Serialize}; use sqlx::PgPool; use std::sync::Arc; use uuid::Uuid; @@ -66,6 +67,13 @@ pub struct UpdateUserResponse { pub username: String, } +#[derive(Serialize, Deserialize, Debug, utoipa::ToSchema)] +pub struct UserConfig { + language: Option, + theme: Option, + compact_layout: Option, +} + pub fn routes() -> Router { Router::new() .route("/login", post(login)) @@ -74,6 +82,8 @@ pub fn routes() -> Router { .route("/account/settings", put(update_user)) .route("/account/upload-avatar", post(upload_avatar)) .route("/account/get-avatar/{uuid}", get(get_avatar)) + .route("/account/get-config", get(get_config)) + .route("/account/save-config", post(save_config)) .layer(axum::middleware::from_fn(registration_guard)) } @@ -410,3 +420,71 @@ async fn get_avatar( .body(axum::body::Body::from(file_contents)) .unwrap()) } + +#[utoipa::path( + get, + path = "/account/get-config", + responses( + (status = 200, description = "User config found", body = UserConfig), + (status = 401, description = "Missing authentication header or Invalid or expired token", body = ErrorResponse), + (status = 404, description = "User not found", body = ErrorResponse), + ), + security( ("bearer_auth" = []) + ) +)] +async fn get_config( + headers: HeaderMap, + Extension(db): Extension, +) -> Result { + let claims = verify_jwt(headers)?; + let user_id = user_id_from_uuid(&db, claims.sub).await?; + + let config: sqlx::types::Json = + sqlx::query_scalar("SELECT config FROM user_config_ WHERE user_id = $1") + .bind(user_id) + .fetch_one(&db) + .await + .map_err(|e| { + tracing::debug!("Failed to get user config: {e}"); + APIError::UserNotFound + })?; + + let config = config.0; + + Ok(Json(config).into_response()) +} + +#[utoipa::path( + post, + path = "/account/save-config", + request_body(content = UserConfig, content_type = "application/json", description = "User config in json format"), + responses( + (status = 200, description = "User config saved", body = UserConfig), + (status = 401, description = "Missing authentication header or Invalid or expired token", body = ErrorResponse), + (status = 404, description = "User not found", body = ErrorResponse), + ), + security( + ("bearer_auth" = []) + ) +)] +async fn save_config( + headers: HeaderMap, + Extension(db): Extension, + Json(payload): Json, +) -> Result { + let claims = verify_jwt(headers)?; + + let user_id = user_id_from_uuid(&db, claims.sub).await?; + + sqlx::query("UPDATE user_config_ SET config = $1 WHERE user_id = $2") + .bind(sqlx::types::Json(&payload)) + .bind(user_id) + .execute(&db) + .await + .map_err(|e| { + tracing::debug!("Failed to save user config: {e}"); + APIError::UserNotFound + })?; + + Ok((StatusCode::OK, Json(payload)).into_response()) +}