diff --git a/Cargo.lock b/Cargo.lock index 3baaa16..1b5a3fe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2115,6 +2115,7 @@ dependencies = [ "futures-util", "hmac 0.10.1", "http", + "image", "itertools", "juniper", "juniper_actix", @@ -2133,6 +2134,7 @@ dependencies = [ "sea-query-binder", "secstr", "serde", + "serde_bytes", "serde_json", "sha2", "sqlx", @@ -3317,6 +3319,15 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde_bytes" +version = "0.11.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfc50e8183eeeb6178dcb167ae34a8051d63535023ae38b5d8d12beae193d37b" +dependencies = [ + "serde", +] + [[package]] name = "serde_derive" version = "1.0.137" diff --git a/app/src/components/create_user.rs b/app/src/components/create_user.rs index fa271cb..dc146e2 100644 --- a/app/src/components/create_user.rs +++ b/app/src/components/create_user.rs @@ -90,6 +90,7 @@ impl CommonComponent for CreateUserForm { displayName: to_option(model.display_name), firstName: to_option(model.first_name), lastName: to_option(model.last_name), + avatar: None, }, }; self.common.call_graphql::( diff --git a/app/src/components/user_details_form.rs b/app/src/components/user_details_form.rs index 08da5e0..cd3433c 100644 --- a/app/src/components/user_details_form.rs +++ b/app/src/components/user_details_form.rs @@ -234,6 +234,7 @@ impl UserDetailsForm { displayName: None, firstName: None, lastName: None, + avatar: None, }; let default_user_input = user_input.clone(); let model = self.form.model(); diff --git a/migration-tool/src/lldap.rs b/migration-tool/src/lldap.rs index e4a8ef7..564f125 100644 --- a/migration-tool/src/lldap.rs +++ b/migration-tool/src/lldap.rs @@ -85,6 +85,7 @@ impl User { display_name, first_name, last_name, + avatar: None, }, password, dn, diff --git a/schema.graphql b/schema.graphql index f6a01da..b776344 100644 --- a/schema.graphql +++ b/schema.graphql @@ -60,6 +60,7 @@ input CreateUserInput { displayName: String firstName: String lastName: String + avatar: String } type User { @@ -68,6 +69,7 @@ type User { displayName: String! firstName: String! lastName: String! + avatar: String! creationDate: DateTimeUtc! uuid: String! "The groups to which this user belongs." @@ -85,6 +87,7 @@ input UpdateUserInput { displayName: String firstName: String lastName: String + avatar: String } schema { diff --git a/server/Cargo.toml b/server/Cargo.toml index d28b2f2..81b104f 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -45,6 +45,7 @@ tracing-actix-web = "0.4.0-beta.7" tracing-attributes = "^0.1.21" tracing-log = "*" rustls-pemfile = "1.0.0" +serde_bytes = "0.11.7" [dependencies.chrono] features = ["serde"] @@ -117,5 +118,10 @@ version = "^0.1.4" features = ["default", "rustls"] version = "=3.0.0-beta.5" +[dependencies.image] +features = ["jpeg"] +default-features = false +version = "0.24" + [dev-dependencies] mockall = "0.9.1" diff --git a/server/src/domain/handler.rs b/server/src/domain/handler.rs index 0e01ab8..8be3176 100644 --- a/server/src/domain/handler.rs +++ b/server/src/domain/handler.rs @@ -82,6 +82,69 @@ impl From for UserId { } } +#[derive(PartialEq, Eq, Clone, Debug, Default, Serialize, Deserialize, sqlx::Type)] +#[sqlx(transparent)] +pub struct JpegPhoto(#[serde(with = "serde_bytes")] Vec); + +impl From for sea_query::Value { + fn from(photo: JpegPhoto) -> Self { + photo.0.into() + } +} + +impl From<&JpegPhoto> for sea_query::Value { + fn from(photo: &JpegPhoto) -> Self { + photo.0.as_slice().into() + } +} + +impl TryFrom> for JpegPhoto { + type Error = anyhow::Error; + fn try_from(bytes: Vec) -> anyhow::Result { + // Confirm that it's a valid Jpeg, then store only the bytes. + image::io::Reader::with_format( + std::io::Cursor::new(bytes.as_slice()), + image::ImageFormat::Jpeg, + ) + .decode()?; + Ok(JpegPhoto(bytes)) + } +} + +impl TryFrom for JpegPhoto { + type Error = anyhow::Error; + fn try_from(string: String) -> anyhow::Result { + // The String format is in base64. + Self::try_from(base64::decode(string.as_str())?) + } +} + +impl From<&JpegPhoto> for String { + fn from(val: &JpegPhoto) -> Self { + base64::encode(&val.0) + } +} + +impl JpegPhoto { + pub fn for_tests() -> Self { + use image::{ImageOutputFormat, Rgb, RgbImage}; + let img = RgbImage::from_fn(32, 32, |x, y| { + if (x + y) % 2 == 0 { + Rgb([0, 0, 0]) + } else { + Rgb([255, 255, 255]) + } + }); + let mut bytes: Vec = Vec::new(); + img.write_to( + &mut std::io::Cursor::new(&mut bytes), + ImageOutputFormat::Jpeg(0), + ) + .unwrap(); + Self(bytes) + } +} + #[derive(PartialEq, Eq, Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] pub struct User { pub user_id: UserId, @@ -89,7 +152,7 @@ pub struct User { pub display_name: String, pub first_name: String, pub last_name: String, - // pub avatar: ?, + pub avatar: JpegPhoto, pub creation_date: chrono::DateTime, pub uuid: Uuid, } @@ -105,6 +168,7 @@ impl Default for User { display_name: String::new(), first_name: String::new(), last_name: String::new(), + avatar: JpegPhoto::default(), creation_date: epoch, uuid: Uuid::from_name_and_date("", &epoch), } @@ -159,6 +223,7 @@ pub struct CreateUserRequest { pub display_name: Option, pub first_name: Option, pub last_name: Option, + pub avatar: Option, } #[derive(PartialEq, Eq, Debug, Serialize, Deserialize, Clone, Default)] @@ -169,6 +234,7 @@ pub struct UpdateUserRequest { pub display_name: Option, pub first_name: Option, pub last_name: Option, + pub avatar: Option, } #[derive(PartialEq, Eq, Debug, Serialize, Deserialize, Clone)] @@ -263,4 +329,11 @@ mod tests { Uuid::from_name_and_date(user_id, &date2) ); } + + #[test] + fn test_jpeg_try_from_bytes() { + let base64_raw = "/9j/4AAQSkZJRgABAQEASABIAAD/2wBDAP//////////////////////////////////////////////////////////////////////////////////////2wBDAf//////////////////////////////////////////////////////////////////////////////////////wAARCADqATkDASIAAhEBAxEB/8QAFwABAQEBAAAAAAAAAAAAAAAAAAECA//EACQQAQEBAAIBBAMBAQEBAAAAAAABESExQQISUXFhgZGxocHw/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAH/xAAWEQEBAQAAAAAAAAAAAAAAAAAAEQH/2gAMAwEAAhEDEQA/AMriLyCKgg1gQwCgs4FTMOdutepjQak+FzMSVqgxZdRdPPIIvH5WzzGdBriphtTeAXg2ZjKA1pqKDUGZca3foBek8gFv8Ie3fKdA1qb8s7hoL6eLVt51FsAnql3Ut1M7AWbflLMDkEMX/F6/YjK/pADFQAUNA6alYagKk72m/j9p4Bq2fDDSYKLNXPNLoHE/NT6RYC31cJxZ3yWVM+aBYi/S2ZgiAsnYJx5D21vPmqrm3PTfpQQwyAC8JZvSKDni41ZrMuUVVl+Uz9w9v/1QWrZsZ5nFPHYH+JZyureQSF5M+fJ0CAfwRAVRBQA1DAWVUayoJUWoDpsxntPsueBV4+VxhdyAtv8AjOLGpIDMLbeGvbF4iozJfr/WukAVABAXAQXEAAASzVAZdO2WNordm+emFl7XcQSNZiFtv0C9w90nhJf4mA1u+GcJFwIyAqL/AOovwgGNfSRqdIrNa29M0gKCAojU9PAMjWXpckEJFNFEAAXEUBABYz6rZ0ureQc9vyt9XxDF2QAXtABcQAs0AZywkvluJbyipifas52DcyxjlZweAO0xri/hc+wZOEKIu6nSyeToVZyWXwvCg53gW81QQ7aTNAn5dGZJPs1UXURQAUEMCXQLZE93PRZ5hPTgNMrbIzKCm52LZwCs+2M8w2g3sjPuZAXb4IsMAUACzVUGM4/K+md6vEXUUyM5PDR0IxYe6ramih0VNBrS4xoqN8Q1BFQk3yqyAsioioAAKgDSJL4/jQIn5igLrPqtOuf6oOaxbMoAltUAhhIoJiiggrPu+AaOIxtAX3JbaAIaLwi4t9X4T3fg2AFtqcrUUarP20zUDAmqoE0WRBZPNVUVEAAAAVAC8kvih2DSKxOdBqs7Z0l0gI0mKAC4AuHE7ZtBriM+744QAAAAABAFsveIttBICyaikvy1+r/Cen5rWQHIBQa4rIDRqSl5qDWqziqgAAAATA7BpGdqXb2C2+J/UgAtRQBSQtkBWb6vhLbQAAAAAEBRAAAAAUbm+GZNdPxAP+ql2Tjwx7/wIgZ8iKvBk+CJoCXii9gaqZ/qqihAAAEVABGkBFUwBftNkZ3QW34QAAABFAQAVAAAAAARVkl8gs/43sk1jL45LvHArepk+E9XTG35oLqsmIKmLAEygKg0y1AFQBUXwgAAAoBC34S3UAAABAVAAAAAABAUQAVABdRQa1PcYyit2z58M8C4ouM2NXpOEGeWtNZUatiAIoAKIoCoAoG4C9MW6dgIoAIAAAAAAACKWAgL0CAAAALiANCKioNLgM1CrLihmTafkt1EF3SZ5ZVUW4mnIKvAi5fhEURVDWVQBRAAAAAAAAQFRVyAyulgAqCKlF8IqLsEgC9mGoC+IusqCrv5ZEUVOk1RuJfwSLOOkGFi4XPCoYYrNiKauosBGi9ICstM1UAAAAAAFQ0VcTBAXUGgIqGoKhKAzRRUQUAwxoSrGRpkQA/qiosOL9oJptMRRVZa0VUqSiChE6BqMgCwqKqIogAIAqKCKgKoogg0lBFuIKgAAAKNRlf2gqsftsEtZWoAAqAACKoMqAAeSoqp39kL2AqLOlE8rEBFQARYALhigrNC9gGmooLp4TweEQFFBFAECgIoAu0ifIAqAAA//9k="; + let base64_jpeg = base64::decode(base64_raw).unwrap(); + JpegPhoto::try_from(base64_jpeg).unwrap(); + } } diff --git a/server/src/domain/sql_backend_handler.rs b/server/src/domain/sql_backend_handler.rs index d8d48ab..566a13d 100644 --- a/server/src/domain/sql_backend_handler.rs +++ b/server/src/domain/sql_backend_handler.rs @@ -367,6 +367,7 @@ impl BackendHandler for SqlBackendHandler { Users::DisplayName, Users::FirstName, Users::LastName, + Users::Avatar, Users::CreationDate, Users::Uuid, ]; @@ -378,6 +379,7 @@ impl BackendHandler for SqlBackendHandler { request.display_name.unwrap_or_default().into(), request.first_name.unwrap_or_default().into(), request.last_name.unwrap_or_default().into(), + request.avatar.unwrap_or_default().into(), now.naive_utc().into(), uuid.into(), ]; @@ -409,6 +411,9 @@ impl BackendHandler for SqlBackendHandler { if let Some(last_name) = request.last_name { values.push((Users::LastName, last_name.into())); } + if let Some(avatar) = request.avatar { + values.push((Users::Avatar, avatar.into())); + } if values.is_empty() { return Ok(()); } diff --git a/server/src/infra/graphql/mutation.rs b/server/src/infra/graphql/mutation.rs index 743cd96..74ed3b2 100644 --- a/server/src/infra/graphql/mutation.rs +++ b/server/src/infra/graphql/mutation.rs @@ -1,6 +1,8 @@ use crate::domain::handler::{ - BackendHandler, CreateUserRequest, GroupId, UpdateGroupRequest, UpdateUserRequest, UserId, + BackendHandler, CreateUserRequest, GroupId, JpegPhoto, UpdateGroupRequest, UpdateUserRequest, + UserId, }; +use anyhow::Context as AnyhowContext; use juniper::{graphql_object, FieldResult, GraphQLInputObject, GraphQLObject}; use tracing::{debug, debug_span, Instrument}; @@ -28,6 +30,8 @@ pub struct CreateUserInput { display_name: Option, first_name: Option, last_name: Option, + // Base64 encoded JpegPhoto. + avatar: Option, } #[derive(PartialEq, Eq, Debug, GraphQLInputObject)] @@ -38,6 +42,8 @@ pub struct UpdateUserInput { display_name: Option, first_name: Option, last_name: Option, + // Base64 encoded JpegPhoto. + avatar: Option, } #[derive(PartialEq, Eq, Debug, GraphQLInputObject)] @@ -73,6 +79,14 @@ impl Mutation { return Err("Unauthorized user creation".into()); } let user_id = UserId::new(&user.id); + let avatar = user + .avatar + .map(base64::decode) + .transpose() + .context("Invalid base64 image")? + .map(JpegPhoto::try_from) + .transpose() + .context("Provided image is not a valid JPEG")?; context .handler .create_user(CreateUserRequest { @@ -81,6 +95,7 @@ impl Mutation { display_name: user.display_name, first_name: user.first_name, last_name: user.last_name, + avatar, }) .instrument(span.clone()) .await?; @@ -126,6 +141,14 @@ impl Mutation { span.in_scope(|| debug!("Unauthorized")); return Err("Unauthorized user update".into()); } + let avatar = user + .avatar + .map(base64::decode) + .transpose() + .context("Invalid base64 image")? + .map(JpegPhoto::try_from) + .transpose() + .context("Provided image is not a valid JPEG")?; context .handler .update_user(UpdateUserRequest { @@ -134,6 +157,7 @@ impl Mutation { display_name: user.display_name, first_name: user.first_name, last_name: user.last_name, + avatar, }) .instrument(span) .await?; diff --git a/server/src/infra/graphql/query.rs b/server/src/infra/graphql/query.rs index 856e215..3955dbe 100644 --- a/server/src/infra/graphql/query.rs +++ b/server/src/infra/graphql/query.rs @@ -217,6 +217,10 @@ impl User { &self.user.last_name } + fn avatar(&self) -> String { + (&self.user.avatar).into() + } + fn creation_date(&self) -> chrono::DateTime { self.user.creation_date } diff --git a/server/src/infra/ldap_handler.rs b/server/src/infra/ldap_handler.rs index 84505e4..30b1b09 100644 --- a/server/src/infra/ldap_handler.rs +++ b/server/src/infra/ldap_handler.rs @@ -1459,6 +1459,7 @@ mod tests { display_name: "Jimminy Cricket".to_string(), first_name: "Jim".to_string(), last_name: "Cricket".to_string(), + avatar: JpegPhoto::for_tests(), uuid: uuid!("04ac75e0-2900-3e21-926c-2f732c26b3fc"), creation_date: Utc.ymd(2014, 7, 8).and_hms(9, 10, 11), },