From cf333b4eba0b2d270d30678cea171012fbc32933 Mon Sep 17 00:00:00 2001 From: Radical Date: Tue, 20 May 2025 14:54:34 +0200 Subject: [PATCH 1/8] feat: add bunny-api-tokio --- Cargo.toml | 2 ++ src/config.rs | 41 +++++++++++++++++++++++++++++++++++++++++ src/main.rs | 13 ++++++++++--- 3 files changed, 53 insertions(+), 3 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 33d01e7..be98c7a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,6 +30,8 @@ uuid = { version = "1.16", features = ["serde", "v7"] } random-string = "1.1" actix-ws = "0.3.0" futures-util = "0.3.31" +bunny-api-tokio = "0.2.1" +bindet = "0.3.2" [dependencies.tokio] version = "1.44" diff --git a/src/config.rs b/src/config.rs index 65a5965..95818b3 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,14 +1,17 @@ use crate::Error; +use bunny_api_tokio::edge_storage::Endpoint; use log::debug; use serde::Deserialize; use sqlx::postgres::PgConnectOptions; use tokio::fs::read_to_string; +use url::Url; #[derive(Debug, Deserialize)] pub struct ConfigBuilder { database: Database, cache_database: CacheDatabase, web: Option, + bunny: BunnyBuilder, } #[derive(Debug, Deserialize, Clone)] @@ -36,6 +39,14 @@ struct WebBuilder { _ssl: Option, } +#[derive(Debug, Deserialize)] +struct BunnyBuilder { + api_key: String, + endpoint: String, + storage_zone: String, + cdn_url: Url, +} + impl ConfigBuilder { pub async fn load(path: String) -> Result { debug!("loading config from: {}", path); @@ -59,10 +70,31 @@ impl ConfigBuilder { } }; + let endpoint = match &*self.bunny.endpoint { + "Frankfurt" => Endpoint::Frankfurt, + "London" => Endpoint::London, + "New York" => Endpoint::NewYork, + "Los Angeles" => Endpoint::LosAngeles, + "Singapore" => Endpoint::Singapore, + "Stockholm" => Endpoint::Stockholm, + "Sao Paulo" => Endpoint::SaoPaulo, + "Johannesburg" => Endpoint::Johannesburg, + "Sydney" => Endpoint::Sydney, + url => Endpoint::Custom(url.to_string()), + }; + + let bunny = Bunny { + api_key: self.bunny.api_key, + endpoint, + storage_zone: self.bunny.storage_zone, + cdn_url: self.bunny.cdn_url, + }; + Config { database: self.database, cache_database: self.cache_database, web, + bunny, } } } @@ -72,6 +104,7 @@ pub struct Config { pub database: Database, pub cache_database: CacheDatabase, pub web: Web, + pub bunny: Bunny, } #[derive(Debug, Clone)] @@ -80,6 +113,14 @@ pub struct Web { pub port: u16, } +#[derive(Debug, Clone)] +pub struct Bunny { + pub api_key: String, + pub endpoint: Endpoint, + pub storage_zone: String, + pub cdn_url: Url, +} + impl Database { pub fn connect_options(&self) -> PgConnectOptions { PgConnectOptions::new() diff --git a/src/main.rs b/src/main.rs index fbad594..bf918dc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -25,9 +25,10 @@ struct Args { pub struct Data { pub pool: Pool, pub cache_pool: redis::Client, - pub _config: Config, + pub config: Config, pub argon2: Argon2<'static>, pub start_time: SystemTime, + pub bunny_cdn: bunny_api_tokio::Client, } #[tokio::main] @@ -48,6 +49,10 @@ async fn main() -> Result<(), Error> { let cache_pool = redis::Client::open(config.cache_database.url())?; + let mut bunny_cdn = bunny_api_tokio::Client::new(config.bunny.api_key.clone()).await?; + + bunny_cdn.storage.init(config.bunny.endpoint.clone(), config.bunny.storage_zone.clone())?; + /* TODO: Figure out if a table should be used here and if not then what. Also figure out if these should be different types from what they currently are and if we should add more "constraints" @@ -94,7 +99,8 @@ async fn main() -> Result<(), Error> { uuid uuid PRIMARY KEY NOT NULL, owner_uuid uuid NOT NULL REFERENCES users(uuid), name VARCHAR(100) NOT NULL, - description VARCHAR(300) + description VARCHAR(300), + icon VARCHAR(100) DEFAULT NULL ); CREATE TABLE IF NOT EXISTS guild_members ( uuid uuid PRIMARY KEY NOT NULL, @@ -164,10 +170,11 @@ async fn main() -> Result<(), Error> { let data = Data { pool, cache_pool, - _config: config, + config, // TODO: Possibly implement "pepper" into this (thinking it could generate one if it doesnt exist and store it on disk) argon2: Argon2::default(), start_time: SystemTime::now(), + bunny_cdn, }; HttpServer::new(move || { From cee1b41e89eb0c31d71e04428be0b779bee8b48a Mon Sep 17 00:00:00 2001 From: Radical Date: Tue, 20 May 2025 14:54:47 +0200 Subject: [PATCH 2/8] feat: implement server icons! --- src/api/v1/servers/uuid/icon.rs | 56 +++++++++++++++++++++ src/api/v1/servers/uuid/mod.rs | 3 ++ src/structs.rs | 88 ++++++++++++++++++++++++++++++--- 3 files changed, 140 insertions(+), 7 deletions(-) create mode 100644 src/api/v1/servers/uuid/icon.rs diff --git a/src/api/v1/servers/uuid/icon.rs b/src/api/v1/servers/uuid/icon.rs new file mode 100644 index 0000000..e8a828d --- /dev/null +++ b/src/api/v1/servers/uuid/icon.rs @@ -0,0 +1,56 @@ +use actix_web::{put, web, Error, HttpRequest, HttpResponse}; +use uuid::Uuid; +use futures_util::StreamExt as _; + +use crate::{api::v1::auth::check_access_token, structs::{Guild, Member}, utils::get_auth_header, Data}; + +#[put("{uuid}/icon")] +pub async fn upload( + req: HttpRequest, + path: web::Path<(Uuid,)>, + mut payload: web::Payload, + data: web::Data, +) -> Result { + let headers = req.headers(); + + let auth_header = get_auth_header(headers); + + if let Err(error) = auth_header { + return Ok(error); + } + + let guild_uuid = path.into_inner().0; + + let authorized = check_access_token(auth_header.unwrap(), &data.pool).await; + + if let Err(error) = authorized { + return Ok(error); + } + + let uuid = authorized.unwrap(); + + let member = Member::fetch_one(&data.pool, uuid, guild_uuid).await; + + if let Err(error) = member { + return Ok(error); + } + + let guild_result = Guild::fetch_one(&data.pool, guild_uuid).await; + + if let Err(error) = guild_result { + return Ok(error); + } + + let mut guild = guild_result.unwrap(); + + let mut bytes = web::BytesMut::new(); + while let Some(item) = payload.next().await { + bytes.extend_from_slice(&item?); + } + + if let Err(error) = guild.set_icon(&data.bunny_cdn, &data.pool, data.config.bunny.cdn_url.clone(), bytes).await { + return Ok(error) + } + + Ok(HttpResponse::Ok().finish()) +} diff --git a/src/api/v1/servers/uuid/mod.rs b/src/api/v1/servers/uuid/mod.rs index 8f387aa..87d9e51 100644 --- a/src/api/v1/servers/uuid/mod.rs +++ b/src/api/v1/servers/uuid/mod.rs @@ -4,6 +4,7 @@ use uuid::Uuid; mod channels; mod invites; mod roles; +mod icon; use crate::{ Data, @@ -30,6 +31,8 @@ pub fn web() -> Scope { // Invites .service(invites::get) .service(invites::create) + // Icon + .service(icon::upload) } #[get("/{uuid}")] diff --git a/src/structs.rs b/src/structs.rs index 0ef738f..92252cc 100644 --- a/src/structs.rs +++ b/src/structs.rs @@ -1,9 +1,12 @@ use std::str::FromStr; -use actix_web::HttpResponse; +use actix_web::{web::BytesMut, HttpResponse}; +use bindet::FileType; use log::error; use serde::{Deserialize, Serialize}; use sqlx::{Pool, Postgres, prelude::FromRow}; +use tokio::task; +use url::Url; use uuid::Uuid; use crate::Data; @@ -288,7 +291,7 @@ pub struct Guild { pub uuid: Uuid, name: String, description: Option, - icon: String, + icon: Option, owner_uuid: Uuid, pub roles: Vec, member_count: i64, @@ -297,7 +300,7 @@ pub struct Guild { impl Guild { pub async fn fetch_one(pool: &Pool, guild_uuid: Uuid) -> Result { let row = sqlx::query_as(&format!( - "SELECT CAST(owner_uuid AS VARCHAR), name, description FROM guilds WHERE uuid = '{}'", + "SELECT CAST(owner_uuid AS VARCHAR), name, description, icon FROM guilds WHERE uuid = '{}'", guild_uuid )) .fetch_one(pool) @@ -309,7 +312,7 @@ impl Guild { return Err(HttpResponse::InternalServerError().finish()); } - let (owner_uuid_raw, name, description): (String, String, Option) = row.unwrap(); + let (owner_uuid_raw, name, description, icon): (String, String, Option, Option) = row.unwrap(); let owner_uuid = Uuid::from_str(&owner_uuid_raw).unwrap(); @@ -321,8 +324,7 @@ impl Guild { uuid: guild_uuid, name, description, - // FIXME: This isnt supposed to be bogus - icon: String::from("bogus"), + icon, owner_uuid, roles, member_count, @@ -378,7 +380,7 @@ impl Guild { uuid: guild_uuid, name, description, - icon: "bogus".to_string(), + icon: None, owner_uuid, roles: vec![], member_count: 1, @@ -443,6 +445,78 @@ impl Guild { guild_uuid: self.uuid, }) } + + // FIXME: Horrible security + pub async fn set_icon(&mut self, bunny_cdn: &bunny_api_tokio::Client, pool: &Pool, cdn_url: Url, icon: BytesMut) -> Result<(), HttpResponse> { + let ico = icon.clone(); + + let result = 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 let Err(error) = result { + error!("{}", error); + + return Err(HttpResponse::InternalServerError().finish()) + } + + let image_type = result.unwrap(); + + if image_type == "unknown" { + return Err(HttpResponse::BadRequest().finish()) + } + + if let Some(icon) = &self.icon { + let relative_url = icon.trim_start_matches("https://cdn.gorb.app/"); + + let delete_result = bunny_cdn.storage.delete(relative_url).await; + + if let Err(error) = delete_result { + error!("{}", error); + + return Err(HttpResponse::InternalServerError().finish()) + } + } + + let path = format!("icons/{}/icon.{}", self.uuid, image_type); + + let upload_result = bunny_cdn.storage.upload(path.clone(), icon.into()).await; + + if let Err(error) = upload_result { + error!("{}", error); + + return Err(HttpResponse::InternalServerError().finish()) + } + + + let icon_url = cdn_url.join(&path).unwrap(); + + let row = sqlx::query(&format!("UPDATE guilds SET icon = $1 WHERE uuid = '{}'", self.uuid)) + .bind(icon_url.as_str()) + .execute(pool) + .await; + + if let Err(error) = row { + error!("{}", error); + + return Err(HttpResponse::InternalServerError().finish()) + } + + self.icon = Some(icon_url.to_string()); + + Ok(()) + } } #[derive(FromRow)] From b66c8f0613c75120fe6e7c07a079bc9e76afe0f4 Mon Sep 17 00:00:00 2001 From: Radical Date: Tue, 20 May 2025 18:04:44 +0200 Subject: [PATCH 3/8] feat: implement proper user and me structs --- src/api/v1/users/me.rs | 87 +++++++++++++++++++--------- src/api/v1/users/mod.rs | 29 ++-------- src/api/v1/users/uuid.rs | 30 ++-------- src/main.rs | 1 + src/structs.rs | 120 ++++++++++++++++++++++++++++++++++++++- 5 files changed, 191 insertions(+), 76 deletions(-) diff --git a/src/api/v1/users/me.rs b/src/api/v1/users/me.rs index f641678..09cb400 100644 --- a/src/api/v1/users/me.rs +++ b/src/api/v1/users/me.rs @@ -1,15 +1,7 @@ -use actix_web::{Error, HttpRequest, HttpResponse, get, web}; -use log::error; -use serde::Serialize; +use actix_web::{get, post, web, Error, HttpRequest, HttpResponse}; +use serde::Deserialize; -use crate::{Data, api::v1::auth::check_access_token, utils::get_auth_header}; - -#[derive(Serialize)] -struct Response { - uuid: String, - username: String, - display_name: String, -} +use crate::{api::v1::auth::check_access_token, structs::Me, utils::get_auth_header, Data}; #[get("/me")] pub async fn res(req: HttpRequest, data: web::Data) -> Result { @@ -29,23 +21,64 @@ pub async fn res(req: HttpRequest, data: web::Data) -> Result) = row.unwrap(); - - Ok(HttpResponse::Ok().json(Response { - uuid: uuid.to_string(), - username, - display_name: display_name.unwrap_or_default(), - })) + Ok(HttpResponse::Ok().json(me.unwrap())) +} + +#[derive(Deserialize)] +struct NewInfo { + username: Option, + display_name: Option, + password: Option, + email: Option, +} + +#[post("/me")] +pub async fn update(req: HttpRequest, new_info: web::Json, data: web::Data) -> Result { + let headers = req.headers(); + + let auth_header = get_auth_header(headers); + + if let Err(error) = auth_header { + return Ok(error); + } + + let authorized = check_access_token(auth_header.unwrap(), &data.pool).await; + + if let Err(error) = authorized { + return Ok(error); + } + + let uuid = authorized.unwrap(); + + let me_result = Me::get(&data.pool, uuid).await; + + if let Err(error) = me_result { + return Ok(error); + } + + let me = me_result; + + if let Some(username) = &new_info.username { + todo!(); + } + + if let Some(display_name) = &new_info.display_name { + 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 079cb77..f79bb5a 100644 --- a/src/api/v1/users/mod.rs +++ b/src/api/v1/users/mod.rs @@ -1,8 +1,6 @@ -use crate::{Data, api::v1::auth::check_access_token, utils::get_auth_header}; +use crate::{api::v1::auth::check_access_token, structs::User, utils::get_auth_header, Data}; use actix_web::{Error, HttpRequest, HttpResponse, Scope, get, web}; -use log::error; -use serde::{Deserialize, Serialize}; -use sqlx::prelude::FromRow; +use serde::Deserialize; mod me; mod uuid; @@ -13,14 +11,6 @@ struct RequestQuery { amount: Option, } -#[derive(Serialize, FromRow)] -struct Response { - uuid: String, - username: String, - display_name: Option, - email: String, -} - pub fn web() -> Scope { web::scope("/users") .service(res) @@ -52,18 +42,11 @@ pub async fn res( return Ok(error); } - let row = sqlx::query_as("SELECT CAST(uuid AS VARCHAR), username, display_name, email FROM users ORDER BY username LIMIT $1 OFFSET $2") - .bind(amount) - .bind(start) - .fetch_all(&data.pool) - .await; + let accounts = User::fetch_all(&data.pool, start, amount).await; - if let Err(error) = row { - error!("{}", error); - return Ok(HttpResponse::InternalServerError().finish()); + if let Err(error) = accounts { + return Ok(error); } - let accounts: Vec = row.unwrap(); - - Ok(HttpResponse::Ok().json(accounts)) + Ok(HttpResponse::Ok().json(accounts.unwrap())) } diff --git a/src/api/v1/users/uuid.rs b/src/api/v1/users/uuid.rs index 9edaffa..046734f 100644 --- a/src/api/v1/users/uuid.rs +++ b/src/api/v1/users/uuid.rs @@ -1,16 +1,8 @@ use actix_web::{Error, HttpRequest, HttpResponse, get, web}; use log::error; -use serde::Serialize; use uuid::Uuid; -use crate::{Data, api::v1::auth::check_access_token, utils::get_auth_header}; - -#[derive(Serialize, Clone)] -struct Response { - uuid: String, - username: String, - display_name: String, -} +use crate::{api::v1::auth::check_access_token, structs::User, utils::get_auth_header, Data}; #[get("/{uuid}")] pub async fn res( @@ -42,25 +34,13 @@ pub async fn res( .body(cache_hit)); } - let row = sqlx::query_as(&format!( - "SELECT username, display_name FROM users WHERE uuid = '{}'", - uuid - )) - .fetch_one(&data.pool) - .await; + let user_result = User::fetch_one(&data.pool, uuid).await; - if let Err(error) = row { - error!("{}", error); - return Ok(HttpResponse::InternalServerError().finish()); + if let Err(error) = user_result { + return Ok(error); } - let (username, display_name): (String, Option) = row.unwrap(); - - let user = Response { - uuid: uuid.to_string(), - username, - display_name: display_name.unwrap_or_default(), - }; + let user = user_result.unwrap(); let cache_result = data .set_cache_key(uuid.to_string(), user.clone(), 1800) diff --git a/src/main.rs b/src/main.rs index bf918dc..3ede686 100644 --- a/src/main.rs +++ b/src/main.rs @@ -68,6 +68,7 @@ async fn main() -> Result<(), Error> { password varchar(512) NOT NULL, email varchar(100) NOT NULL, email_verified boolean NOT NULL DEFAULT FALSE, + avatar varchar(100) DEFAULT NULL, is_deleted boolean NOT NULL DEFAULT FALSE, deleted_at int8 DEFAULT NULL, CONSTRAINT unique_username_active UNIQUE NULLS NOT DISTINCT (username, is_deleted), diff --git a/src/structs.rs b/src/structs.rs index 92252cc..26bd89d 100644 --- a/src/structs.rs +++ b/src/structs.rs @@ -740,7 +740,7 @@ pub struct Message { } #[derive(FromRow)] -pub struct InviteBuilder { +struct InviteBuilder { id: String, user_uuid: String, guild_uuid: String, @@ -784,3 +784,121 @@ impl Invite { Ok(invite.unwrap().build()) } } + +#[derive(FromRow, Clone)] +struct UserBuilder { + uuid: String, + username: String, + display_name: Option, + avatar: Option, + email: Option, +} + +impl UserBuilder { + fn build(self) -> User { + User { + uuid: Uuid::from_str(&self.uuid).unwrap(), + username: self.username, + display_name: self.display_name, + avatar: self.avatar, + email: self.email, + } + } +} + +#[derive(Serialize, Clone)] +pub struct User { + uuid: Uuid, + username: String, + display_name: Option, + avatar: Option, + #[serde(skip_serializing_if = "Option::is_none")] + email: Option, +} + +impl User { + pub async fn fetch_one(pool: &Pool, user_uuid: Uuid) -> Result { + let user_result: Result = + sqlx::query_as(&format!("SELECT CAST(uuid AS VARCHAR), username, display_name, avatar, email FROM users WHERE uuid = '{}'", user_uuid)) + .fetch_one(pool) + .await; + + if let Err(error) = user_result { + error!("{}", error); + + return Err(HttpResponse::InternalServerError().finish()); + } + + let mut user = user_result.unwrap(); + + // Override email since it shouldn't be returned by this (FromRow impl kinda sucks since it doesnt automatically count missing columns as Nulls) + user.email = None; + + Ok(user.build()) + } + + pub async fn fetch_all(pool: &Pool, start: i32, amount: i32) -> Result, HttpResponse> { + let row = sqlx::query_as("SELECT CAST(uuid AS VARCHAR), username, display_name, avatar, email FROM users ORDER BY username LIMIT $1 OFFSET $2") + .bind(amount) + .bind(start) + .fetch_all(pool) + .await; + + if let Err(error) = row { + error!("{}", error); + return Err(HttpResponse::InternalServerError().finish()); + } + + let accounts: Vec = row.unwrap(); + + Ok(accounts.iter().map(|u| u.clone().build()).collect()) + } +} + +#[derive(FromRow)] +struct MeBuilder { + uuid: String, + username: String, + display_name: Option, + avatar: Option, + email: String, + email_verified: bool, +} + +impl MeBuilder { + fn build(self) -> Me { + Me { + uuid: Uuid::from_str(&self.uuid).unwrap(), + username: self.username, + display_name: self.display_name, + avatar: self.avatar, + email: self.email, + email_verified: self.email_verified, + } + } +} + +#[derive(Serialize)] +pub struct Me { + uuid: Uuid, + username: String, + display_name: Option, + avatar: Option, + email: String, + email_verified: bool, +} + +impl Me { + pub async fn get(pool: &Pool, user_uuid: Uuid) -> Result { + let me: Result = sqlx::query_as(&format!("SELECT CAST(uuid AS VARCHAR), username, display_name, avatar, email, email_verified FROM users WHERE uuid = '{}'", user_uuid)) + .fetch_one(pool) + .await; + + if let Err(error) = me { + error!("{}", error); + return Err(HttpResponse::InternalServerError().finish()) + } + + Ok(me.unwrap().build()) + } +} From 4124b08bb2232a60e38d059624da3f741701d2ac Mon Sep 17 00:00:00 2001 From: Radical Date: Tue, 20 May 2025 22:20:32 +0200 Subject: [PATCH 4/8] style: change function name --- src/api/v1/users/mod.rs | 2 +- src/structs.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/api/v1/users/mod.rs b/src/api/v1/users/mod.rs index f79bb5a..de9aec0 100644 --- a/src/api/v1/users/mod.rs +++ b/src/api/v1/users/mod.rs @@ -42,7 +42,7 @@ pub async fn res( return Ok(error); } - let accounts = User::fetch_all(&data.pool, start, amount).await; + let accounts = User::fetch_amount(&data.pool, start, amount).await; if let Err(error) = accounts { return Ok(error); diff --git a/src/structs.rs b/src/structs.rs index 26bd89d..a6e1587 100644 --- a/src/structs.rs +++ b/src/structs.rs @@ -837,7 +837,7 @@ impl User { Ok(user.build()) } - pub async fn fetch_all(pool: &Pool, start: i32, amount: i32) -> Result, HttpResponse> { + pub async fn fetch_amount(pool: &Pool, start: i32, amount: i32) -> Result, HttpResponse> { let row = sqlx::query_as("SELECT CAST(uuid AS VARCHAR), username, display_name, avatar, email FROM users ORDER BY username LIMIT $1 OFFSET $2") .bind(amount) .bind(start) From 85f6db499fd01d38c7b4374fec53ae13508f78c1 Mon Sep 17 00:00:00 2001 From: Radical Date: Tue, 20 May 2025 22:20:45 +0200 Subject: [PATCH 5/8] fix: use patch request for updating user --- src/api/v1/users/me.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/api/v1/users/me.rs b/src/api/v1/users/me.rs index 09cb400..bd6a12d 100644 --- a/src/api/v1/users/me.rs +++ b/src/api/v1/users/me.rs @@ -1,4 +1,4 @@ -use actix_web::{get, post, web, Error, HttpRequest, HttpResponse}; +use actix_web::{get, patch, web, Error, HttpRequest, HttpResponse}; use serde::Deserialize; use crate::{api::v1::auth::check_access_token, structs::Me, utils::get_auth_header, Data}; @@ -38,7 +38,7 @@ struct NewInfo { email: Option, } -#[post("/me")] +#[patch("/me")] pub async fn update(req: HttpRequest, new_info: web::Json, data: web::Data) -> Result { let headers = req.headers(); @@ -62,7 +62,7 @@ pub async fn update(req: HttpRequest, new_info: web::Json, data: web::D return Ok(error); } - let me = me_result; + let me = me_result.unwrap(); if let Some(username) = &new_info.username { todo!(); From 81f7527c79e7c31e30938781e76cae9757147e18 Mon Sep 17 00:00:00 2001 From: Radical Date: Fri, 23 May 2025 20:32:43 +0200 Subject: [PATCH 6/8] feat: move image check to utils.rs --- src/structs.rs | 35 +++++++++-------------------------- src/utils.rs | 21 +++++++++++++++++++-- 2 files changed, 28 insertions(+), 28 deletions(-) diff --git a/src/structs.rs b/src/structs.rs index 371f623..b4403ed 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(()) } 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, From d6364a0dc007959f0b6e5e11b8537dcd13256594 Mon Sep 17 00:00:00 2001 From: Radical Date: Fri, 23 May 2025 20:33:42 +0200 Subject: [PATCH 7/8] feat: add debug error printing Got a random error message while coding (still have no idea what sent it), this will let you run the code with debug logging if you arent sure where errors are coming from --- src/error.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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()) From 97072d54d180eedddd5b488b107a899bd9e86533 Mon Sep 17 00:00:00 2001 From: Radical Date: Fri, 23 May 2025 20:33:58 +0200 Subject: [PATCH 8/8] feat: user avatars --- Cargo.toml | 1 + src/api/v1/users/me.rs | 44 +++++++++++++++++++++++++++++------------ src/api/v1/users/mod.rs | 1 + src/structs.rs | 32 ++++++++++++++++++++++++++++++ 4 files changed, 65 insertions(+), 13 deletions(-) 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/structs.rs b/src/structs.rs index b4403ed..e593996 100644 --- a/src/structs.rs +++ b/src/structs.rs @@ -667,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)]