server: Add support for users' avatars in GrahpQL

This commit is contained in:
Valentin Tolmer 2022-08-03 23:53:25 +02:00 committed by nitnelave
parent 0e3c5120da
commit 3acc448048
11 changed files with 132 additions and 2 deletions

11
Cargo.lock generated
View File

@ -2115,6 +2115,7 @@ dependencies = [
"futures-util", "futures-util",
"hmac 0.10.1", "hmac 0.10.1",
"http", "http",
"image",
"itertools", "itertools",
"juniper", "juniper",
"juniper_actix", "juniper_actix",
@ -2133,6 +2134,7 @@ dependencies = [
"sea-query-binder", "sea-query-binder",
"secstr", "secstr",
"serde", "serde",
"serde_bytes",
"serde_json", "serde_json",
"sha2", "sha2",
"sqlx", "sqlx",
@ -3317,6 +3319,15 @@ dependencies = [
"serde_derive", "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]] [[package]]
name = "serde_derive" name = "serde_derive"
version = "1.0.137" version = "1.0.137"

View File

@ -90,6 +90,7 @@ impl CommonComponent<CreateUserForm> for CreateUserForm {
displayName: to_option(model.display_name), displayName: to_option(model.display_name),
firstName: to_option(model.first_name), firstName: to_option(model.first_name),
lastName: to_option(model.last_name), lastName: to_option(model.last_name),
avatar: None,
}, },
}; };
self.common.call_graphql::<CreateUser, _>( self.common.call_graphql::<CreateUser, _>(

View File

@ -234,6 +234,7 @@ impl UserDetailsForm {
displayName: None, displayName: None,
firstName: None, firstName: None,
lastName: None, lastName: None,
avatar: None,
}; };
let default_user_input = user_input.clone(); let default_user_input = user_input.clone();
let model = self.form.model(); let model = self.form.model();

View File

@ -85,6 +85,7 @@ impl User {
display_name, display_name,
first_name, first_name,
last_name, last_name,
avatar: None,
}, },
password, password,
dn, dn,

View File

@ -60,6 +60,7 @@ input CreateUserInput {
displayName: String displayName: String
firstName: String firstName: String
lastName: String lastName: String
avatar: String
} }
type User { type User {
@ -68,6 +69,7 @@ type User {
displayName: String! displayName: String!
firstName: String! firstName: String!
lastName: String! lastName: String!
avatar: String!
creationDate: DateTimeUtc! creationDate: DateTimeUtc!
uuid: String! uuid: String!
"The groups to which this user belongs." "The groups to which this user belongs."
@ -85,6 +87,7 @@ input UpdateUserInput {
displayName: String displayName: String
firstName: String firstName: String
lastName: String lastName: String
avatar: String
} }
schema { schema {

View File

@ -45,6 +45,7 @@ tracing-actix-web = "0.4.0-beta.7"
tracing-attributes = "^0.1.21" tracing-attributes = "^0.1.21"
tracing-log = "*" tracing-log = "*"
rustls-pemfile = "1.0.0" rustls-pemfile = "1.0.0"
serde_bytes = "0.11.7"
[dependencies.chrono] [dependencies.chrono]
features = ["serde"] features = ["serde"]
@ -117,5 +118,10 @@ version = "^0.1.4"
features = ["default", "rustls"] features = ["default", "rustls"]
version = "=3.0.0-beta.5" version = "=3.0.0-beta.5"
[dependencies.image]
features = ["jpeg"]
default-features = false
version = "0.24"
[dev-dependencies] [dev-dependencies]
mockall = "0.9.1" mockall = "0.9.1"

View File

@ -82,6 +82,69 @@ impl From<String> for UserId {
} }
} }
#[derive(PartialEq, Eq, Clone, Debug, Default, Serialize, Deserialize, sqlx::Type)]
#[sqlx(transparent)]
pub struct JpegPhoto(#[serde(with = "serde_bytes")] Vec<u8>);
impl From<JpegPhoto> 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<Vec<u8>> for JpegPhoto {
type Error = anyhow::Error;
fn try_from(bytes: Vec<u8>) -> anyhow::Result<Self> {
// 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<String> for JpegPhoto {
type Error = anyhow::Error;
fn try_from(string: String) -> anyhow::Result<Self> {
// 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<u8> = 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)] #[derive(PartialEq, Eq, Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct User { pub struct User {
pub user_id: UserId, pub user_id: UserId,
@ -89,7 +152,7 @@ pub struct User {
pub display_name: String, pub display_name: String,
pub first_name: String, pub first_name: String,
pub last_name: String, pub last_name: String,
// pub avatar: ?, pub avatar: JpegPhoto,
pub creation_date: chrono::DateTime<chrono::Utc>, pub creation_date: chrono::DateTime<chrono::Utc>,
pub uuid: Uuid, pub uuid: Uuid,
} }
@ -105,6 +168,7 @@ impl Default for User {
display_name: String::new(), display_name: String::new(),
first_name: String::new(), first_name: String::new(),
last_name: String::new(), last_name: String::new(),
avatar: JpegPhoto::default(),
creation_date: epoch, creation_date: epoch,
uuid: Uuid::from_name_and_date("", &epoch), uuid: Uuid::from_name_and_date("", &epoch),
} }
@ -159,6 +223,7 @@ pub struct CreateUserRequest {
pub display_name: Option<String>, pub display_name: Option<String>,
pub first_name: Option<String>, pub first_name: Option<String>,
pub last_name: Option<String>, pub last_name: Option<String>,
pub avatar: Option<JpegPhoto>,
} }
#[derive(PartialEq, Eq, Debug, Serialize, Deserialize, Clone, Default)] #[derive(PartialEq, Eq, Debug, Serialize, Deserialize, Clone, Default)]
@ -169,6 +234,7 @@ pub struct UpdateUserRequest {
pub display_name: Option<String>, pub display_name: Option<String>,
pub first_name: Option<String>, pub first_name: Option<String>,
pub last_name: Option<String>, pub last_name: Option<String>,
pub avatar: Option<JpegPhoto>,
} }
#[derive(PartialEq, Eq, Debug, Serialize, Deserialize, Clone)] #[derive(PartialEq, Eq, Debug, Serialize, Deserialize, Clone)]
@ -263,4 +329,11 @@ mod tests {
Uuid::from_name_and_date(user_id, &date2) 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();
}
} }

