diff --git a/migrations/0004_add_avatar_color.sql b/migrations/0004_add_avatar_color.sql new file mode 100644 index 0000000..9de6c98 --- /dev/null +++ b/migrations/0004_add_avatar_color.sql @@ -0,0 +1,2 @@ +ALTER TABLE users ADD COLUMN avatar_color TEXT; + diff --git a/sql/schema.sql b/sql/schema.sql index de7abcd..4852107 100644 --- a/sql/schema.sql +++ b/sql/schema.sql @@ -2,6 +2,7 @@ CREATE TABLE IF NOT EXISTS users ( id TEXT PRIMARY KEY NOT NULL, name TEXT, + avatar_color TEXT, email TEXT NOT NULL UNIQUE, email_verified BOOLEAN NOT NULL DEFAULT 0, master_password_hash TEXT NOT NULL, diff --git a/src/handlers/accounts.rs b/src/handlers/accounts.rs index 00d6395..d51e1c3 100644 --- a/src/handlers/accounts.rs +++ b/src/handlers/accounts.rs @@ -21,7 +21,7 @@ use crate::{ sync::Profile, user::{ ChangeKdfRequest, ChangePasswordRequest, PasswordOrOtpData, PreloginResponse, - RegisterRequest, RotateKeyRequest, User, + RegisterRequest, RotateKeyRequest, User, ProfileData, AvatarData, }, }, }; @@ -212,6 +212,7 @@ pub async fn register( let user = User { id: Uuid::new_v4().to_string(), name: payload.name, + avatar_color: None, email: payload.email.to_lowercase(), email_verified: false, master_password_hash: hashed_password, @@ -312,6 +313,110 @@ pub async fn get_profile( Ok(Json(profile)) } +#[worker::send] +pub async fn post_profile( + claims: Claims, + State(env): State>, + Json(payload): Json, +) -> Result, AppError> { + if payload.name.len() > 50 { + return Err(AppError::BadRequest( + "The field Name must be a string with a maximum length of 50.".to_string(), + )); + } + + let db = db::get_db(&env)?; + let user_id = &claims.sub; + + let user_value: Value = db + .prepare("SELECT * FROM users WHERE id = ?1") + .bind(&[user_id.clone().into()])? + .first(None) + .await + .map_err(|_| AppError::Database)? + .ok_or_else(|| AppError::NotFound("User not found".to_string()))?; + + let mut user: User = serde_json::from_value(user_value).map_err(|_| AppError::Internal)?; + let now = Utc::now().to_rfc3339(); + + user.name = Some(payload.name); + user.updated_at = now.clone(); + + query!( + &db, + "UPDATE users SET name = ?1, updated_at = ?2 WHERE id = ?3", + user.name, + now, + user_id + ) + .map_err(|_| AppError::Database)? + .run() + .await + .map_err(|_| AppError::Database)?; + + let profile = Profile::from_user(user)?; + + Ok(Json(profile)) +} + +#[worker::send] +pub async fn put_profile( + claims: Claims, + state: State>, + json: Json, +) -> Result, AppError> { + post_profile(claims, state, json).await +} + +#[worker::send] +pub async fn put_avatar( + claims: Claims, + State(env): State>, + Json(payload): Json, +) -> Result, AppError> { + if let Some(color) = &payload.avatar_color { + if color.len() != 7 { + return Err(AppError::BadRequest( + "The field AvatarColor must be a HTML/Hex color code with a length of 7 characters" + .to_string(), + )); + } + } + + let db = db::get_db(&env)?; + let user_id = &claims.sub; + + let user_value: Value = db + .prepare("SELECT * FROM users WHERE id = ?1") + .bind(&[user_id.clone().into()])? + .first(None) + .await + .map_err(|_| AppError::Database)? + .ok_or_else(|| AppError::NotFound("User not found".to_string()))?; + + let mut user: User = serde_json::from_value(user_value).map_err(|_| AppError::Internal)?; + let now = Utc::now().to_rfc3339(); + + user.avatar_color = payload.avatar_color; + user.updated_at = now.clone(); + + query!( + &db, + "UPDATE users SET avatar_color = ?1, updated_at = ?2 WHERE id = ?3", + user.avatar_color, + now, + user_id + ) + .map_err(|_| AppError::Database)? + .run() + .await + .map_err(|_| AppError::Database)?; + + let profile = Profile::from_user(user)?; + + Ok(Json(profile)) +} + #[worker::send] pub async fn delete_account( claims: Claims, diff --git a/src/models/sync.rs b/src/models/sync.rs index 16dc89e..b22c5da 100644 --- a/src/models/sync.rs +++ b/src/models/sync.rs @@ -9,6 +9,8 @@ use serde_json::Value; pub struct Profile { #[serde(skip_serializing_if = "Option::is_none")] pub name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub avatar_color: Option, pub email: String, pub id: String, #[serde(skip_serializing_if = "Option::is_none")] @@ -35,6 +37,7 @@ impl Profile { Ok(Self { id: user.id, name: user.name, + avatar_color: user.avatar_color, email: user.email, master_password_hint: user.master_password_hint, security_stamp: user.security_stamp, diff --git a/src/models/user.rs b/src/models/user.rs index dbb181b..b053b26 100644 --- a/src/models/user.rs +++ b/src/models/user.rs @@ -7,6 +7,7 @@ use crate::{crypto::verify_password, error::AppError}; pub struct User { pub id: String, pub name: Option, + pub avatar_color: Option, pub email: String, #[serde(with = "bool_from_int")] pub email_verified: bool, @@ -321,3 +322,15 @@ pub struct RotateFolderData { pub id: Option, pub name: String, } + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ProfileData { + pub name: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AvatarData { + pub avatar_color: Option, +} diff --git a/src/router.rs b/src/router.rs index 8dc9a71..485506b 100644 --- a/src/router.rs +++ b/src/router.rs @@ -27,6 +27,9 @@ pub fn api_router(env: Env) -> Router { // For on-demand sync checks .route("/api/accounts/revision-date", get(accounts::revision_date)) .route("/api/accounts/profile", get(accounts::get_profile)) + .route("/api/accounts/profile", post(accounts::post_profile)) + .route("/api/accounts/profile", put(accounts::put_profile)) + .route("/api/accounts/avatar", put(accounts::put_avatar)) // Delete account .route("/api/accounts", delete(accounts::delete_account)) .route("/api/accounts/delete", post(accounts::delete_account))