diff --git a/Cargo.toml b/Cargo.toml index e1f7a85..568466d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,6 +36,7 @@ diesel = { version = "2.2", features = ["uuid"] } diesel-async = { version = "0.5", features = ["deadpool", "postgres", "async-connection-wrapper"] } diesel_migrations = { version = "2.2.0", features = ["postgres"] } thiserror = "2.0.12" +actix-multipart = "0.7.2" [dependencies.tokio] version = "1.44" diff --git a/src/api/v1/users/me.rs b/src/api/v1/users/me.rs index 2cefa4f..9647002 100644 --- a/src/api/v1/users/me.rs +++ b/src/api/v1/users/me.rs @@ -1,4 +1,5 @@ use actix_web::{get, patch, web, HttpRequest, HttpResponse}; +use actix_multipart::form::{json::Json as MpJson, tempfile::TempFile, MultipartForm}; use serde::Deserialize; use crate::{error::Error, structs::Me, api::v1::auth::check_access_token, utils::get_auth_header, Data}; @@ -18,7 +19,7 @@ pub async fn res(req: HttpRequest, data: web::Data) -> Result, display_name: Option, @@ -26,8 +27,15 @@ struct NewInfo { email: Option, } +#[derive(Debug, MultipartForm)] +struct UploadForm { + #[multipart(limit = "100MB")] + avatar: Option, + json: Option>, +} + #[patch("/me")] -pub async fn update(req: HttpRequest, new_info: web::Json, data: web::Data) -> Result { +pub async fn update(req: HttpRequest, MultipartForm(form): MultipartForm, data: web::Data) -> Result { let headers = req.headers(); let auth_header = get_auth_header(headers)?; @@ -36,22 +44,32 @@ pub async fn update(req: HttpRequest, new_info: web::Json, data: web::D let uuid = check_access_token(auth_header, &mut conn).await?; - let me = Me::get(&mut conn, uuid).await?; + let mut me = Me::get(&mut conn, uuid).await?; - if let Some(username) = &new_info.username { - todo!(); + if let Some(avatar) = form.avatar { + let bytes = tokio::fs::read(avatar.file).await?; + + let byte_slice: &[u8] = &bytes; + + me.set_avatar(&data.bunny_cdn, &mut conn, data.config.bunny.cdn_url.clone(), byte_slice.into()).await?; } - if let Some(display_name) = &new_info.display_name { - todo!(); - } + if let Some(new_info) = form.json { + if let Some(username) = &new_info.username { + todo!(); + } - if let Some(password) = &new_info.password { - todo!(); - } + if let Some(display_name) = &new_info.display_name { + todo!(); + } - if let Some(email) = &new_info.email { - todo!(); + if let Some(password) = &new_info.password { + todo!(); + } + + if let Some(email) = &new_info.email { + todo!(); + } } Ok(HttpResponse::Ok().finish()) diff --git a/src/api/v1/users/mod.rs b/src/api/v1/users/mod.rs index 5259ed9..57f5f7d 100644 --- a/src/api/v1/users/mod.rs +++ b/src/api/v1/users/mod.rs @@ -9,6 +9,7 @@ pub fn web() -> Scope { web::scope("/users") .service(res) .service(me::res) + .service(me::update) .service(uuid::res) } diff --git a/src/error.rs b/src/error.rs index 8843b22..47415c0 100644 --- a/src/error.rs +++ b/src/error.rs @@ -10,7 +10,7 @@ use diesel_async::pooled_connection::PoolError as DieselPoolError; use tokio::task::JoinError; use serde_json::Error as JsonError; use toml::de::Error as TomlError; -use log::error; +use log::{debug, error}; #[derive(Debug, Error)] pub enum Error { @@ -54,6 +54,7 @@ pub enum Error { impl ResponseError for Error { fn error_response(&self) -> HttpResponse { + debug!("{:?}", self); error!("{}: {}", self.status_code(), self.to_string()); HttpResponse::build(self.status_code()) diff --git a/src/structs.rs b/src/structs.rs index 371f623..e593996 100644 --- a/src/structs.rs +++ b/src/structs.rs @@ -5,9 +5,8 @@ use diesel_async::{pooled_connection::AsyncDieselConnectionManager, RunQueryDsl} use tokio::task; use url::Url; use actix_web::web::BytesMut; -use bindet::FileType; -use crate::{error::Error, Conn, Data, schema::*}; +use crate::{error::Error, schema::*, utils::image_check, Conn, Data}; fn load_or_empty(query_result: Result, diesel::result::Error>) -> Result, diesel::result::Error> { match query_result { @@ -256,7 +255,7 @@ impl GuildBuilder { uuid: self.uuid, name: self.name, description: self.description, - icon: self.icon, + icon: self.icon.and_then(|i| i.parse().ok()), owner_uuid: self.owner_uuid, roles: roles, member_count: member_count, @@ -269,7 +268,7 @@ pub struct Guild { pub uuid: Uuid, name: String, description: Option, - icon: Option, + icon: Option, owner_uuid: Uuid, pub roles: Vec, member_count: i64, @@ -410,29 +409,13 @@ impl Guild { // FIXME: Horrible security pub async fn set_icon(&mut self, bunny_cdn: &bunny_api_tokio::Client, conn: &mut Conn, cdn_url: Url, icon: BytesMut) -> Result<(), Error> { - let ico = icon.clone(); - - let image_type = task::spawn_blocking(move || { - let buf = std::io::Cursor::new(ico.to_vec()); - - let detect = bindet::detect(buf).map_err(|e| e.kind()); - - if let Ok(Some(file_type)) = detect { - if file_type.likely_to_be == vec![FileType::Jpg] { - return String::from("jpg") - } else if file_type.likely_to_be == vec![FileType::Png] { - return String::from("png") - } - } - String::from("unknown") - }).await?; - - if image_type == "unknown" { - return Err(Error::BadRequest("Not an image".to_string())) - } + let icon_clone = icon.clone(); + let image_type = task::spawn_blocking(move || image_check(icon_clone)).await??; if let Some(icon) = &self.icon { - let relative_url = icon.trim_start_matches("https://cdn.gorb.app/"); + let relative_url = icon + .path() + .trim_start_matches('/'); bunny_cdn.storage.delete(relative_url).await?; } @@ -450,7 +433,7 @@ impl Guild { .execute(conn) .await?; - self.icon = Some(icon_url.to_string()); + self.icon = Some(icon_url); Ok(()) } @@ -684,6 +667,38 @@ impl Me { Ok(me) } + + pub async fn set_avatar(&mut self, bunny_cdn: &bunny_api_tokio::Client, conn: &mut Conn, cdn_url: Url, avatar: BytesMut) -> Result<(), Error> { + let avatar_clone = avatar.clone(); + let image_type = task::spawn_blocking(move || image_check(avatar_clone)).await??; + + if let Some(avatar) = &self.avatar { + let avatar_url: Url = avatar.parse()?; + + let relative_url = avatar_url + .path() + .trim_start_matches('/'); + + bunny_cdn.storage.delete(relative_url).await?; + } + + let path = format!("avatar/{}/avatar.{}", self.uuid, image_type); + + bunny_cdn.storage.upload(path.clone(), avatar.into()).await?; + + let avatar_url = cdn_url.join(&path)?; + + use users::dsl; + update(users::table) + .filter(dsl::uuid.eq(self.uuid)) + .set(dsl::avatar.eq(avatar_url.as_str())) + .execute(conn) + .await?; + + self.avatar = Some(avatar_url.to_string()); + + Ok(()) + } } #[derive(Deserialize)] diff --git a/src/utils.rs b/src/utils.rs index b7ddcc1..4e9d435 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,7 +1,8 @@ use actix_web::{ - cookie::{Cookie, SameSite, time::Duration}, - http::header::HeaderMap, + cookie::{time::Duration, Cookie, SameSite}, + http::header::HeaderMap, web::BytesMut, }; +use bindet::FileType; use getrandom::fill; use hex::encode; use redis::RedisError; @@ -59,6 +60,22 @@ pub fn generate_refresh_token() -> Result { Ok(encode(buf)) } +pub fn image_check(icon: BytesMut) -> Result { + let buf = std::io::Cursor::new(icon); + + let detect = bindet::detect(buf).map_err(|e| e.kind()); + + if let Ok(Some(file_type)) = detect { + if file_type.likely_to_be == vec![FileType::Jpg] { + return Ok(String::from("jpg")) + } else if file_type.likely_to_be == vec![FileType::Png] { + return Ok(String::from("png")) + } + } + + Err(Error::BadRequest("Uploaded file is not an image".to_string())) +} + impl Data { pub async fn set_cache_key( &self,