View File

@ -367,6 +367,7 @@ impl BackendHandler for SqlBackendHandler {
Users::DisplayName, Users::DisplayName,
Users::FirstName, Users::FirstName,
Users::LastName, Users::LastName,
Users::Avatar,
Users::CreationDate, Users::CreationDate,
Users::Uuid, Users::Uuid,
]; ];
@ -378,6 +379,7 @@ impl BackendHandler for SqlBackendHandler {
request.display_name.unwrap_or_default().into(), request.display_name.unwrap_or_default().into(),
request.first_name.unwrap_or_default().into(), request.first_name.unwrap_or_default().into(),
request.last_name.unwrap_or_default().into(), request.last_name.unwrap_or_default().into(),
request.avatar.unwrap_or_default().into(),
now.naive_utc().into(), now.naive_utc().into(),
uuid.into(), uuid.into(),
]; ];
@ -409,6 +411,9 @@ impl BackendHandler for SqlBackendHandler {
if let Some(last_name) = request.last_name { if let Some(last_name) = request.last_name {
values.push((Users::LastName, last_name.into())); values.push((Users::LastName, last_name.into()));
} }
if let Some(avatar) = request.avatar {
values.push((Users::Avatar, avatar.into()));
}
if values.is_empty() { if values.is_empty() {
return Ok(()); return Ok(());
} }

View File

@ -1,6 +1,8 @@
use crate::domain::handler::{ 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 juniper::{graphql_object, FieldResult, GraphQLInputObject, GraphQLObject};
use tracing::{debug, debug_span, Instrument}; use tracing::{debug, debug_span, Instrument};
@ -28,6 +30,8 @@ pub struct CreateUserInput {
display_name: Option<String>, display_name: Option<String>,
first_name: Option<String>, first_name: Option<String>,
last_name: Option<String>, last_name: Option<String>,
// Base64 encoded JpegPhoto.
avatar: Option<String>,
} }
#[derive(PartialEq, Eq, Debug, GraphQLInputObject)] #[derive(PartialEq, Eq, Debug, GraphQLInputObject)]
@ -38,6 +42,8 @@ pub struct UpdateUserInput {
display_name: Option<String>, display_name: Option<String>,
first_name: Option<String>, first_name: Option<String>,
last_name: Option<String>, last_name: Option<String>,
// Base64 encoded JpegPhoto.
avatar: Option<String>,
} }
#[derive(PartialEq, Eq, Debug, GraphQLInputObject)] #[derive(PartialEq, Eq, Debug, GraphQLInputObject)]
@ -73,6 +79,14 @@ impl<Handler: BackendHandler + Sync> Mutation<Handler> {
return Err("Unauthorized user creation".into()); return Err("Unauthorized user creation".into());
} }
let user_id = UserId::new(&user.id); 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 context
.handler .handler
.create_user(CreateUserRequest { .create_user(CreateUserRequest {
@ -81,6 +95,7 @@ impl<Handler: BackendHandler + Sync> Mutation<Handler> {
display_name: user.display_name, display_name: user.display_name,
first_name: user.first_name, first_name: user.first_name,
last_name: user.last_name, last_name: user.last_name,
avatar,
}) })
.instrument(span.clone()) .instrument(span.clone())
.await?; .await?;
@ -126,6 +141,14 @@ impl<Handler: BackendHandler + Sync> Mutation<Handler> {
span.in_scope(|| debug!("Unauthorized")); span.in_scope(|| debug!("Unauthorized"));
return Err("Unauthorized user update".into()); 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 context
.handler .handler
.update_user(UpdateUserRequest { .update_user(UpdateUserRequest {
@ -134,6 +157,7 @@ impl<Handler: BackendHandler + Sync> Mutation<Handler> {
display_name: user.display_name, display_name: user.display_name,
first_name: user.first_name, first_name: user.first_name,
last_name: user.last_name, last_name: user.last_name,
avatar,
}) })
.instrument(span) .instrument(span)
.await?; .await?;

View File

@ -217,6 +217,10 @@ impl<Handler: BackendHandler + Sync> User<Handler> {
&self.user.last_name &self.user.last_name
} }
fn avatar(&self) -> String {
(&self.user.avatar).into()
}
fn creation_date(&self) -> chrono::DateTime<chrono::Utc> { fn creation_date(&self) -> chrono::DateTime<chrono::Utc> {
self.user.creation_date self.user.creation_date
} }

View File

@ -1459,6 +1459,7 @@ mod tests {
display_name: "Jimminy Cricket".to_string(), display_name: "Jimminy Cricket".to_string(),
first_name: "Jim".to_string(), first_name: "Jim".to_string(),
last_name: "Cricket".to_string(), last_name: "Cricket".to_string(),
avatar: JpegPhoto::for_tests(),
uuid: uuid!("04ac75e0-2900-3e21-926c-2f732c26b3fc"), uuid: uuid!("04ac75e0-2900-3e21-926c-2f732c26b3fc"),
creation_date: Utc.ymd(2014, 7, 8).and_hms(9, 10, 11), creation_date: Utc.ymd(2014, 7, 8).and_hms(9, 10, 11),
}, },