From cf333b4eba0b2d270d30678cea171012fbc32933 Mon Sep 17 00:00:00 2001 From: Radical Date: Tue, 20 May 2025 14:54:34 +0200 Subject: [PATCH 01/92] 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 02/92] 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 03/92] 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 04/92] 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 05/92] 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 27fbb6508e445b3eecd21dc9bc445b1eff72134d Mon Sep 17 00:00:00 2001 From: Radical Date: Wed, 21 May 2025 20:47:45 +0200 Subject: [PATCH 06/92] build: switch sqlx to diesel --- Cargo.toml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 33d01e7..e6dcd84 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,7 +21,6 @@ regex = "1.11" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" simple_logger = "5.0.0" -sqlx = { version = "0.8", features = ["runtime-tokio", "tls-native-tls", "postgres"] } redis = { version = "0.31.0", features= ["tokio-comp"] } tokio-tungstenite = { version = "0.26", features = ["native-tls", "url"] } toml = "0.8" @@ -30,6 +29,9 @@ uuid = { version = "1.16", features = ["serde", "v7"] } random-string = "1.1" actix-ws = "0.3.0" futures-util = "0.3.31" +deadpool = "0.12" +diesel = "2.2" +diesel-async = { version = "0.5", features = ["deadpool", "postgres"] } [dependencies.tokio] version = "1.44" From b9c7bda2b15ea19754d046655fb92f5a6972f8e2 Mon Sep 17 00:00:00 2001 From: Radical Date: Wed, 21 May 2025 20:48:09 +0200 Subject: [PATCH 07/92] feat: use diesel in main fn and data struct --- src/main.rs | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/src/main.rs b/src/main.rs index fbad594..9036665 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,14 +3,19 @@ use actix_web::{App, HttpServer, web}; use argon2::Argon2; use clap::Parser; use simple_logger::SimpleLogger; -use sqlx::{PgPool, Pool, Postgres}; +use diesel_async::pooled_connection::AsyncDieselConnectionManager; +use diesel_async::pooled_connection::deadpool::Pool; +use diesel_async::RunQueryDsl; use std::time::SystemTime; mod config; use config::{Config, ConfigBuilder}; mod api; +type Conn = deadpool::managed::Object>; + pub mod structs; pub mod utils; +pub mod tables; type Error = Box; @@ -23,7 +28,7 @@ struct Args { #[derive(Clone)] pub struct Data { - pub pool: Pool, + pub pool: deadpool::managed::Pool, Conn>, pub cache_pool: redis::Client, pub _config: Config, pub argon2: Argon2<'static>, @@ -44,17 +49,21 @@ async fn main() -> Result<(), Error> { let web = config.web.clone(); - let pool = PgPool::connect_with(config.database.connect_options()).await?; + // create a new connection pool with the default config + let pool_config = AsyncDieselConnectionManager::::new(config.database.url()); + let pool = Pool::builder(pool_config).build()?; let cache_pool = redis::Client::open(config.cache_database.url())?; + let mut conn = pool.get().await?; + /* 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" TODO: References to time should be removed in favor of using the timestamp built in to UUIDv7 (apart from deleted_at in users) */ - sqlx::raw_sql( + diesel::sql_query( r#" CREATE TABLE IF NOT EXISTS users ( uuid uuid PRIMARY KEY NOT NULL, @@ -141,7 +150,7 @@ async fn main() -> Result<(), Error> { ); "#, ) - .execute(&pool) + .execute(&mut conn) .await?; /* From 746949f0e54284f907ce414392bd62dc482a453e Mon Sep 17 00:00:00 2001 From: Radical Date: Wed, 21 May 2025 20:48:43 +0200 Subject: [PATCH 08/92] feat: use url format --- src/config.rs | 26 ++++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/src/config.rs b/src/config.rs index 65a5965..4e8fc21 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,7 +1,6 @@ use crate::Error; use log::debug; use serde::Deserialize; -use sqlx::postgres::PgConnectOptions; use tokio::fs::read_to_string; #[derive(Debug, Deserialize)] @@ -81,13 +80,24 @@ pub struct Web { } impl Database { - pub fn connect_options(&self) -> PgConnectOptions { - PgConnectOptions::new() - .database(&self.database) - .host(&self.host) - .username(&self.username) - .password(&self.password) - .port(self.port) + pub fn url(&self) -> String { + let mut url = String::from("postgres://"); + + url += &self.username; + + url += ":"; + url += &self.password; + + url += "@"; + + url += &self.host; + url += ":"; + url += &self.port.to_string(); + + url += "/"; + url += &self.database; + + url } } From da804cd43637150379df28aabfaad2185628d66d Mon Sep 17 00:00:00 2001 From: Radical Date: Wed, 21 May 2025 20:49:13 +0200 Subject: [PATCH 09/92] feat: use diesel on Channel and ChannelPermission structs --- src/structs.rs | 21 +++++++++------------ 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/src/structs.rs b/src/structs.rs index 1b339b1..7cec7c9 100644 --- a/src/structs.rs +++ b/src/structs.rs @@ -1,14 +1,15 @@ use std::str::FromStr; use actix_web::HttpResponse; +use diesel::Selectable; use log::error; use serde::{Deserialize, Serialize}; -use sqlx::{Pool, Postgres, prelude::FromRow}; use uuid::Uuid; -use crate::Data; +use crate::{Conn, Data, tables::*}; -#[derive(Serialize, Deserialize, Clone)] +#[derive(Serialize, Deserialize, Clone, Selectable)] +#[diesel(table_name = channels)] pub struct Channel { pub uuid: Uuid, pub guild_uuid: Uuid, @@ -17,7 +18,7 @@ pub struct Channel { pub permissions: Vec, } -#[derive(Serialize, Clone, FromRow)] +#[derive(Serialize, Clone)] struct ChannelPermissionBuilder { role_uuid: String, permissions: i32, @@ -32,7 +33,8 @@ impl ChannelPermissionBuilder { } } -#[derive(Serialize, Deserialize, Clone, FromRow)] +#[derive(Serialize, Deserialize, Clone, Selectable)] +#[diesel(table_name = channel_permissions)] pub struct ChannelPermission { pub role_uuid: Uuid, pub permissions: i32, @@ -40,15 +42,10 @@ pub struct ChannelPermission { impl Channel { pub async fn fetch_all( - pool: &Pool, + conn: &mut Conn, guild_uuid: Uuid, ) -> Result, HttpResponse> { - let row = sqlx::query_as(&format!( - "SELECT CAST(uuid AS VARCHAR), name, description FROM channels WHERE guild_uuid = '{}'", - guild_uuid - )) - .fetch_all(pool) - .await; + if let Err(error) = row { error!("{}", error); From f1d5b4316eeccac7be3ceee0d5d8f2e57d2cdf9d Mon Sep 17 00:00:00 2001 From: Radical Date: Wed, 21 May 2025 20:49:20 +0200 Subject: [PATCH 10/92] feat: add tables.rs --- src/tables.rs | 109 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 src/tables.rs diff --git a/src/tables.rs b/src/tables.rs new file mode 100644 index 0000000..3dbd38b --- /dev/null +++ b/src/tables.rs @@ -0,0 +1,109 @@ +use diesel::table; + +table! { + users (uuid) { + uuid -> Uuid, + username -> VarChar, + display_name -> Nullable, + password -> VarChar, + email -> VarChar, + email_verified -> Bool, + is_deleted -> Bool, + deleted_at -> Int8, + } +} + +table! { + instance_permissions (uuid) { + uuid -> Uuid, + administrator -> Bool, + } +} + +table! { + refresh_tokens (token) { + token -> VarChar, + uuid -> Uuid, + created_at -> Int8, + device_name -> VarChar, + } +} + +table! { + access_tokens (token) { + token -> VarChar, + refresh_token -> VarChar, + uuid -> Uuid, + created_at -> Int8 + } +} + +table! { + guilds (uuid) { + uuid -> Uuid, + owner_uuid -> Uuid, + name -> VarChar, + description -> VarChar + } +} + +table! { + guild_members (uuid) { + uuid -> Uuid, + guild_uuid -> Uuid, + user_uuid -> Uuid, + nickname -> VarChar, + } +} + +table! { + roles (uuid, guild_uuid) { + uuid -> Uuid, + guild_uuid -> Uuid, + name -> VarChar, + color -> Int4, + position -> Int4, + permissions -> Int8, + } +} + +table! { + role_members (role_uuid, member_uuid) { + role_uuid -> Uuid, + member_uuid -> Uuid, + } +} + +table! { + channels (uuid) { + uuid -> Uuid, + guild_uuid -> Uuid, + name -> VarChar, + description -> VarChar, + } +} + +table! { + channel_permissions (channel_uuid, role_uuid) { + channel_uuid -> Uuid, + role_uuid -> Uuid, + permissions -> Int8, + } +} + +table! { + messages (uuid) { + uuid -> Uuid, + channel_uuid -> Uuid, + user_uuid -> Uuid, + message -> VarChar, + } +} + +table! { + invites (id) { + id -> VarChar, + guild_uuid -> Uuid, + user_uuid -> Uuid, + } +} From a6d35b0ba2c29e99fcf445bcde038f80c1b3e0a4 Mon Sep 17 00:00:00 2001 From: Radical Date: Wed, 21 May 2025 21:49:01 +0200 Subject: [PATCH 11/92] feat: use diesel-cli instead of hand writing tables after reading the documentation, crazy right? I figured out i was making my life hard, this makes my life easy again --- diesel.toml | 9 + migrations/.keep | 0 .../down.sql | 6 + .../up.sql | 36 ++++ .../2025-05-21-192435_create_users/down.sql | 4 + .../2025-05-21-192435_create_users/up.sql | 20 +++ .../down.sql | 2 + .../up.sql | 5 + .../2025-05-21-193321_create_tokens/down.sql | 3 + .../2025-05-21-193321_create_tokens/up.sql | 13 ++ .../2025-05-21-193500_create_guilds/down.sql | 3 + .../2025-05-21-193500_create_guilds/up.sql | 13 ++ .../2025-05-21-193620_create_roles/down.sql | 3 + .../2025-05-21-193620_create_roles/up.sql | 15 ++ .../down.sql | 3 + .../2025-05-21-193745_create_channels/up.sql | 13 ++ .../down.sql | 2 + .../2025-05-21-193954_create_messages/up.sql | 7 + .../2025-05-21-194207_create_invites/down.sql | 2 + .../2025-05-21-194207_create_invites/up.sql | 6 + src/main.rs | 97 +---------- src/schema.rs | 156 ++++++++++++++++++ src/structs.rs | 2 +- src/tables.rs | 109 ------------ 24 files changed, 323 insertions(+), 206 deletions(-) create mode 100644 diesel.toml create mode 100644 migrations/.keep create mode 100644 migrations/00000000000000_diesel_initial_setup/down.sql create mode 100644 migrations/00000000000000_diesel_initial_setup/up.sql create mode 100644 migrations/2025-05-21-192435_create_users/down.sql create mode 100644 migrations/2025-05-21-192435_create_users/up.sql create mode 100644 migrations/2025-05-21-192936_create_instance_permissions/down.sql create mode 100644 migrations/2025-05-21-192936_create_instance_permissions/up.sql create mode 100644 migrations/2025-05-21-193321_create_tokens/down.sql create mode 100644 migrations/2025-05-21-193321_create_tokens/up.sql create mode 100644 migrations/2025-05-21-193500_create_guilds/down.sql create mode 100644 migrations/2025-05-21-193500_create_guilds/up.sql create mode 100644 migrations/2025-05-21-193620_create_roles/down.sql create mode 100644 migrations/2025-05-21-193620_create_roles/up.sql create mode 100644 migrations/2025-05-21-193745_create_channels/down.sql create mode 100644 migrations/2025-05-21-193745_create_channels/up.sql create mode 100644 migrations/2025-05-21-193954_create_messages/down.sql create mode 100644 migrations/2025-05-21-193954_create_messages/up.sql create mode 100644 migrations/2025-05-21-194207_create_invites/down.sql create mode 100644 migrations/2025-05-21-194207_create_invites/up.sql create mode 100644 src/schema.rs delete mode 100644 src/tables.rs diff --git a/diesel.toml b/diesel.toml new file mode 100644 index 0000000..a0d61bf --- /dev/null +++ b/diesel.toml @@ -0,0 +1,9 @@ +# For documentation on how to configure this file, +# see https://diesel.rs/guides/configuring-diesel-cli + +[print_schema] +file = "src/schema.rs" +custom_type_derives = ["diesel::query_builder::QueryId", "Clone"] + +[migrations_directory] +dir = "migrations" diff --git a/migrations/.keep b/migrations/.keep new file mode 100644 index 0000000..e69de29 diff --git a/migrations/00000000000000_diesel_initial_setup/down.sql b/migrations/00000000000000_diesel_initial_setup/down.sql new file mode 100644 index 0000000..a9f5260 --- /dev/null +++ b/migrations/00000000000000_diesel_initial_setup/down.sql @@ -0,0 +1,6 @@ +-- This file was automatically created by Diesel to setup helper functions +-- and other internal bookkeeping. This file is safe to edit, any future +-- changes will be added to existing projects as new migrations. + +DROP FUNCTION IF EXISTS diesel_manage_updated_at(_tbl regclass); +DROP FUNCTION IF EXISTS diesel_set_updated_at(); diff --git a/migrations/00000000000000_diesel_initial_setup/up.sql b/migrations/00000000000000_diesel_initial_setup/up.sql new file mode 100644 index 0000000..d68895b --- /dev/null +++ b/migrations/00000000000000_diesel_initial_setup/up.sql @@ -0,0 +1,36 @@ +-- This file was automatically created by Diesel to setup helper functions +-- and other internal bookkeeping. This file is safe to edit, any future +-- changes will be added to existing projects as new migrations. + + + + +-- Sets up a trigger for the given table to automatically set a column called +-- `updated_at` whenever the row is modified (unless `updated_at` was included +-- in the modified columns) +-- +-- # Example +-- +-- ```sql +-- CREATE TABLE users (id SERIAL PRIMARY KEY, updated_at TIMESTAMP NOT NULL DEFAULT NOW()); +-- +-- SELECT diesel_manage_updated_at('users'); +-- ``` +CREATE OR REPLACE FUNCTION diesel_manage_updated_at(_tbl regclass) RETURNS VOID AS $$ +BEGIN + EXECUTE format('CREATE TRIGGER set_updated_at BEFORE UPDATE ON %s + FOR EACH ROW EXECUTE PROCEDURE diesel_set_updated_at()', _tbl); +END; +$$ LANGUAGE plpgsql; + +CREATE OR REPLACE FUNCTION diesel_set_updated_at() RETURNS trigger AS $$ +BEGIN + IF ( + NEW IS DISTINCT FROM OLD AND + NEW.updated_at IS NOT DISTINCT FROM OLD.updated_at + ) THEN + NEW.updated_at := current_timestamp; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; diff --git a/migrations/2025-05-21-192435_create_users/down.sql b/migrations/2025-05-21-192435_create_users/down.sql new file mode 100644 index 0000000..a54826f --- /dev/null +++ b/migrations/2025-05-21-192435_create_users/down.sql @@ -0,0 +1,4 @@ +-- This file should undo anything in `up.sql` +DROP INDEX idx_unique_username_active; +DROP INDEX idx_unique_email_active; +DROP TABLE users; diff --git a/migrations/2025-05-21-192435_create_users/up.sql b/migrations/2025-05-21-192435_create_users/up.sql new file mode 100644 index 0000000..0262507 --- /dev/null +++ b/migrations/2025-05-21-192435_create_users/up.sql @@ -0,0 +1,20 @@ +-- Your SQL goes here +CREATE TABLE users ( + uuid uuid PRIMARY KEY NOT NULL, + username varchar(32) NOT NULL, + display_name varchar(64) DEFAULT NULL, + password varchar(512) NOT NULL, + email varchar(100) NOT NULL, + email_verified boolean NOT NULL DEFAULT FALSE, + is_deleted boolean NOT NULL DEFAULT FALSE, + deleted_at int8 DEFAULT NULL, + CONSTRAINT unique_username_active UNIQUE NULLS NOT DISTINCT (username, is_deleted), + CONSTRAINT unique_email_active UNIQUE NULLS NOT DISTINCT (email, is_deleted) +); + +CREATE UNIQUE INDEX idx_unique_username_active +ON users(username) +WHERE is_deleted = FALSE; +CREATE UNIQUE INDEX idx_unique_email_active +ON users(email) +WHERE is_deleted = FALSE; diff --git a/migrations/2025-05-21-192936_create_instance_permissions/down.sql b/migrations/2025-05-21-192936_create_instance_permissions/down.sql new file mode 100644 index 0000000..c72fb0f --- /dev/null +++ b/migrations/2025-05-21-192936_create_instance_permissions/down.sql @@ -0,0 +1,2 @@ +-- This file should undo anything in `up.sql` +DROP TABLE instance_permissions; diff --git a/migrations/2025-05-21-192936_create_instance_permissions/up.sql b/migrations/2025-05-21-192936_create_instance_permissions/up.sql new file mode 100644 index 0000000..f3dd755 --- /dev/null +++ b/migrations/2025-05-21-192936_create_instance_permissions/up.sql @@ -0,0 +1,5 @@ +-- Your SQL goes here +CREATE TABLE instance_permissions ( + uuid uuid PRIMARY KEY NOT NULL REFERENCES users(uuid), + administrator boolean NOT NULL DEFAULT FALSE +); diff --git a/migrations/2025-05-21-193321_create_tokens/down.sql b/migrations/2025-05-21-193321_create_tokens/down.sql new file mode 100644 index 0000000..4555fe6 --- /dev/null +++ b/migrations/2025-05-21-193321_create_tokens/down.sql @@ -0,0 +1,3 @@ +-- This file should undo anything in `up.sql` +DROP TABLE access_tokens; +DROP TABLE refresh_tokens; diff --git a/migrations/2025-05-21-193321_create_tokens/up.sql b/migrations/2025-05-21-193321_create_tokens/up.sql new file mode 100644 index 0000000..b3fb554 --- /dev/null +++ b/migrations/2025-05-21-193321_create_tokens/up.sql @@ -0,0 +1,13 @@ +-- Your SQL goes here +CREATE TABLE refresh_tokens ( + token varchar(64) PRIMARY KEY UNIQUE NOT NULL, + uuid uuid NOT NULL REFERENCES users(uuid), + created_at int8 NOT NULL, + device_name varchar(16) NOT NULL +); +CREATE TABLE access_tokens ( + token varchar(32) PRIMARY KEY UNIQUE NOT NULL, + refresh_token varchar(64) UNIQUE NOT NULL REFERENCES refresh_tokens(token) ON UPDATE CASCADE ON DELETE CASCADE, + uuid uuid NOT NULL REFERENCES users(uuid), + created_at int8 NOT NULL +); diff --git a/migrations/2025-05-21-193500_create_guilds/down.sql b/migrations/2025-05-21-193500_create_guilds/down.sql new file mode 100644 index 0000000..12ae87e --- /dev/null +++ b/migrations/2025-05-21-193500_create_guilds/down.sql @@ -0,0 +1,3 @@ +-- This file should undo anything in `up.sql` +DROP TABLE guild_members; +DROP TABLE guilds; diff --git a/migrations/2025-05-21-193500_create_guilds/up.sql b/migrations/2025-05-21-193500_create_guilds/up.sql new file mode 100644 index 0000000..268c597 --- /dev/null +++ b/migrations/2025-05-21-193500_create_guilds/up.sql @@ -0,0 +1,13 @@ +-- Your SQL goes here +CREATE TABLE guilds ( + uuid uuid PRIMARY KEY NOT NULL, + owner_uuid uuid NOT NULL REFERENCES users(uuid), + name VARCHAR(100) NOT NULL, + description VARCHAR(300) +); +CREATE TABLE guild_members ( + uuid uuid PRIMARY KEY NOT NULL, + guild_uuid uuid NOT NULL REFERENCES guilds(uuid) ON DELETE CASCADE, + user_uuid uuid NOT NULL REFERENCES users(uuid), + nickname VARCHAR(100) DEFAULT NULL +); diff --git a/migrations/2025-05-21-193620_create_roles/down.sql b/migrations/2025-05-21-193620_create_roles/down.sql new file mode 100644 index 0000000..f215a04 --- /dev/null +++ b/migrations/2025-05-21-193620_create_roles/down.sql @@ -0,0 +1,3 @@ +-- This file should undo anything in `up.sql` +DROP TABLE role_members; +DROP TABLE roles; diff --git a/migrations/2025-05-21-193620_create_roles/up.sql b/migrations/2025-05-21-193620_create_roles/up.sql new file mode 100644 index 0000000..55d051d --- /dev/null +++ b/migrations/2025-05-21-193620_create_roles/up.sql @@ -0,0 +1,15 @@ +-- Your SQL goes here +CREATE TABLE roles ( + uuid uuid UNIQUE NOT NULL, + guild_uuid uuid NOT NULL REFERENCES guilds(uuid) ON DELETE CASCADE, + name VARCHAR(50) NOT NULL, + color int NOT NULL DEFAULT 16777215, + position int NOT NULL, + permissions int8 NOT NULL DEFAULT 0, + PRIMARY KEY (uuid, guild_uuid) +); +CREATE TABLE role_members ( + role_uuid uuid NOT NULL REFERENCES roles(uuid) ON DELETE CASCADE, + member_uuid uuid NOT NULL REFERENCES guild_members(uuid) ON DELETE CASCADE, + PRIMARY KEY (role_uuid, member_uuid) +); diff --git a/migrations/2025-05-21-193745_create_channels/down.sql b/migrations/2025-05-21-193745_create_channels/down.sql new file mode 100644 index 0000000..6334604 --- /dev/null +++ b/migrations/2025-05-21-193745_create_channels/down.sql @@ -0,0 +1,3 @@ +-- This file should undo anything in `up.sql` +DROP TABLE channel_permissions; +DROP TABLE channels; diff --git a/migrations/2025-05-21-193745_create_channels/up.sql b/migrations/2025-05-21-193745_create_channels/up.sql new file mode 100644 index 0000000..2cce7f2 --- /dev/null +++ b/migrations/2025-05-21-193745_create_channels/up.sql @@ -0,0 +1,13 @@ +-- Your SQL goes here +CREATE TABLE channels ( + uuid uuid PRIMARY KEY NOT NULL, + guild_uuid uuid NOT NULL REFERENCES guilds(uuid) ON DELETE CASCADE, + name varchar(32) NOT NULL, + description varchar(500) NOT NULL +); +CREATE TABLE channel_permissions ( + channel_uuid uuid NOT NULL REFERENCES channels(uuid) ON DELETE CASCADE, + role_uuid uuid NOT NULL REFERENCES roles(uuid) ON DELETE CASCADE, + permissions int8 NOT NULL DEFAULT 0, + PRIMARY KEY (channel_uuid, role_uuid) +); diff --git a/migrations/2025-05-21-193954_create_messages/down.sql b/migrations/2025-05-21-193954_create_messages/down.sql new file mode 100644 index 0000000..bb9ce09 --- /dev/null +++ b/migrations/2025-05-21-193954_create_messages/down.sql @@ -0,0 +1,2 @@ +-- This file should undo anything in `up.sql` +DROP TABLE messages; diff --git a/migrations/2025-05-21-193954_create_messages/up.sql b/migrations/2025-05-21-193954_create_messages/up.sql new file mode 100644 index 0000000..1510974 --- /dev/null +++ b/migrations/2025-05-21-193954_create_messages/up.sql @@ -0,0 +1,7 @@ +-- Your SQL goes here +CREATE TABLE messages ( + uuid uuid PRIMARY KEY NOT NULL, + channel_uuid uuid NOT NULL REFERENCES channels(uuid) ON DELETE CASCADE, + user_uuid uuid NOT NULL REFERENCES users(uuid), + message varchar(4000) NOT NULL +); diff --git a/migrations/2025-05-21-194207_create_invites/down.sql b/migrations/2025-05-21-194207_create_invites/down.sql new file mode 100644 index 0000000..03b72de --- /dev/null +++ b/migrations/2025-05-21-194207_create_invites/down.sql @@ -0,0 +1,2 @@ +-- This file should undo anything in `up.sql` +DROP TABLE invites; diff --git a/migrations/2025-05-21-194207_create_invites/up.sql b/migrations/2025-05-21-194207_create_invites/up.sql new file mode 100644 index 0000000..795b39c --- /dev/null +++ b/migrations/2025-05-21-194207_create_invites/up.sql @@ -0,0 +1,6 @@ +-- Your SQL goes here +CREATE TABLE invites ( + id varchar(32) PRIMARY KEY NOT NULL, + guild_uuid uuid NOT NULL REFERENCES guilds(uuid) ON DELETE CASCADE, + user_uuid uuid NOT NULL REFERENCES users(uuid) +); diff --git a/src/main.rs b/src/main.rs index 9036665..0a9d493 100644 --- a/src/main.rs +++ b/src/main.rs @@ -15,7 +15,7 @@ type Conn = deadpool::managed::Object; @@ -57,101 +57,6 @@ async fn main() -> Result<(), Error> { let mut conn = pool.get().await?; - /* - 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" - - TODO: References to time should be removed in favor of using the timestamp built in to UUIDv7 (apart from deleted_at in users) - */ - diesel::sql_query( - r#" - CREATE TABLE IF NOT EXISTS users ( - uuid uuid PRIMARY KEY NOT NULL, - username varchar(32) NOT NULL, - display_name varchar(64) DEFAULT NULL, - password varchar(512) NOT NULL, - email varchar(100) NOT NULL, - email_verified boolean NOT NULL DEFAULT FALSE, - is_deleted boolean NOT NULL DEFAULT FALSE, - deleted_at int8 DEFAULT NULL, - CONSTRAINT unique_username_active UNIQUE NULLS NOT DISTINCT (username, is_deleted), - CONSTRAINT unique_email_active UNIQUE NULLS NOT DISTINCT (email, is_deleted) - ); - CREATE UNIQUE INDEX IF NOT EXISTS idx_unique_username_active - ON users(username) - WHERE is_deleted = FALSE; - CREATE UNIQUE INDEX IF NOT EXISTS idx_unique_email_active - ON users(email) - WHERE is_deleted = FALSE; - CREATE TABLE IF NOT EXISTS instance_permissions ( - uuid uuid NOT NULL REFERENCES users(uuid), - administrator boolean NOT NULL DEFAULT FALSE - ); - CREATE TABLE IF NOT EXISTS refresh_tokens ( - token varchar(64) PRIMARY KEY UNIQUE NOT NULL, - uuid uuid NOT NULL REFERENCES users(uuid), - created_at int8 NOT NULL, - device_name varchar(16) NOT NULL - ); - CREATE TABLE IF NOT EXISTS access_tokens ( - token varchar(32) PRIMARY KEY UNIQUE NOT NULL, - refresh_token varchar(64) UNIQUE NOT NULL REFERENCES refresh_tokens(token) ON UPDATE CASCADE ON DELETE CASCADE, - uuid uuid NOT NULL REFERENCES users(uuid), - created_at int8 NOT NULL - ); - CREATE TABLE IF NOT EXISTS guilds ( - uuid uuid PRIMARY KEY NOT NULL, - owner_uuid uuid NOT NULL REFERENCES users(uuid), - name VARCHAR(100) NOT NULL, - description VARCHAR(300) - ); - CREATE TABLE IF NOT EXISTS guild_members ( - uuid uuid PRIMARY KEY NOT NULL, - guild_uuid uuid NOT NULL REFERENCES guilds(uuid) ON DELETE CASCADE, - user_uuid uuid NOT NULL REFERENCES users(uuid), - nickname VARCHAR(100) DEFAULT NULL - ); - CREATE TABLE IF NOT EXISTS roles ( - uuid uuid UNIQUE NOT NULL, - guild_uuid uuid NOT NULL REFERENCES guilds(uuid) ON DELETE CASCADE, - name VARCHAR(50) NOT NULL, - color int NOT NULL DEFAULT 16777215, - position int NOT NULL, - permissions int8 NOT NULL DEFAULT 0, - PRIMARY KEY (uuid, guild_uuid) - ); - CREATE TABLE IF NOT EXISTS role_members ( - role_uuid uuid NOT NULL REFERENCES roles(uuid) ON DELETE CASCADE, - member_uuid uuid NOT NULL REFERENCES guild_members(uuid) ON DELETE CASCADE, - PRIMARY KEY (role_uuid, member_uuid) - ); - CREATE TABLE IF NOT EXISTS channels ( - uuid uuid PRIMARY KEY NOT NULL, - guild_uuid uuid NOT NULL REFERENCES guilds(uuid) ON DELETE CASCADE, - name varchar(32) NOT NULL, - description varchar(500) NOT NULL - ); - CREATE TABLE IF NOT EXISTS channel_permissions ( - channel_uuid uuid NOT NULL REFERENCES channels(uuid) ON DELETE CASCADE, - role_uuid uuid NOT NULL REFERENCES roles(uuid) ON DELETE CASCADE, - permissions int8 NOT NULL DEFAULT 0, - PRIMARY KEY (channel_uuid, role_uuid) - ); - CREATE TABLE IF NOT EXISTS messages ( - uuid uuid PRIMARY KEY NOT NULL, - channel_uuid uuid NOT NULL REFERENCES channels(uuid) ON DELETE CASCADE, - user_uuid uuid NOT NULL REFERENCES users(uuid), - message varchar(4000) NOT NULL - ); - CREATE TABLE IF NOT EXISTS invites ( - id varchar(32) PRIMARY KEY NOT NULL, - guild_uuid uuid NOT NULL REFERENCES guilds(uuid) ON DELETE CASCADE, - user_uuid uuid NOT NULL REFERENCES users(uuid) - ); - "#, - ) - .execute(&mut conn) - .await?; /* **Stored for later possible use** diff --git a/src/schema.rs b/src/schema.rs new file mode 100644 index 0000000..f83018c --- /dev/null +++ b/src/schema.rs @@ -0,0 +1,156 @@ +// @generated automatically by Diesel CLI. + +diesel::table! { + access_tokens (token) { + #[max_length = 32] + token -> Varchar, + #[max_length = 64] + refresh_token -> Varchar, + uuid -> Uuid, + created_at -> Int8, + } +} + +diesel::table! { + channel_permissions (channel_uuid, role_uuid) { + channel_uuid -> Uuid, + role_uuid -> Uuid, + permissions -> Int8, + } +} + +diesel::table! { + channels (uuid) { + uuid -> Uuid, + guild_uuid -> Uuid, + #[max_length = 32] + name -> Varchar, + #[max_length = 500] + description -> Varchar, + } +} + +diesel::table! { + guild_members (uuid) { + uuid -> Uuid, + guild_uuid -> Uuid, + user_uuid -> Uuid, + #[max_length = 100] + nickname -> Nullable, + } +} + +diesel::table! { + guilds (uuid) { + uuid -> Uuid, + owner_uuid -> Uuid, + #[max_length = 100] + name -> Varchar, + #[max_length = 300] + description -> Nullable, + } +} + +diesel::table! { + instance_permissions (uuid) { + uuid -> Uuid, + administrator -> Bool, + } +} + +diesel::table! { + invites (id) { + #[max_length = 32] + id -> Varchar, + guild_uuid -> Uuid, + user_uuid -> Uuid, + } +} + +diesel::table! { + messages (uuid) { + uuid -> Uuid, + channel_uuid -> Uuid, + user_uuid -> Uuid, + #[max_length = 4000] + message -> Varchar, + } +} + +diesel::table! { + refresh_tokens (token) { + #[max_length = 64] + token -> Varchar, + uuid -> Uuid, + created_at -> Int8, + #[max_length = 16] + device_name -> Varchar, + } +} + +diesel::table! { + role_members (role_uuid, member_uuid) { + role_uuid -> Uuid, + member_uuid -> Uuid, + } +} + +diesel::table! { + roles (uuid, guild_uuid) { + uuid -> Uuid, + guild_uuid -> Uuid, + #[max_length = 50] + name -> Varchar, + color -> Int4, + position -> Int4, + permissions -> Int8, + } +} + +diesel::table! { + users (uuid) { + uuid -> Uuid, + #[max_length = 32] + username -> Varchar, + #[max_length = 64] + display_name -> Nullable, + #[max_length = 512] + password -> Varchar, + #[max_length = 100] + email -> Varchar, + email_verified -> Bool, + is_deleted -> Bool, + deleted_at -> Nullable, + } +} + +diesel::joinable!(access_tokens -> refresh_tokens (refresh_token)); +diesel::joinable!(access_tokens -> users (uuid)); +diesel::joinable!(channel_permissions -> channels (channel_uuid)); +diesel::joinable!(channels -> guilds (guild_uuid)); +diesel::joinable!(guild_members -> guilds (guild_uuid)); +diesel::joinable!(guild_members -> users (user_uuid)); +diesel::joinable!(guilds -> users (owner_uuid)); +diesel::joinable!(instance_permissions -> users (uuid)); +diesel::joinable!(invites -> guilds (guild_uuid)); +diesel::joinable!(invites -> users (user_uuid)); +diesel::joinable!(messages -> channels (channel_uuid)); +diesel::joinable!(messages -> users (user_uuid)); +diesel::joinable!(refresh_tokens -> users (uuid)); +diesel::joinable!(role_members -> guild_members (member_uuid)); +diesel::joinable!(roles -> guilds (guild_uuid)); + +diesel::allow_tables_to_appear_in_same_query!( + access_tokens, + channel_permissions, + channels, + guild_members, + guilds, + instance_permissions, + invites, + messages, + refresh_tokens, + role_members, + roles, + users, +); diff --git a/src/structs.rs b/src/structs.rs index 7cec7c9..b9fd471 100644 --- a/src/structs.rs +++ b/src/structs.rs @@ -6,7 +6,7 @@ use log::error; use serde::{Deserialize, Serialize}; use uuid::Uuid; -use crate::{Conn, Data, tables::*}; +use crate::{Conn, Data, schema::*}; #[derive(Serialize, Deserialize, Clone, Selectable)] #[diesel(table_name = channels)] diff --git a/src/tables.rs b/src/tables.rs deleted file mode 100644 index 3dbd38b..0000000 --- a/src/tables.rs +++ /dev/null @@ -1,109 +0,0 @@ -use diesel::table; - -table! { - users (uuid) { - uuid -> Uuid, - username -> VarChar, - display_name -> Nullable, - password -> VarChar, - email -> VarChar, - email_verified -> Bool, - is_deleted -> Bool, - deleted_at -> Int8, - } -} - -table! { - instance_permissions (uuid) { - uuid -> Uuid, - administrator -> Bool, - } -} - -table! { - refresh_tokens (token) { - token -> VarChar, - uuid -> Uuid, - created_at -> Int8, - device_name -> VarChar, - } -} - -table! { - access_tokens (token) { - token -> VarChar, - refresh_token -> VarChar, - uuid -> Uuid, - created_at -> Int8 - } -} - -table! { - guilds (uuid) { - uuid -> Uuid, - owner_uuid -> Uuid, - name -> VarChar, - description -> VarChar - } -} - -table! { - guild_members (uuid) { - uuid -> Uuid, - guild_uuid -> Uuid, - user_uuid -> Uuid, - nickname -> VarChar, - } -} - -table! { - roles (uuid, guild_uuid) { - uuid -> Uuid, - guild_uuid -> Uuid, - name -> VarChar, - color -> Int4, - position -> Int4, - permissions -> Int8, - } -} - -table! { - role_members (role_uuid, member_uuid) { - role_uuid -> Uuid, - member_uuid -> Uuid, - } -} - -table! { - channels (uuid) { - uuid -> Uuid, - guild_uuid -> Uuid, - name -> VarChar, - description -> VarChar, - } -} - -table! { - channel_permissions (channel_uuid, role_uuid) { - channel_uuid -> Uuid, - role_uuid -> Uuid, - permissions -> Int8, - } -} - -table! { - messages (uuid) { - uuid -> Uuid, - channel_uuid -> Uuid, - user_uuid -> Uuid, - message -> VarChar, - } -} - -table! { - invites (id) { - id -> VarChar, - guild_uuid -> Uuid, - user_uuid -> Uuid, - } -} From 2e1382c1d41261c7d82c70a843537777f06fff2c Mon Sep 17 00:00:00 2001 From: Radical Date: Thu, 22 May 2025 16:28:58 +0200 Subject: [PATCH 12/92] feat: make channel description nullable --- .../2025-05-21-203022_channel_description_nullable/down.sql | 4 ++++ .../2025-05-21-203022_channel_description_nullable/up.sql | 3 +++ src/schema.rs | 2 +- 3 files changed, 8 insertions(+), 1 deletion(-) create mode 100644 migrations/2025-05-21-203022_channel_description_nullable/down.sql create mode 100644 migrations/2025-05-21-203022_channel_description_nullable/up.sql diff --git a/migrations/2025-05-21-203022_channel_description_nullable/down.sql b/migrations/2025-05-21-203022_channel_description_nullable/down.sql new file mode 100644 index 0000000..73344b1 --- /dev/null +++ b/migrations/2025-05-21-203022_channel_description_nullable/down.sql @@ -0,0 +1,4 @@ +-- This file should undo anything in `up.sql` +UPDATE channels SET description = '' WHERE description IS NULL; +ALTER TABLE ONLY channels ALTER COLUMN description SET NOT NULL; +ALTER TABLE ONLY channels ALTER COLUMN description DROP DEFAULT; diff --git a/migrations/2025-05-21-203022_channel_description_nullable/up.sql b/migrations/2025-05-21-203022_channel_description_nullable/up.sql new file mode 100644 index 0000000..5ca6776 --- /dev/null +++ b/migrations/2025-05-21-203022_channel_description_nullable/up.sql @@ -0,0 +1,3 @@ +-- Your SQL goes here +ALTER TABLE ONLY channels ALTER COLUMN description DROP NOT NULL; +ALTER TABLE ONLY channels ALTER COLUMN description SET DEFAULT NULL; diff --git a/src/schema.rs b/src/schema.rs index f83018c..b3274fc 100644 --- a/src/schema.rs +++ b/src/schema.rs @@ -26,7 +26,7 @@ diesel::table! { #[max_length = 32] name -> Varchar, #[max_length = 500] - description -> Varchar, + description -> Nullable, } } From c1885210fbb39b272fb1f25da215c1b0ae4c4536 Mon Sep 17 00:00:00 2001 From: Radical Date: Thu, 22 May 2025 16:29:57 +0200 Subject: [PATCH 13/92] feat: include migrations in binary Lets us change the schema and not worry about instance admins having to manually update their DB! --- Cargo.toml | 5 +++-- build.rs | 3 +++ src/main.rs | 18 +++++++++++++++--- 3 files changed, 21 insertions(+), 5 deletions(-) create mode 100644 build.rs diff --git a/Cargo.toml b/Cargo.toml index e6dcd84..9a91b30 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,8 +30,9 @@ random-string = "1.1" actix-ws = "0.3.0" futures-util = "0.3.31" deadpool = "0.12" -diesel = "2.2" -diesel-async = { version = "0.5", features = ["deadpool", "postgres"] } +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"] } [dependencies.tokio] version = "1.44" diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..284ad12 --- /dev/null +++ b/build.rs @@ -0,0 +1,3 @@ +fn main() { + println!("cargo:rerun-if-changed=migrations"); +} \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index 0a9d493..10da4f1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,14 +5,16 @@ use clap::Parser; use simple_logger::SimpleLogger; use diesel_async::pooled_connection::AsyncDieselConnectionManager; use diesel_async::pooled_connection::deadpool::Pool; -use diesel_async::RunQueryDsl; use std::time::SystemTime; mod config; use config::{Config, ConfigBuilder}; -mod api; +use diesel_migrations::{embed_migrations, EmbeddedMigrations, MigrationHarness}; + +pub const MIGRATIONS: EmbeddedMigrations = embed_migrations!(); type Conn = deadpool::managed::Object>; +mod api; pub mod structs; pub mod utils; pub mod schema; @@ -55,8 +57,18 @@ async fn main() -> Result<(), Error> { let cache_pool = redis::Client::open(config.cache_database.url())?; - let mut conn = pool.get().await?; + let database_url = config.database.url(); + tokio::task::spawn_blocking(move || { + use diesel::prelude::Connection; + use diesel_async::async_connection_wrapper::AsyncConnectionWrapper; + + + let mut conn = AsyncConnectionWrapper::::establish(&database_url)?; + + conn.run_pending_migrations(MIGRATIONS); + Ok::<_, Box>(()) + }).await?; /* **Stored for later possible use** From 73ceea63b6f4be1b270775ada27479b09e84e017 Mon Sep 17 00:00:00 2001 From: Radical Date: Thu, 22 May 2025 16:31:38 +0200 Subject: [PATCH 14/92] feat: refactor structs.rs to diesel! --- src/structs.rs | 744 +++++++++++++++++++++---------------------------- 1 file changed, 320 insertions(+), 424 deletions(-) diff --git a/src/structs.rs b/src/structs.rs index b9fd471..547a852 100644 --- a/src/structs.rs +++ b/src/structs.rs @@ -1,15 +1,42 @@ -use std::str::FromStr; - use actix_web::HttpResponse; -use diesel::Selectable; +use diesel::{delete, insert_into, prelude::{Insertable, Queryable}, ExpressionMethods, QueryDsl, Selectable, SelectableHelper}; use log::error; use serde::{Deserialize, Serialize}; use uuid::Uuid; +use diesel_async::{pooled_connection::AsyncDieselConnectionManager, RunQueryDsl}; use crate::{Conn, Data, schema::*}; -#[derive(Serialize, Deserialize, Clone, Selectable)] +#[derive(Queryable, Selectable, Insertable, Clone)] #[diesel(table_name = channels)] +#[diesel(check_for_backend(diesel::pg::Pg))] +struct ChannelBuilder { + uuid: Uuid, + guild_uuid: Uuid, + name: String, + description: Option, +} + +impl ChannelBuilder { + async fn build(self, conn: &mut Conn) -> Result { + use self::channel_permissions::dsl::*; + let channel_permission: Vec = channel_permissions + .filter(channel_uuid.eq(self.uuid)) + .select((role_uuid, permissions)) + .load(conn) + .await?; + + Ok(Channel { + uuid: self.uuid, + guild_uuid: self.guild_uuid, + name: self.name, + description: self.description, + permissions: channel_permission, + }) + } +} + +#[derive(Serialize, Deserialize, Clone)] pub struct Channel { pub uuid: Uuid, pub guild_uuid: Uuid, @@ -18,116 +45,81 @@ pub struct Channel { pub permissions: Vec, } -#[derive(Serialize, Clone)] -struct ChannelPermissionBuilder { - role_uuid: String, - permissions: i32, -} - -impl ChannelPermissionBuilder { - fn build(&self) -> ChannelPermission { - ChannelPermission { - role_uuid: Uuid::from_str(&self.role_uuid).unwrap(), - permissions: self.permissions, - } - } -} - -#[derive(Serialize, Deserialize, Clone, Selectable)] +#[derive(Serialize, Deserialize, Clone, Queryable)] #[diesel(table_name = channel_permissions)] +#[diesel(check_for_backend(diesel::pg::Pg))] pub struct ChannelPermission { pub role_uuid: Uuid, - pub permissions: i32, + pub permissions: i64, } impl Channel { pub async fn fetch_all( - conn: &mut Conn, + pool: &deadpool::managed::Pool, Conn>, guild_uuid: Uuid, ) -> Result, HttpResponse> { - + let mut conn = pool.get().await.unwrap(); - if let Err(error) = row { + use channels::dsl; + let channel_builders_result: Result, diesel::result::Error> = dsl::channels + .filter(dsl::guild_uuid.eq(guild_uuid)) + .select(ChannelBuilder::as_select()) + .load(&mut conn) + .await; + + if let Err(error) = channel_builders_result { error!("{}", error); return Err(HttpResponse::InternalServerError().finish()); } - let channels: Vec<(String, String, Option)> = row.unwrap(); + let channel_builders = channel_builders_result.unwrap(); - let futures = channels.iter().map(async |t| { - let (uuid, name, description) = t.to_owned(); - - let row = sqlx::query_as(&format!("SELECT CAST(role_uuid AS VARCHAR), permissions FROM channel_permissions WHERE channel_uuid = '{}'", uuid)) - .fetch_all(pool) - .await; - - if let Err(error) = row { - error!("{}", error); - - return Err(HttpResponse::InternalServerError().finish()) - } - - let channel_permission_builders: Vec = row.unwrap(); - - Ok(Self { - uuid: Uuid::from_str(&uuid).unwrap(), - guild_uuid, - name, - description, - permissions: channel_permission_builders.iter().map(|b| b.build()).collect(), - }) + let channel_futures = channel_builders.iter().map(async move |c| { + let mut conn = pool.get().await?; + c.clone().build(&mut conn).await }); - let channels = futures::future::join_all(futures).await; + + let channels = futures::future::try_join_all(channel_futures).await; - let channels: Result, HttpResponse> = channels.into_iter().collect(); + if let Err(error) = channels { + error!("{}", error); - channels + return Err(HttpResponse::InternalServerError().finish()) + } + + Ok(channels.unwrap()) } pub async fn fetch_one( - pool: &Pool, - guild_uuid: Uuid, + conn: &mut Conn, channel_uuid: Uuid, ) -> Result { - let row = sqlx::query_as(&format!( - "SELECT name, description FROM channels WHERE guild_uuid = '{}' AND uuid = '{}'", - guild_uuid, channel_uuid - )) - .fetch_one(pool) - .await; - - if let Err(error) = row { - error!("{}", error); - - return Err(HttpResponse::InternalServerError().finish()); - } - - let (name, description): (String, Option) = row.unwrap(); - - let row = sqlx::query_as(&format!("SELECT CAST(role_uuid AS VARCHAR), permissions FROM channel_permissions WHERE channel_uuid = '{}'", channel_uuid)) - .fetch_all(pool) + use channels::dsl; + let channel_builder_result: Result = dsl::channels + .filter(dsl::uuid.eq(channel_uuid)) + .select(ChannelBuilder::as_select()) + .get_result(conn) .await; - if let Err(error) = row { + if let Err(error) = channel_builder_result { error!("{}", error); - return Err(HttpResponse::InternalServerError().finish()); + return Err(HttpResponse::InternalServerError().finish()) } - let channel_permission_builders: Vec = row.unwrap(); + let channel_builder = channel_builder_result.unwrap(); - Ok(Self { - uuid: channel_uuid, - guild_uuid, - name, - description, - permissions: channel_permission_builders - .iter() - .map(|b| b.build()) - .collect(), - }) + let channel = channel_builder.build(conn).await; + + if let Err(error) = channel { + error!("{}", error); + + return Err(HttpResponse::InternalServerError().finish()) + } + + Ok(channel.unwrap()) } pub async fn new( @@ -136,19 +128,28 @@ impl Channel { name: String, description: Option, ) -> Result { + let mut conn = data.pool.get().await.unwrap(); + let channel_uuid = Uuid::now_v7(); - let row = sqlx::query(&format!("INSERT INTO channels (uuid, guild_uuid, name, description) VALUES ('{}', '{}', $1, $2)", channel_uuid, guild_uuid)) - .bind(&name) - .bind(&description) - .execute(&data.pool) + let new_channel = ChannelBuilder { + uuid: channel_uuid, + guild_uuid: guild_uuid, + name: name.clone(), + description: description.clone(), + }; + + let insert_result = insert_into(channels::table) + .values(new_channel) + .execute(&mut conn) .await; - if let Err(error) = row { + if let Err(error) = insert_result { error!("{}", error); return Err(HttpResponse::InternalServerError().finish()); } + // returns different object because there's no reason to build the channelbuilder (wastes 1 database request) let channel = Self { uuid: channel_uuid, guild_uuid, @@ -176,13 +177,12 @@ impl Channel { Ok(channel) } - pub async fn delete(self, pool: &Pool) -> Result<(), HttpResponse> { - let result = sqlx::query(&format!( - "DELETE FROM channels WHERE channel_uuid = '{}'", - self.uuid - )) - .execute(pool) - .await; + pub async fn delete(self, conn: &mut Conn) -> Result<(), HttpResponse> { + use channels::dsl; + let result = delete(channels::table) + .filter(dsl::uuid.eq(self.uuid)) + .execute(conn) + .await; if let Err(error) = result { error!("{}", error); @@ -195,50 +195,53 @@ impl Channel { pub async fn fetch_messages( &self, - pool: &Pool, + conn: &mut Conn, amount: i64, offset: i64, ) -> Result, HttpResponse> { - let row = sqlx::query_as(&format!("SELECT CAST(uuid AS VARCHAR), CAST(user_uuid AS VARCHAR), CAST(channel_uuid AS VARCHAR), message FROM messages WHERE channel_uuid = '{}' ORDER BY uuid DESC LIMIT $1 OFFSET $2", self.uuid)) - .bind(amount) - .bind(offset) - .fetch_all(pool) + use messages::dsl; + let messages: Result, diesel::result::Error> = dsl::messages + .filter(dsl::channel_uuid.eq(self.uuid)) + .select(Message::as_select()) + .limit(amount) + .offset(offset) + .load(conn) .await; - if let Err(error) = row { + if let Err(error) = messages { error!("{}", error); return Err(HttpResponse::InternalServerError().finish()); } - let message_builders: Vec = row.unwrap(); - - Ok(message_builders.iter().map(|b| b.build()).collect()) + Ok(messages.unwrap()) } pub async fn new_message( &self, - pool: &Pool, + conn: &mut Conn, user_uuid: Uuid, message: String, ) -> Result { let message_uuid = Uuid::now_v7(); - let row = sqlx::query(&format!("INSERT INTO messages (uuid, channel_uuid, user_uuid, message) VALUES ('{}', '{}', '{}', $1)", message_uuid, self.uuid, user_uuid)) - .bind(&message) - .execute(pool) - .await; - - if let Err(error) = row { - error!("{}", error); - return Err(HttpResponse::InternalServerError().finish()); - } - - Ok(Message { + let message = Message { uuid: message_uuid, channel_uuid: self.uuid, user_uuid, message, - }) + }; + + let insert_result = insert_into(messages::table) + .values(message.clone()) + .execute(conn) + .await; + + if let Err(error) = insert_result { + error!("{}", error); + return Err(HttpResponse::InternalServerError().finish()); + } + + Ok(message) } } @@ -280,6 +283,34 @@ impl Permissions { } } +#[derive(Serialize, Queryable, Selectable, Insertable, Clone)] +#[diesel(table_name = guilds)] +#[diesel(check_for_backend(diesel::pg::Pg))] +struct GuildBuilder { + uuid: Uuid, + name: String, + description: Option, + owner_uuid: Uuid, +} + +impl GuildBuilder { + async fn build(self, conn: &mut Conn) -> Result { + let member_count = Member::count(conn, self.uuid).await?; + + let roles = Role::fetch_all(conn, self.uuid).await?; + + Ok(Guild { + uuid: self.uuid, + name: self.name, + description: self.description, + icon: String::from("bogus"), + owner_uuid: self.owner_uuid, + roles: roles, + member_count: member_count, + }) + } +} + #[derive(Serialize)] pub struct Guild { pub uuid: Uuid, @@ -292,85 +323,50 @@ 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 = '{}'", - guild_uuid - )) - .fetch_one(pool) - .await; + pub async fn fetch_one(conn: &mut Conn, guild_uuid: Uuid) -> Result { + use guilds::dsl; + let guild_builder: Result = dsl::guilds + .filter(dsl::uuid.eq(guild_uuid)) + .select(GuildBuilder::as_select()) + .get_result(conn) + .await; - if let Err(error) = row { + if let Err(error) = guild_builder { error!("{}", error); return Err(HttpResponse::InternalServerError().finish()); } - let (owner_uuid_raw, name, description): (String, String, Option) = row.unwrap(); + let guild = guild_builder.unwrap().build(conn).await?; - let owner_uuid = Uuid::from_str(&owner_uuid_raw).unwrap(); - - let member_count = Member::count(pool, guild_uuid).await?; - - let roles = Role::fetch_all(pool, guild_uuid).await?; - - Ok(Self { - uuid: guild_uuid, - name, - description, - // FIXME: This isnt supposed to be bogus - icon: String::from("bogus"), - owner_uuid, - roles, - member_count, - }) + Ok(guild) } pub async fn fetch_amount( - pool: &Pool, - start: i32, - amount: i32, + pool: &deadpool::managed::Pool, Conn>, + offset: i64, + amount: i64, ) -> Result, HttpResponse> { // Fetch guild data from database - let rows = sqlx::query_as::<_, (String, String, String, Option)>( - "SELECT CAST(uuid AS VARCHAR), CAST(owner_uuid AS VARCHAR), name, description - FROM guilds - ORDER BY name - LIMIT $1 OFFSET $2", - ) - .bind(amount) - .bind(start) - .fetch_all(pool) - .await - .map_err(|error| { - error!("{}", error); - HttpResponse::InternalServerError().finish() - })?; + let mut conn = pool.get().await.unwrap(); + + use guilds::dsl; + let guild_builders: Vec = dsl::guilds + .select(GuildBuilder::as_select()) + .order_by(dsl::uuid) + .offset(offset) + .limit(amount) + .load(&mut conn) + .await + .map_err(|error| { + error!("{}", error); + HttpResponse::InternalServerError().finish() + })?; // Process each guild concurrently - let guild_futures = rows.into_iter().map(|(guild_uuid_raw, owner_uuid_raw, name, description)| async move { - let uuid = Uuid::from_str(&guild_uuid_raw).map_err(|_| { - HttpResponse::BadRequest().body("Invalid guild UUID format") - })?; - - let owner_uuid = Uuid::from_str(&owner_uuid_raw).map_err(|_| { - HttpResponse::BadRequest().body("Invalid owner UUID format") - })?; - - let (member_count, roles) = tokio::try_join!( - Member::count(pool, uuid), - Role::fetch_all(pool, uuid) - )?; - - Ok::(Self { - uuid, - name, - description, - icon: String::from("bogus"), // FIXME: Replace with actual icon handling - owner_uuid, - roles, - member_count, - }) + let guild_futures = guild_builders.iter().map(async move |g| { + let mut conn = pool.get().await.unwrap(); + g.clone().build(&mut conn).await }); // Execute all futures concurrently and collect results @@ -378,49 +374,28 @@ impl Guild { } pub async fn new( - pool: &Pool, + conn: &mut Conn, name: String, description: Option, owner_uuid: Uuid, ) -> Result { let guild_uuid = Uuid::now_v7(); - let row = sqlx::query(&format!( - "INSERT INTO guilds (uuid, owner_uuid, name, description) VALUES ('{}', '{}', $1, $2)", - guild_uuid, owner_uuid - )) - .bind(&name) - .bind(&description) - .execute(pool) - .await; + let guild_builder = GuildBuilder { + uuid: guild_uuid, + name: name.clone(), + description: description.clone(), + owner_uuid, + }; - if let Err(error) = row { - error!("{}", error); - return Err(HttpResponse::InternalServerError().finish()); - } - - let row = sqlx::query(&format!( - "INSERT INTO guild_members (uuid, guild_uuid, user_uuid) VALUES ('{}', '{}', '{}')", - Uuid::now_v7(), - guild_uuid, - owner_uuid - )) - .execute(pool) - .await; - - if let Err(error) = row { - error!("{}", error); - - let row = sqlx::query(&format!("DELETE FROM guilds WHERE uuid = '{}'", guild_uuid)) - .execute(pool) - .await; - - if let Err(error) = row { + insert_into(guilds::table) + .values(guild_builder) + .execute(conn) + .await + .map_err(|error| { error!("{}", error); - } - - return Err(HttpResponse::InternalServerError().finish()); - } + HttpResponse::InternalServerError().finish() + })?; Ok(Guild { uuid: guild_uuid, @@ -433,168 +408,116 @@ impl Guild { }) } - pub async fn get_invites(&self, pool: &Pool) -> Result, HttpResponse> { - let invites = sqlx::query_as(&format!( - "SELECT (id, guild_uuid, user_uuid) FROM invites WHERE guild_uuid = '{}'", - self.uuid - )) - .fetch_all(pool) - .await; + pub async fn get_invites(&self, conn: &mut Conn) -> Result, HttpResponse> { + use invites::dsl; + let invites = dsl::invites + .filter(dsl::guild_uuid.eq(self.uuid)) + .select(Invite::as_select()) + .load(conn) + .await + .map_err(|error| { + error!("{}", error); + HttpResponse::InternalServerError().finish() + })?; - if let Err(error) = invites { - error!("{}", error); - return Err(HttpResponse::InternalServerError().finish()); - } - - Ok(invites - .unwrap() - .iter() - .map(|b: &InviteBuilder| b.build()) - .collect()) + Ok(invites) } pub async fn create_invite( &self, - pool: &Pool, + conn: &mut Conn, member: &Member, custom_id: Option, ) -> Result { let invite_id; - if custom_id.is_none() { - let charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; - - invite_id = random_string::generate(8, charset); - } else { - invite_id = custom_id.unwrap(); + if let Some(id) = custom_id { + invite_id = id; if invite_id.len() > 32 { return Err(HttpResponse::BadRequest().finish()); } + } else { + let charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + + invite_id = random_string::generate(8, charset); } - let result = sqlx::query(&format!( - "INSERT INTO invites (id, guild_uuid, user_uuid) VALUES ($1, '{}', '{}'", - self.uuid, member.user_uuid - )) - .bind(&invite_id) - .execute(pool) - .await; - - if let Err(error) = result { - error!("{}", error); - return Err(HttpResponse::InternalServerError().finish()); - } - - Ok(Invite { + let invite = Invite { id: invite_id, user_uuid: member.user_uuid, guild_uuid: self.uuid, - }) + }; + + insert_into(invites::table) + .values(invite.clone()) + .execute(conn) + .await + .map_err(|error| { + error!("{}", error); + HttpResponse::InternalServerError().finish() + })?; + + Ok(invite) } } -#[derive(FromRow)] -struct RoleBuilder { - uuid: String, - guild_uuid: String, - name: String, - color: i64, - position: i32, - permissions: i64, -} - -impl RoleBuilder { - fn build(&self) -> Role { - Role { - uuid: Uuid::from_str(&self.uuid).unwrap(), - guild_uuid: Uuid::from_str(&self.guild_uuid).unwrap(), - name: self.name.clone(), - color: self.color, - position: self.position, - permissions: self.permissions, - } - } -} - -#[derive(Serialize, Clone)] +#[derive(Serialize, Clone, Queryable, Selectable, Insertable)] +#[diesel(table_name = roles)] +#[diesel(check_for_backend(diesel::pg::Pg))] pub struct Role { uuid: Uuid, guild_uuid: Uuid, name: String, - color: i64, + color: i32, position: i32, permissions: i64, } impl Role { pub async fn fetch_all( - pool: &Pool, + conn: &mut Conn, guild_uuid: Uuid, ) -> Result, HttpResponse> { - let role_builders_result = sqlx::query_as(&format!("SELECT (uuid, guild_uuid, name, color, position, permissions) FROM roles WHERE guild_uuid = '{}'", guild_uuid)) - .fetch_all(pool) - .await; + use roles::dsl; + let roles: Vec = dsl::roles + .filter(dsl::guild_uuid.eq(guild_uuid)) + .select(Role::as_select()) + .load(conn) + .await + .map_err(|error| { + error!("{}", error); + HttpResponse::InternalServerError().finish() + })?; - if let Err(error) = role_builders_result { - error!("{}", error); - - return Err(HttpResponse::InternalServerError().finish()); - } - - let role_builders: Vec = role_builders_result.unwrap(); - - Ok(role_builders.iter().map(|b| b.build()).collect()) + Ok(roles) } pub async fn fetch_one( - pool: &Pool, + conn: &mut Conn, role_uuid: Uuid, - guild_uuid: Uuid, ) -> Result { - let row = sqlx::query_as(&format!("SELECT (name, color, position, permissions) FROM roles WHERE guild_uuid = '{}' AND uuid = '{}'", guild_uuid, role_uuid)) - .fetch_one(pool) - .await; + use roles::dsl; + let role: Role = dsl::roles + .filter(dsl::uuid.eq(role_uuid)) + .select(Role::as_select()) + .get_result(conn) + .await + .map_err(|error| { + error!("{}", error); + HttpResponse::InternalServerError().finish() + })?; - if let Err(error) = row { - error!("{}", error); - - return Err(HttpResponse::InternalServerError().finish()); - } - - let (name, color, position, permissions) = row.unwrap(); - - Ok(Role { - uuid: role_uuid, - guild_uuid, - name, - color, - position, - permissions, - }) + Ok(role) } pub async fn new( - pool: &Pool, + conn: &mut Conn, guild_uuid: Uuid, name: String, ) -> Result { let role_uuid = Uuid::now_v7(); - let row = sqlx::query(&format!( - "INSERT INTO channels (uuid, guild_uuid, name, position) VALUES ('{}', '{}', $1, $2)", - role_uuid, guild_uuid - )) - .bind(&name) - .bind(0) - .execute(pool) - .await; - - if let Err(error) = row { - error!("{}", error); - return Err(HttpResponse::InternalServerError().finish()); - } - - let role = Self { + let role = Role { uuid: role_uuid, guild_uuid, name, @@ -603,10 +526,22 @@ impl Role { permissions: 0, }; + insert_into(roles::table) + .values(role.clone()) + .execute(conn) + .await + .map_err(|error| { + error!("{}", error); + HttpResponse::InternalServerError().finish() + })?; + Ok(role) } } +#[derive(Queryable, Selectable, Insertable)] +#[diesel(table_name = guild_members)] +#[diesel(check_for_backend(diesel::pg::Pg))] pub struct Member { pub uuid: Uuid, pub nickname: Option, @@ -615,67 +550,63 @@ pub struct Member { } impl Member { - async fn count(pool: &Pool, guild_uuid: Uuid) -> Result { - let member_count = sqlx::query_scalar(&format!( - "SELECT COUNT(uuid) FROM guild_members WHERE guild_uuid = '{}'", - guild_uuid - )) - .fetch_one(pool) - .await; + async fn count(conn: &mut Conn, guild_uuid: Uuid) -> Result { + use guild_members::dsl; + let count: i64 = dsl::guild_members + .filter(dsl::guild_uuid.eq(guild_uuid)) + .count() + .get_result(conn) + .await + .map_err(|error| { + error!("{}", error); + HttpResponse::InternalServerError() + })?; - if let Err(error) = member_count { - error!("{}", error); - - return Err(HttpResponse::InternalServerError().finish()); - } - - Ok(member_count.unwrap()) + Ok(count) } pub async fn fetch_one( - pool: &Pool, + conn: &mut Conn, user_uuid: Uuid, guild_uuid: Uuid, ) -> Result { - let row = sqlx::query_as(&format!("SELECT CAST(uuid AS VARCHAR), nickname FROM guild_members WHERE guild_uuid = '{}' AND user_uuid = '{}'", guild_uuid, user_uuid)) - .fetch_one(pool) - .await; - - if let Err(error) = row { + use guild_members::dsl; + let member: Member = dsl::guild_members + .filter(dsl::user_uuid.eq(user_uuid)) + .filter(dsl::guild_uuid.eq(guild_uuid)) + .select(Member::as_select()) + .get_result(conn) + .await + .map_err(|error| { error!("{}", error); + HttpResponse::InternalServerError().finish() + })?; - return Err(HttpResponse::InternalServerError().finish()); - } - - let (uuid, nickname): (String, Option) = row.unwrap(); - - Ok(Self { - uuid: Uuid::from_str(&uuid).unwrap(), - nickname, - user_uuid, - guild_uuid, - }) + Ok(member) } pub async fn new( - pool: &Pool, + conn: &mut Conn, user_uuid: Uuid, guild_uuid: Uuid, ) -> Result { let member_uuid = Uuid::now_v7(); - let row = sqlx::query(&format!( - "INSERT INTO guild_members uuid, guild_uuid, user_uuid VALUES ('{}', '{}', '{}')", - member_uuid, guild_uuid, user_uuid - )) - .execute(pool) - .await; + let member = Member { + uuid: member_uuid, + guild_uuid, + user_uuid, + nickname: None, + }; - if let Err(error) = row { - error!("{}", error); - - return Err(HttpResponse::InternalServerError().finish()); - } + insert_into(guild_members::table) + .values(member) + .execute(conn) + .await + .map_err(|error| { + error!("{}", error); + HttpResponse::InternalServerError().finish() + })?; Ok(Self { uuid: member_uuid, @@ -686,26 +617,9 @@ impl Member { } } -#[derive(FromRow)] -struct MessageBuilder { - uuid: String, - channel_uuid: String, - user_uuid: String, - message: String, -} - -impl MessageBuilder { - fn build(&self) -> Message { - Message { - uuid: Uuid::from_str(&self.uuid).unwrap(), - channel_uuid: Uuid::from_str(&self.channel_uuid).unwrap(), - user_uuid: Uuid::from_str(&self.user_uuid).unwrap(), - message: self.message.clone(), - } - } -} - -#[derive(Serialize)] +#[derive(Clone, Serialize, Queryable, Selectable, Insertable)] +#[diesel(table_name = messages)] +#[diesel(check_for_backend(diesel::pg::Pg))] pub struct Message { uuid: Uuid, channel_uuid: Uuid, @@ -713,25 +627,8 @@ pub struct Message { message: String, } -#[derive(FromRow)] -pub struct InviteBuilder { - id: String, - user_uuid: String, - guild_uuid: String, -} - -impl InviteBuilder { - fn build(&self) -> Invite { - Invite { - id: self.id.clone(), - user_uuid: Uuid::from_str(&self.user_uuid).unwrap(), - guild_uuid: Uuid::from_str(&self.guild_uuid).unwrap(), - } - } -} - /// Server invite struct -#[derive(Serialize)] +#[derive(Clone, Serialize, Queryable, Selectable, Insertable)] pub struct Invite { /// case-sensitive alphanumeric string with a fixed length of 8 characters, can be up to 32 characters for custom invites id: String, @@ -742,20 +639,19 @@ pub struct Invite { } impl Invite { - pub async fn fetch_one(pool: &Pool, invite_id: String) -> Result { - let invite: Result = - sqlx::query_as("SELECT id, user_uuid, guild_uuid FROM invites WHERE id = $1") - .bind(invite_id) - .fetch_one(pool) - .await; + pub async fn fetch_one(conn: &mut Conn, invite_id: String) -> Result { + use invites::dsl; + let invite: Invite = dsl::invites + .filter(dsl::id.eq(invite_id)) + .select(Invite::as_select()) + .get_result(conn) + .await + .map_err(|error| { + error!("{}", error); + HttpResponse::InternalServerError().finish() + })?; - if let Err(error) = invite { - error!("{}", error); - - return Err(HttpResponse::InternalServerError().finish()); - } - - Ok(invite.unwrap().build()) + Ok(invite) } } From fee46e143327476bf4c7f9bfffe4ebbbd6920ed6 Mon Sep 17 00:00:00 2001 From: Radical Date: Fri, 23 May 2025 12:52:41 +0200 Subject: [PATCH 15/92] feat: use thiserror for errors --- Cargo.toml | 1 + src/error.rs | 79 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+) create mode 100644 src/error.rs diff --git a/Cargo.toml b/Cargo.toml index 9a91b30..8c03fec 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,6 +33,7 @@ deadpool = "0.12" 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" [dependencies.tokio] version = "1.44" diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..5d10251 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,79 @@ +use std::{io, time::SystemTimeError}; + +use actix_web::{error::ResponseError, http::{header::{ContentType, ToStrError}, StatusCode}, HttpResponse}; +use deadpool::managed::{BuildError, PoolError}; +use redis::RedisError; +use serde::Serialize; +use thiserror::Error; +use diesel::{result::Error as DieselError, ConnectionError}; +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; + +#[derive(Debug, Error)] +pub enum Error { + #[error(transparent)] + SqlError(#[from] DieselError), + #[error(transparent)] + PoolError(#[from] PoolError), + #[error(transparent)] + BuildError(#[from] BuildError), + #[error(transparent)] + RedisError(#[from] RedisError), + #[error(transparent)] + ConnectionError(#[from] ConnectionError), + #[error(transparent)] + JoinError(#[from] JoinError), + #[error(transparent)] + IoError(#[from] io::Error), + #[error(transparent)] + TomlError(#[from] TomlError), + #[error(transparent)] + JsonError(#[from] JsonError), + #[error(transparent)] + SystemTimeError(#[from] SystemTimeError), + #[error(transparent)] + ToStrError(#[from] ToStrError), + #[error(transparent)] + RandomError(#[from] getrandom::Error), + #[error("{0}")] + PasswordHashError(String), + #[error("{0}")] + BadRequest(String), + #[error("{0}")] + Unauthorized(String), +} + +impl ResponseError for Error { + fn error_response(&self) -> HttpResponse { + error!("{}: {}", self.status_code(), self.to_string()); + + HttpResponse::build(self.status_code()) + .insert_header(ContentType::json()) + .json(WebError::new(self.to_string())) + } + + fn status_code(&self) -> StatusCode { + match *self { + Error::SqlError(DieselError::NotFound) => StatusCode::NOT_FOUND, + Error::BadRequest(_) => StatusCode::BAD_REQUEST, + Error::Unauthorized(_) => StatusCode::UNAUTHORIZED, + _ => StatusCode::INTERNAL_SERVER_ERROR, + } + } +} + +#[derive(Serialize)] +struct WebError { + message: String, +} + +impl WebError { + fn new(message: String) -> Self { + Self { + message, + } + } +} From 3e698edf8cd8e498b60e24e74d44c96810a89207 Mon Sep 17 00:00:00 2001 From: Radical Date: Fri, 23 May 2025 12:54:10 +0200 Subject: [PATCH 16/92] feat: use new error type in main --- src/main.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/main.rs b/src/main.rs index 10da4f1..7fb7087 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,6 +2,7 @@ use actix_cors::Cors; use actix_web::{App, HttpServer, web}; use argon2::Argon2; use clap::Parser; +use error::Error; use simple_logger::SimpleLogger; use diesel_async::pooled_connection::AsyncDieselConnectionManager; use diesel_async::pooled_connection::deadpool::Pool; @@ -18,8 +19,7 @@ mod api; pub mod structs; pub mod utils; pub mod schema; - -type Error = Box; +pub mod error; #[derive(Parser, Debug)] #[command(version, about, long_about = None)] @@ -66,9 +66,9 @@ async fn main() -> Result<(), Error> { let mut conn = AsyncConnectionWrapper::::establish(&database_url)?; - conn.run_pending_migrations(MIGRATIONS); + conn.run_pending_migrations(MIGRATIONS)?; Ok::<_, Box>(()) - }).await?; + }).await?.unwrap(); /* **Stored for later possible use** From 49db25e4548e81ae6777edaea9b664d45c87bf78 Mon Sep 17 00:00:00 2001 From: Radical Date: Fri, 23 May 2025 12:54:52 +0200 Subject: [PATCH 17/92] feat: use new error type in structs, utils and config --- src/config.rs | 2 +- src/structs.rs | 328 ++++++++++++++++++------------------------------- src/utils.rs | 35 +++--- 3 files changed, 144 insertions(+), 221 deletions(-) diff --git a/src/config.rs b/src/config.rs index 4e8fc21..3b537c9 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,4 +1,4 @@ -use crate::Error; +use crate::error::Error; use log::debug; use serde::Deserialize; use tokio::fs::read_to_string; diff --git a/src/structs.rs b/src/structs.rs index 547a852..3bc214b 100644 --- a/src/structs.rs +++ b/src/structs.rs @@ -1,11 +1,17 @@ -use actix_web::HttpResponse; use diesel::{delete, insert_into, prelude::{Insertable, Queryable}, ExpressionMethods, QueryDsl, Selectable, SelectableHelper}; -use log::error; use serde::{Deserialize, Serialize}; use uuid::Uuid; use diesel_async::{pooled_connection::AsyncDieselConnectionManager, RunQueryDsl}; -use crate::{Conn, Data, schema::*}; +use crate::{error::Error, Conn, Data, schema::*}; + +fn load_or_empty(query_result: Result, diesel::result::Error>) -> Result, diesel::result::Error> { + match query_result { + Ok(vec) => Ok(vec), + Err(diesel::result::Error::NotFound) => Ok(Vec::new()), + Err(e) => Err(e), + } +} #[derive(Queryable, Selectable, Insertable, Clone)] #[diesel(table_name = channels)] @@ -18,13 +24,15 @@ struct ChannelBuilder { } impl ChannelBuilder { - async fn build(self, conn: &mut Conn) -> Result { + async fn build(self, conn: &mut Conn) -> Result { use self::channel_permissions::dsl::*; - let channel_permission: Vec = channel_permissions - .filter(channel_uuid.eq(self.uuid)) - .select((role_uuid, permissions)) - .load(conn) - .await?; + let channel_permission: Vec = load_or_empty( + channel_permissions + .filter(channel_uuid.eq(self.uuid)) + .select(ChannelPermission::as_select()) + .load(conn) + .await + )?; Ok(Channel { uuid: self.uuid, @@ -45,7 +53,7 @@ pub struct Channel { pub permissions: Vec, } -#[derive(Serialize, Deserialize, Clone, Queryable)] +#[derive(Serialize, Deserialize, Clone, Queryable, Selectable)] #[diesel(table_name = channel_permissions)] #[diesel(check_for_backend(diesel::pg::Pg))] pub struct ChannelPermission { @@ -57,69 +65,38 @@ impl Channel { pub async fn fetch_all( pool: &deadpool::managed::Pool, Conn>, guild_uuid: Uuid, - ) -> Result, HttpResponse> { - let mut conn = pool.get().await.unwrap(); + ) -> Result, Error> { + let mut conn = pool.get().await?; use channels::dsl; - let channel_builders_result: Result, diesel::result::Error> = dsl::channels - .filter(dsl::guild_uuid.eq(guild_uuid)) - .select(ChannelBuilder::as_select()) - .load(&mut conn) - .await; - - if let Err(error) = channel_builders_result { - error!("{}", error); - - return Err(HttpResponse::InternalServerError().finish()); - } - - let channel_builders = channel_builders_result.unwrap(); + let channel_builders: Vec = load_or_empty( + dsl::channels + .filter(dsl::guild_uuid.eq(guild_uuid)) + .select(ChannelBuilder::as_select()) + .load(&mut conn) + .await + )?; let channel_futures = channel_builders.iter().map(async move |c| { let mut conn = pool.get().await?; c.clone().build(&mut conn).await }); - - let channels = futures::future::try_join_all(channel_futures).await; - - if let Err(error) = channels { - error!("{}", error); - - return Err(HttpResponse::InternalServerError().finish()) - } - - Ok(channels.unwrap()) + futures::future::try_join_all(channel_futures).await } pub async fn fetch_one( conn: &mut Conn, channel_uuid: Uuid, - ) -> Result { + ) -> Result { use channels::dsl; - let channel_builder_result: Result = dsl::channels + let channel_builder: ChannelBuilder = dsl::channels .filter(dsl::uuid.eq(channel_uuid)) .select(ChannelBuilder::as_select()) .get_result(conn) - .await; + .await?; - if let Err(error) = channel_builder_result { - error!("{}", error); - - return Err(HttpResponse::InternalServerError().finish()) - } - - let channel_builder = channel_builder_result.unwrap(); - - let channel = channel_builder.build(conn).await; - - if let Err(error) = channel { - error!("{}", error); - - return Err(HttpResponse::InternalServerError().finish()) - } - - Ok(channel.unwrap()) + channel_builder.build(conn).await } pub async fn new( @@ -127,8 +104,8 @@ impl Channel { guild_uuid: Uuid, name: String, description: Option, - ) -> Result { - let mut conn = data.pool.get().await.unwrap(); + ) -> Result { + let mut conn = data.pool.get().await?; let channel_uuid = Uuid::now_v7(); @@ -139,15 +116,10 @@ impl Channel { description: description.clone(), }; - let insert_result = insert_into(channels::table) + insert_into(channels::table) .values(new_channel) .execute(&mut conn) - .await; - - if let Err(error) = insert_result { - error!("{}", error); - return Err(HttpResponse::InternalServerError().finish()); - } + .await?; // returns different object because there's no reason to build the channelbuilder (wastes 1 database request) let channel = Self { @@ -158,37 +130,21 @@ impl Channel { permissions: vec![], }; - let cache_result = data + data .set_cache_key(channel_uuid.to_string(), channel.clone(), 1800) - .await; + .await?; - if let Err(error) = cache_result { - error!("{}", error); - return Err(HttpResponse::InternalServerError().finish()); - } - - let cache_deletion_result = data.del_cache_key(format!("{}_channels", guild_uuid)).await; - - if let Err(error) = cache_deletion_result { - error!("{}", error); - return Err(HttpResponse::InternalServerError().finish()); - } + data.del_cache_key(format!("{}_channels", guild_uuid)).await?; Ok(channel) } - pub async fn delete(self, conn: &mut Conn) -> Result<(), HttpResponse> { + pub async fn delete(self, conn: &mut Conn) -> Result<(), Error> { use channels::dsl; - let result = delete(channels::table) + delete(channels::table) .filter(dsl::uuid.eq(self.uuid)) .execute(conn) - .await; - - if let Err(error) = result { - error!("{}", error); - - return Err(HttpResponse::InternalServerError().finish()); - } + .await?; Ok(()) } @@ -198,22 +154,19 @@ impl Channel { conn: &mut Conn, amount: i64, offset: i64, - ) -> Result, HttpResponse> { + ) -> Result, Error> { use messages::dsl; - let messages: Result, diesel::result::Error> = dsl::messages - .filter(dsl::channel_uuid.eq(self.uuid)) - .select(Message::as_select()) - .limit(amount) - .offset(offset) - .load(conn) - .await; + let messages: Vec = load_or_empty( + dsl::messages + .filter(dsl::channel_uuid.eq(self.uuid)) + .select(Message::as_select()) + .limit(amount) + .offset(offset) + .load(conn) + .await + )?; - if let Err(error) = messages { - error!("{}", error); - return Err(HttpResponse::InternalServerError().finish()); - } - - Ok(messages.unwrap()) + Ok(messages) } pub async fn new_message( @@ -221,7 +174,7 @@ impl Channel { conn: &mut Conn, user_uuid: Uuid, message: String, - ) -> Result { + ) -> Result { let message_uuid = Uuid::now_v7(); let message = Message { @@ -231,15 +184,10 @@ impl Channel { message, }; - let insert_result = insert_into(messages::table) + insert_into(messages::table) .values(message.clone()) .execute(conn) - .await; - - if let Err(error) = insert_result { - error!("{}", error); - return Err(HttpResponse::InternalServerError().finish()); - } + .await?; Ok(message) } @@ -294,7 +242,7 @@ struct GuildBuilder { } impl GuildBuilder { - async fn build(self, conn: &mut Conn) -> Result { + async fn build(self, conn: &mut Conn) -> Result { let member_count = Member::count(conn, self.uuid).await?; let roles = Role::fetch_all(conn, self.uuid).await?; @@ -323,49 +271,39 @@ pub struct Guild { } impl Guild { - pub async fn fetch_one(conn: &mut Conn, guild_uuid: Uuid) -> Result { + pub async fn fetch_one(conn: &mut Conn, guild_uuid: Uuid) -> Result { use guilds::dsl; - let guild_builder: Result = dsl::guilds + let guild_builder: GuildBuilder = dsl::guilds .filter(dsl::uuid.eq(guild_uuid)) .select(GuildBuilder::as_select()) .get_result(conn) - .await; + .await?; - if let Err(error) = guild_builder { - error!("{}", error); - - return Err(HttpResponse::InternalServerError().finish()); - } - - let guild = guild_builder.unwrap().build(conn).await?; - - Ok(guild) + guild_builder.build(conn).await } pub async fn fetch_amount( pool: &deadpool::managed::Pool, Conn>, offset: i64, amount: i64, - ) -> Result, HttpResponse> { + ) -> Result, Error> { // Fetch guild data from database - let mut conn = pool.get().await.unwrap(); + let mut conn = pool.get().await?; use guilds::dsl; - let guild_builders: Vec = dsl::guilds - .select(GuildBuilder::as_select()) - .order_by(dsl::uuid) - .offset(offset) - .limit(amount) - .load(&mut conn) - .await - .map_err(|error| { - error!("{}", error); - HttpResponse::InternalServerError().finish() - })?; + let guild_builders: Vec = load_or_empty( + dsl::guilds + .select(GuildBuilder::as_select()) + .order_by(dsl::uuid) + .offset(offset) + .limit(amount) + .load(&mut conn) + .await + )?; // Process each guild concurrently let guild_futures = guild_builders.iter().map(async move |g| { - let mut conn = pool.get().await.unwrap(); + let mut conn = pool.get().await?; g.clone().build(&mut conn).await }); @@ -378,7 +316,7 @@ impl Guild { name: String, description: Option, owner_uuid: Uuid, - ) -> Result { + ) -> Result { let guild_uuid = Uuid::now_v7(); let guild_builder = GuildBuilder { @@ -391,11 +329,21 @@ impl Guild { insert_into(guilds::table) .values(guild_builder) .execute(conn) - .await - .map_err(|error| { - error!("{}", error); - HttpResponse::InternalServerError().finish() - })?; + .await?; + + let member_uuid = Uuid::now_v7(); + + let member = Member { + uuid: member_uuid, + nickname: None, + user_uuid: owner_uuid, + guild_uuid, + }; + + insert_into(guild_members::table) + .values(member) + .execute(conn) + .await?; Ok(Guild { uuid: guild_uuid, @@ -408,17 +356,15 @@ impl Guild { }) } - pub async fn get_invites(&self, conn: &mut Conn) -> Result, HttpResponse> { + pub async fn get_invites(&self, conn: &mut Conn) -> Result, Error> { use invites::dsl; - let invites = dsl::invites - .filter(dsl::guild_uuid.eq(self.uuid)) - .select(Invite::as_select()) - .load(conn) - .await - .map_err(|error| { - error!("{}", error); - HttpResponse::InternalServerError().finish() - })?; + let invites = load_or_empty( + dsl::invites + .filter(dsl::guild_uuid.eq(self.uuid)) + .select(Invite::as_select()) + .load(conn) + .await + )?; Ok(invites) } @@ -428,13 +374,13 @@ impl Guild { conn: &mut Conn, member: &Member, custom_id: Option, - ) -> Result { + ) -> Result { let invite_id; if let Some(id) = custom_id { invite_id = id; if invite_id.len() > 32 { - return Err(HttpResponse::BadRequest().finish()); + return Err(Error::BadRequest("MAX LENGTH".to_string())) } } else { let charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; @@ -451,11 +397,7 @@ impl Guild { insert_into(invites::table) .values(invite.clone()) .execute(conn) - .await - .map_err(|error| { - error!("{}", error); - HttpResponse::InternalServerError().finish() - })?; + .await?; Ok(invite) } @@ -477,17 +419,15 @@ impl Role { pub async fn fetch_all( conn: &mut Conn, guild_uuid: Uuid, - ) -> Result, HttpResponse> { + ) -> Result, Error> { use roles::dsl; - let roles: Vec = dsl::roles - .filter(dsl::guild_uuid.eq(guild_uuid)) - .select(Role::as_select()) - .load(conn) - .await - .map_err(|error| { - error!("{}", error); - HttpResponse::InternalServerError().finish() - })?; + let roles: Vec = load_or_empty( + dsl::roles + .filter(dsl::guild_uuid.eq(guild_uuid)) + .select(Role::as_select()) + .load(conn) + .await + )?; Ok(roles) } @@ -495,17 +435,13 @@ impl Role { pub async fn fetch_one( conn: &mut Conn, role_uuid: Uuid, - ) -> Result { + ) -> Result { use roles::dsl; let role: Role = dsl::roles .filter(dsl::uuid.eq(role_uuid)) .select(Role::as_select()) .get_result(conn) - .await - .map_err(|error| { - error!("{}", error); - HttpResponse::InternalServerError().finish() - })?; + .await?; Ok(role) } @@ -514,7 +450,7 @@ impl Role { conn: &mut Conn, guild_uuid: Uuid, name: String, - ) -> Result { + ) -> Result { let role_uuid = Uuid::now_v7(); let role = Role { @@ -529,11 +465,7 @@ impl Role { insert_into(roles::table) .values(role.clone()) .execute(conn) - .await - .map_err(|error| { - error!("{}", error); - HttpResponse::InternalServerError().finish() - })?; + .await?; Ok(role) } @@ -550,17 +482,13 @@ pub struct Member { } impl Member { - async fn count(conn: &mut Conn, guild_uuid: Uuid) -> Result { + async fn count(conn: &mut Conn, guild_uuid: Uuid) -> Result { use guild_members::dsl; let count: i64 = dsl::guild_members .filter(dsl::guild_uuid.eq(guild_uuid)) .count() .get_result(conn) - .await - .map_err(|error| { - error!("{}", error); - HttpResponse::InternalServerError() - })?; + .await?; Ok(count) } @@ -569,18 +497,14 @@ impl Member { conn: &mut Conn, user_uuid: Uuid, guild_uuid: Uuid, - ) -> Result { + ) -> Result { use guild_members::dsl; let member: Member = dsl::guild_members .filter(dsl::user_uuid.eq(user_uuid)) .filter(dsl::guild_uuid.eq(guild_uuid)) .select(Member::as_select()) .get_result(conn) - .await - .map_err(|error| { - error!("{}", error); - HttpResponse::InternalServerError().finish() - })?; + .await?; Ok(member) } @@ -589,7 +513,7 @@ impl Member { conn: &mut Conn, user_uuid: Uuid, guild_uuid: Uuid, - ) -> Result { + ) -> Result { let member_uuid = Uuid::now_v7(); let member = Member { @@ -602,11 +526,7 @@ impl Member { insert_into(guild_members::table) .values(member) .execute(conn) - .await - .map_err(|error| { - error!("{}", error); - HttpResponse::InternalServerError().finish() - })?; + .await?; Ok(Self { uuid: member_uuid, @@ -639,17 +559,13 @@ pub struct Invite { } impl Invite { - pub async fn fetch_one(conn: &mut Conn, invite_id: String) -> Result { + pub async fn fetch_one(conn: &mut Conn, invite_id: String) -> Result { use invites::dsl; let invite: Invite = dsl::invites .filter(dsl::id.eq(invite_id)) .select(Invite::as_select()) .get_result(conn) - .await - .map_err(|error| { - error!("{}", error); - HttpResponse::InternalServerError().finish() - })?; + .await?; Ok(invite) } @@ -657,6 +573,6 @@ impl Invite { #[derive(Deserialize)] pub struct StartAmountQuery { - pub start: Option, - pub amount: Option, + pub start: Option, + pub amount: Option, } diff --git a/src/utils.rs b/src/utils.rs index 77c5e0a..b7ddcc1 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,5 +1,4 @@ use actix_web::{ - HttpResponse, cookie::{Cookie, SameSite, time::Duration}, http::header::HeaderMap, }; @@ -8,25 +7,31 @@ use hex::encode; use redis::RedisError; use serde::Serialize; -use crate::Data; +use crate::{error::Error, Data}; -pub fn get_auth_header(headers: &HeaderMap) -> Result<&str, HttpResponse> { +pub fn get_auth_header(headers: &HeaderMap) -> Result<&str, Error> { let auth_token = headers.get(actix_web::http::header::AUTHORIZATION); if auth_token.is_none() { - return Err(HttpResponse::Unauthorized().finish()); + return Err(Error::Unauthorized("No authorization header provided".to_string())); } - let auth = auth_token.unwrap().to_str(); + let auth_raw = auth_token.unwrap().to_str()?; - if let Err(error) = auth { - return Err(HttpResponse::Unauthorized().json(format!(r#" {{ "error": "{}" }} "#, error))); + let mut auth = auth_raw.split_whitespace(); + + let auth_type = auth.nth(0); + + let auth_value = auth.nth(0); + + if auth_type.is_none() { + return Err(Error::BadRequest("Authorization header is empty".to_string())); + } else if auth_type.is_some_and(|at| at != "Bearer") { + return Err(Error::BadRequest("Only token auth is supported".to_string())); } - - let auth_value = auth.unwrap().split_whitespace().nth(1); - + if auth_value.is_none() { - return Err(HttpResponse::BadRequest().finish()); + return Err(Error::BadRequest("No token provided".to_string())); } Ok(auth_value.unwrap()) @@ -60,12 +65,12 @@ impl Data { key: String, value: impl Serialize, expire: u32, - ) -> Result<(), RedisError> { + ) -> Result<(), Error> { let mut conn = self.cache_pool.get_multiplexed_tokio_connection().await?; let key_encoded = encode(key); - let value_json = serde_json::to_string(&value).unwrap(); + let value_json = serde_json::to_string(&value)?; redis::cmd("SET") .arg(&[key_encoded.clone(), value_json]) @@ -75,7 +80,9 @@ impl Data { redis::cmd("EXPIRE") .arg(&[key_encoded, expire.to_string()]) .exec_async(&mut conn) - .await + .await?; + + Ok(()) } pub async fn get_cache_key(&self, key: String) -> Result { From bf51f623e47cb2877630ab63838b1e0847e0a803 Mon Sep 17 00:00:00 2001 From: Radical Date: Fri, 23 May 2025 12:55:27 +0200 Subject: [PATCH 18/92] feat: migrate to diesel and new error type in auth --- src/api/v1/auth/login.rs | 152 +++++++++++------------------------- src/api/v1/auth/mod.rs | 53 ++++++------- src/api/v1/auth/refresh.rs | 75 ++++++++---------- src/api/v1/auth/register.rs | 123 ++++++++++------------------- src/api/v1/auth/revoke.rs | 105 +++++-------------------- 5 files changed, 162 insertions(+), 346 deletions(-) diff --git a/src/api/v1/auth/login.rs b/src/api/v1/auth/login.rs index 38d5449..8ad345e 100644 --- a/src/api/v1/auth/login.rs +++ b/src/api/v1/auth/login.rs @@ -1,14 +1,14 @@ use std::time::{SystemTime, UNIX_EPOCH}; -use actix_web::{Error, HttpResponse, post, web}; +use actix_web::{HttpResponse, post, web}; use argon2::{PasswordHash, PasswordVerifier}; -use log::error; +use diesel::{dsl::insert_into, ExpressionMethods, QueryDsl}; +use diesel_async::RunQueryDsl; use serde::Deserialize; +use uuid::Uuid; use crate::{ - Data, - api::v1::auth::{EMAIL_REGEX, PASSWORD_REGEX, USERNAME_REGEX}, - utils::{generate_access_token, generate_refresh_token, refresh_token_cookie}, + error::Error, api::v1::auth::{EMAIL_REGEX, PASSWORD_REGEX, USERNAME_REGEX}, schema::*, utils::{generate_access_token, generate_refresh_token, refresh_token_cookie}, Data }; use super::Response; @@ -29,66 +29,42 @@ pub async fn response( return Ok(HttpResponse::Forbidden().json(r#"{ "password_hashed": false }"#)); } + use users::dsl; + + let mut conn = data.pool.get().await?; + if EMAIL_REGEX.is_match(&login_information.username) { - let row = - sqlx::query_as("SELECT CAST(uuid as VARCHAR), password FROM users WHERE email = $1") - .bind(&login_information.username) - .fetch_one(&data.pool) - .await; + // FIXME: error handling, right now i just want this to work + let (uuid, password): (Uuid, String) = dsl::users + .filter(dsl::email.eq(&login_information.username)) + .select((dsl::uuid, dsl::password)) + .get_result(&mut conn) + .await?; - if let Err(error) = row { - if error.to_string() - == "no rows returned by a query that expected to return at least one row" - { - return Ok(HttpResponse::Unauthorized().finish()); - } - - error!("{}", error); - return Ok(HttpResponse::InternalServerError().json( - r#"{ "error": "Unhandled exception occured, contact the server administrator" }"#, - )); - } - - let (uuid, password): (String, String) = row.unwrap(); - - return Ok(login( + return login( data.clone(), uuid, login_information.password.clone(), password, login_information.device_name.clone(), ) - .await); + .await; } else if USERNAME_REGEX.is_match(&login_information.username) { - let row = - sqlx::query_as("SELECT CAST(uuid as VARCHAR), password FROM users WHERE username = $1") - .bind(&login_information.username) - .fetch_one(&data.pool) - .await; + // FIXME: error handling, right now i just want this to work + let (uuid, password): (Uuid, String) = dsl::users + .filter(dsl::username.eq(&login_information.username)) + .select((dsl::uuid, dsl::password)) + .get_result(&mut conn) + .await?; - if let Err(error) = row { - if error.to_string() - == "no rows returned by a query that expected to return at least one row" - { - return Ok(HttpResponse::Unauthorized().finish()); - } - - error!("{}", error); - return Ok(HttpResponse::InternalServerError().json( - r#"{ "error": "Unhandled exception occured, contact the server administrator" }"#, - )); - } - - let (uuid, password): (String, String) = row.unwrap(); - - return Ok(login( + return login( data.clone(), uuid, login_information.password.clone(), password, login_information.device_name.clone(), ) - .await); + .await; } Ok(HttpResponse::Unauthorized().finish()) @@ -96,79 +72,45 @@ pub async fn response( async fn login( data: actix_web::web::Data, - uuid: String, + uuid: Uuid, request_password: String, database_password: String, device_name: String, -) -> HttpResponse { - let parsed_hash_raw = PasswordHash::new(&database_password); +) -> Result { + let mut conn = data.pool.get().await?; - if let Err(error) = parsed_hash_raw { - error!("{}", error); - return HttpResponse::InternalServerError().finish(); - } - - let parsed_hash = parsed_hash_raw.unwrap(); + let parsed_hash = PasswordHash::new(&database_password).map_err(|e| Error::PasswordHashError(e.to_string()))?; if data .argon2 .verify_password(request_password.as_bytes(), &parsed_hash) .is_err() { - return HttpResponse::Unauthorized().finish(); + return Err(Error::Unauthorized("Wrong username or password".to_string())); } - let refresh_token_raw = generate_refresh_token(); - let access_token_raw = generate_access_token(); - - if let Err(error) = refresh_token_raw { - error!("{}", error); - return HttpResponse::InternalServerError().finish(); - } - - let refresh_token = refresh_token_raw.unwrap(); - - if let Err(error) = access_token_raw { - error!("{}", error); - return HttpResponse::InternalServerError().finish(); - } - - let access_token = access_token_raw.unwrap(); + let refresh_token = generate_refresh_token()?; + let access_token = generate_access_token()?; let current_time = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() + .duration_since(UNIX_EPOCH)? .as_secs() as i64; - if let Err(error) = sqlx::query(&format!( - "INSERT INTO refresh_tokens (token, uuid, created_at, device_name) VALUES ($1, '{}', $2, $3 )", - uuid - )) - .bind(&refresh_token) - .bind(current_time) - .bind(device_name) - .execute(&data.pool) - .await - { - error!("{}", error); - return HttpResponse::InternalServerError().finish(); - } + use refresh_tokens::dsl as rdsl; - if let Err(error) = sqlx::query(&format!( - "INSERT INTO access_tokens (token, refresh_token, uuid, created_at) VALUES ($1, $2, '{}', $3 )", - uuid - )) - .bind(&access_token) - .bind(&refresh_token) - .bind(current_time) - .execute(&data.pool) - .await - { - error!("{}", error); - return HttpResponse::InternalServerError().finish() - } + insert_into(refresh_tokens::table) + .values((rdsl::token.eq(&refresh_token), rdsl::uuid.eq(uuid), rdsl::created_at.eq(current_time), rdsl::device_name.eq(device_name))) + .execute(&mut conn) + .await?; - HttpResponse::Ok() + use access_tokens::dsl as adsl; + + insert_into(access_tokens::table) + .values((adsl::token.eq(&access_token), adsl::refresh_token.eq(&refresh_token), adsl::uuid.eq(uuid), adsl::created_at.eq(current_time))) + .execute(&mut conn) + .await?; + + Ok(HttpResponse::Ok() .cookie(refresh_token_cookie(refresh_token)) - .json(Response { access_token }) + .json(Response { access_token })) } diff --git a/src/api/v1/auth/mod.rs b/src/api/v1/auth/mod.rs index 326b2ef..249ec4b 100644 --- a/src/api/v1/auth/mod.rs +++ b/src/api/v1/auth/mod.rs @@ -1,16 +1,17 @@ use std::{ - str::FromStr, sync::LazyLock, time::{SystemTime, UNIX_EPOCH}, }; -use actix_web::{HttpResponse, Scope, web}; -use log::error; +use actix_web::{Scope, web}; +use diesel::{ExpressionMethods, QueryDsl}; +use diesel_async::RunQueryDsl; use regex::Regex; use serde::Serialize; -use sqlx::Postgres; use uuid::Uuid; +use crate::{error::Error, Conn, schema::access_tokens::dsl}; + mod login; mod refresh; mod register; @@ -40,40 +41,30 @@ pub fn web() -> Scope { pub async fn check_access_token( access_token: &str, - pool: &sqlx::Pool, -) -> Result { - let row = sqlx::query_as( - "SELECT CAST(uuid as VARCHAR), created_at FROM access_tokens WHERE token = $1", - ) - .bind(access_token) - .fetch_one(pool) - .await; - - if let Err(error) = row { - if error.to_string() - == "no rows returned by a query that expected to return at least one row" - { - return Err(HttpResponse::Unauthorized().finish()); - } - - error!("{}", error); - return Err(HttpResponse::InternalServerError().json( - r#"{ "error": "Unhandled exception occured, contact the server administrator" }"#, - )); - } - - let (uuid, created_at): (String, i64) = row.unwrap(); + conn: &mut Conn, +) -> Result { + let (uuid, created_at): (Uuid, i64) = dsl::access_tokens + .filter(dsl::token.eq(access_token)) + .select((dsl::uuid, dsl::created_at)) + .get_result(conn) + .await + .map_err(|error| { + if error == diesel::result::Error::NotFound { + Error::Unauthorized("Invalid access token".to_string()) + } else { + Error::from(error) + } + })?; let current_time = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() + .duration_since(UNIX_EPOCH)? .as_secs() as i64; let lifetime = current_time - created_at; if lifetime > 3600 { - return Err(HttpResponse::Unauthorized().finish()); + return Err(Error::Unauthorized("Invalid access token".to_string())); } - Ok(Uuid::from_str(&uuid).unwrap()) + Ok(uuid) } diff --git a/src/api/v1/auth/refresh.rs b/src/api/v1/auth/refresh.rs index cf1c4bb..468945d 100644 --- a/src/api/v1/auth/refresh.rs +++ b/src/api/v1/auth/refresh.rs @@ -1,10 +1,11 @@ -use actix_web::{Error, HttpRequest, HttpResponse, post, web}; +use actix_web::{HttpRequest, HttpResponse, post, web}; +use diesel::{delete, update, ExpressionMethods, QueryDsl}; +use diesel_async::RunQueryDsl; use log::error; use std::time::{SystemTime, UNIX_EPOCH}; use crate::{ - Data, - utils::{generate_access_token, generate_refresh_token, refresh_token_cookie}, + error::Error, schema::{access_tokens::{self, dsl}, refresh_tokens::{self, dsl as rdsl}}, utils::{generate_access_token, generate_refresh_token, refresh_token_cookie}, Data }; use super::Response; @@ -20,23 +21,23 @@ pub async fn res(req: HttpRequest, data: web::Data) -> Result(&mut conn) .await { - let created_at: i64 = row; - let lifetime = current_time - created_at; if lifetime > 2592000 { - if let Err(error) = sqlx::query("DELETE FROM refresh_tokens WHERE token = $1") - .bind(&refresh_token) - .execute(&data.pool) + if let Err(error) = delete(refresh_tokens::table) + .filter(rdsl::token.eq(&refresh_token)) + .execute(&mut conn) .await { error!("{}", error); @@ -52,8 +53,7 @@ pub async fn res(req: HttpRequest, data: web::Data) -> Result 1987200 { @@ -66,14 +66,14 @@ pub async fn res(req: HttpRequest, data: web::Data) -> Result { refresh_token = new_refresh_token; @@ -84,27 +84,16 @@ pub async fn res(req: HttpRequest, data: web::Data) -> Result { - let refresh_token = generate_refresh_token(); - let access_token = generate_access_token(); + .execute(&mut conn) + .await?; - if refresh_token.is_err() { - error!("{}", refresh_token.unwrap_err()); - return Ok(HttpResponse::InternalServerError().finish()); - } + let refresh_token = generate_refresh_token()?; + let access_token = generate_access_token()?; - let refresh_token = refresh_token.unwrap(); + let current_time = SystemTime::now() + .duration_since(UNIX_EPOCH)? + .as_secs() as i64; - if access_token.is_err() { - error!("{}", access_token.unwrap_err()); - return Ok(HttpResponse::InternalServerError().finish()); - } + insert_into(refresh_tokens::table) + .values(( + rdsl::token.eq(&refresh_token), + rdsl::uuid.eq(uuid), + rdsl::created_at.eq(current_time), + rdsl::device_name.eq(&account_information.device_name), + )) + .execute(&mut conn) + .await?; - let access_token = access_token.unwrap(); + insert_into(access_tokens::table) + .values(( + adsl::token.eq(&access_token), + adsl::refresh_token.eq(&refresh_token), + adsl::uuid.eq(uuid), + adsl::created_at.eq(current_time), + )) + .execute(&mut conn) + .await?; - let current_time = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_secs() as i64; - - if let Err(error) = sqlx::query(&format!("INSERT INTO refresh_tokens (token, uuid, created_at, device_name) VALUES ($1, '{}', $2, $3 )", uuid)) - .bind(&refresh_token) - .bind(current_time) - .bind(&account_information.device_name) - .execute(&data.pool) - .await { - error!("{}", error); - return Ok(HttpResponse::InternalServerError().finish()) - } - - if let Err(error) = sqlx::query(&format!("INSERT INTO access_tokens (token, refresh_token, uuid, created_at) VALUES ($1, $2, '{}', $3 )", uuid)) - .bind(&access_token) - .bind(&refresh_token) - .bind(current_time) - .execute(&data.pool) - .await { - error!("{}", error); - return Ok(HttpResponse::InternalServerError().finish()) - } - - HttpResponse::Ok() - .cookie(refresh_token_cookie(refresh_token)) - .json(Response { access_token }) - } - Err(error) => { - let err_msg = error.as_database_error().unwrap().message(); - - match err_msg { - err_msg - if err_msg.contains("unique") && err_msg.contains("username_key") => - { - HttpResponse::Forbidden().json(ResponseError { - gorb_id_available: false, - ..Default::default() - }) - } - err_msg if err_msg.contains("unique") && err_msg.contains("email_key") => { - HttpResponse::Forbidden().json(ResponseError { - email_available: false, - ..Default::default() - }) - } - _ => { - error!("{}", err_msg); - HttpResponse::InternalServerError().finish() - } - } - } - }, - ); + return Ok(HttpResponse::Ok() + .cookie(refresh_token_cookie(refresh_token)) + .json(Response { access_token })) } Ok(HttpResponse::InternalServerError().finish()) diff --git a/src/api/v1/auth/revoke.rs b/src/api/v1/auth/revoke.rs index a4f9196..116ed5c 100644 --- a/src/api/v1/auth/revoke.rs +++ b/src/api/v1/auth/revoke.rs @@ -1,10 +1,10 @@ -use actix_web::{Error, HttpRequest, HttpResponse, post, web}; +use actix_web::{HttpRequest, HttpResponse, post, web}; use argon2::{PasswordHash, PasswordVerifier}; -use futures::future; -use log::error; -use serde::{Deserialize, Serialize}; +use diesel::{delete, ExpressionMethods, QueryDsl}; +use diesel_async::RunQueryDsl; +use serde::Deserialize; -use crate::{Data, api::v1::auth::check_access_token, utils::get_auth_header}; +use crate::{api::v1::auth::check_access_token, error::Error, schema::users::dsl as udsl, schema::refresh_tokens::{self, dsl as rdsl}, utils::get_auth_header, Data}; #[derive(Deserialize)] struct RevokeRequest { @@ -12,17 +12,6 @@ struct RevokeRequest { device_name: String, } -#[derive(Serialize)] -struct Response { - deleted: bool, -} - -impl Response { - fn new(deleted: bool) -> Self { - Self { deleted } - } -} - // TODO: Should maybe be a delete request? #[post("/revoke")] pub async fn res( @@ -32,85 +21,33 @@ pub async fn res( ) -> Result { let headers = req.headers(); - let auth_header = get_auth_header(headers); + let auth_header = get_auth_header(headers)?; - if let Err(error) = auth_header { - return Ok(error); - } + let mut conn = data.pool.get().await?; - let authorized = check_access_token(auth_header.unwrap(), &data.pool).await; + let uuid = check_access_token(auth_header, &mut conn).await?; - if let Err(error) = authorized { - return Ok(error); - } + let database_password: String = udsl::users + .filter(udsl::uuid.eq(uuid)) + .select(udsl::password) + .get_result(&mut conn) + .await?; - let uuid = authorized.unwrap(); - - let database_password_raw = sqlx::query_scalar(&format!( - "SELECT password FROM users WHERE uuid = '{}'", - uuid - )) - .fetch_one(&data.pool) - .await; - - if let Err(error) = database_password_raw { - error!("{}", error); - return Ok(HttpResponse::InternalServerError().json(Response::new(false))); - } - - let database_password: String = database_password_raw.unwrap(); - - let hashed_password_raw = PasswordHash::new(&database_password); - - if let Err(error) = hashed_password_raw { - error!("{}", error); - return Ok(HttpResponse::InternalServerError().json(Response::new(false))); - } - - let hashed_password = hashed_password_raw.unwrap(); + let hashed_password = PasswordHash::new(&database_password).map_err(|e| Error::PasswordHashError(e.to_string()))?; if data .argon2 .verify_password(revoke_request.password.as_bytes(), &hashed_password) .is_err() { - return Ok(HttpResponse::Unauthorized().finish()); + return Err(Error::Unauthorized("Wrong username or password".to_string())); } - let tokens_raw = sqlx::query_scalar(&format!( - "SELECT token FROM refresh_tokens WHERE uuid = '{}' AND device_name = $1", - uuid - )) - .bind(&revoke_request.device_name) - .fetch_all(&data.pool) - .await; + delete(refresh_tokens::table) + .filter(rdsl::uuid.eq(uuid)) + .filter(rdsl::device_name.eq(&revoke_request.device_name)) + .execute(&mut conn) + .await?; - if tokens_raw.is_err() { - error!("{:?}", tokens_raw); - return Ok(HttpResponse::InternalServerError().json(Response::new(false))); - } - - let tokens: Vec = tokens_raw.unwrap(); - - let mut refresh_tokens_delete = vec![]; - - for token in tokens { - refresh_tokens_delete.push( - sqlx::query("DELETE FROM refresh_tokens WHERE token = $1") - .bind(token.clone()) - .execute(&data.pool), - ); - } - - let results = future::join_all(refresh_tokens_delete).await; - - let errors: Vec<&Result> = - results.iter().filter(|r| r.is_err()).collect(); - - if !errors.is_empty() { - error!("{:?}", errors); - return Ok(HttpResponse::InternalServerError().finish()); - } - - Ok(HttpResponse::Ok().json(Response::new(true))) + Ok(HttpResponse::Ok().finish()) } From 6190d762854138a915918e9376423243e88e33fe Mon Sep 17 00:00:00 2001 From: Radical Date: Fri, 23 May 2025 12:56:19 +0200 Subject: [PATCH 19/92] feat: migrate to diesel and new error type in servers --- src/api/v1/servers/mod.rs | 48 +++------- src/api/v1/servers/uuid/channels/mod.rs | 69 +++----------- .../v1/servers/uuid/channels/uuid/messages.rs | 57 +++--------- src/api/v1/servers/uuid/channels/uuid/mod.rs | 91 ++++--------------- .../v1/servers/uuid/channels/uuid/socket.rs | 56 +++--------- src/api/v1/servers/uuid/invites/mod.rs | 77 ++++------------ src/api/v1/servers/uuid/mod.rs | 31 ++----- src/api/v1/servers/uuid/roles/mod.rs | 78 ++++------------ src/api/v1/servers/uuid/roles/uuid.rs | 45 ++------- 9 files changed, 123 insertions(+), 429 deletions(-) diff --git a/src/api/v1/servers/mod.rs b/src/api/v1/servers/mod.rs index 7c74ff0..8e2e186 100644 --- a/src/api/v1/servers/mod.rs +++ b/src/api/v1/servers/mod.rs @@ -1,9 +1,9 @@ -use actix_web::{get, post, web, Error, HttpRequest, HttpResponse, Scope}; +use actix_web::{get, post, web, HttpRequest, HttpResponse, Scope}; use serde::Deserialize; mod uuid; -use crate::{api::v1::auth::check_access_token, structs::{Guild, StartAmountQuery}, utils::get_auth_header, Data}; +use crate::{error::Error, api::v1::auth::check_access_token, structs::{Guild, StartAmountQuery}, utils::get_auth_header, Data}; #[derive(Deserialize)] struct GuildInfo { @@ -26,33 +26,21 @@ pub async fn create( ) -> Result { let headers = req.headers(); - let auth_header = get_auth_header(headers); + let auth_header = get_auth_header(headers)?; - if let Err(error) = auth_header { - return Ok(error); - } + let mut conn = data.pool.get().await?; - let authorized = check_access_token(auth_header.unwrap(), &data.pool).await; - - if let Err(error) = authorized { - return Ok(error); - } - - let uuid = authorized.unwrap(); + let uuid = check_access_token(auth_header, &mut conn).await?; let guild = Guild::new( - &data.pool, + &mut conn, guild_info.name.clone(), guild_info.description.clone(), uuid, ) - .await; + .await?; - if let Err(error) = guild { - return Ok(error); - } - - Ok(HttpResponse::Ok().json(guild.unwrap())) + Ok(HttpResponse::Ok().json(guild)) } #[get("")] @@ -63,28 +51,16 @@ pub async fn get( ) -> Result { let headers = req.headers(); - let auth_header = get_auth_header(headers); + let auth_header = get_auth_header(headers)?; let start = request_query.start.unwrap_or(0); let amount = request_query.amount.unwrap_or(10); - if let Err(error) = auth_header { - return Ok(error); - } + check_access_token(auth_header, &mut data.pool.get().await.unwrap()).await?; - let authorized = check_access_token(auth_header.unwrap(), &data.pool).await; + let guilds = Guild::fetch_amount(&data.pool, start, amount).await?; - if let Err(error) = authorized { - return Ok(error); - } - - let guilds = Guild::fetch_amount(&data.pool, start, amount).await; - - if let Err(error) = guilds { - return Ok(error); - } - - Ok(HttpResponse::Ok().json(guilds.unwrap())) + Ok(HttpResponse::Ok().json(guilds)) } diff --git a/src/api/v1/servers/uuid/channels/mod.rs b/src/api/v1/servers/uuid/channels/mod.rs index 3e6a342..4348422 100644 --- a/src/api/v1/servers/uuid/channels/mod.rs +++ b/src/api/v1/servers/uuid/channels/mod.rs @@ -1,12 +1,12 @@ use crate::{ + error::Error, Data, api::v1::auth::check_access_token, structs::{Channel, Member}, utils::get_auth_header, }; use ::uuid::Uuid; -use actix_web::{Error, HttpRequest, HttpResponse, get, post, web}; -use log::error; +use actix_web::{HttpRequest, HttpResponse, get, post, web}; use serde::Deserialize; pub mod uuid; @@ -25,52 +25,27 @@ pub async fn get( ) -> Result { let headers = req.headers(); - let auth_header = get_auth_header(headers); - - if let Err(error) = auth_header { - return Ok(error); - } + let auth_header = get_auth_header(headers)?; let guild_uuid = path.into_inner().0; - let authorized = check_access_token(auth_header.unwrap(), &data.pool).await; + let mut conn = data.pool.get().await?; - if let Err(error) = authorized { - return Ok(error); - } + let uuid = check_access_token(auth_header, &mut conn).await?; - let uuid = authorized.unwrap(); + Member::fetch_one(&mut conn, uuid, guild_uuid).await?; - let member = Member::fetch_one(&data.pool, uuid, guild_uuid).await; - - if let Err(error) = member { - return Ok(error); - } - - let cache_result = data.get_cache_key(format!("{}_channels", guild_uuid)).await; - - if let Ok(cache_hit) = cache_result { + if let Ok(cache_hit) = data.get_cache_key(format!("{}_channels", guild_uuid)).await { return Ok(HttpResponse::Ok() .content_type("application/json") .body(cache_hit)); } - let channels_result = Channel::fetch_all(&data.pool, guild_uuid).await; + let channels = Channel::fetch_all(&data.pool, guild_uuid).await?; - if let Err(error) = channels_result { - return Ok(error); - } - - let channels = channels_result.unwrap(); - - let cache_result = data + data .set_cache_key(format!("{}_channels", guild_uuid), channels.clone(), 1800) - .await; - - if let Err(error) = cache_result { - error!("{}", error); - return Ok(HttpResponse::InternalServerError().finish()); - } + .await?; Ok(HttpResponse::Ok().json(channels)) } @@ -84,27 +59,15 @@ pub async fn create( ) -> Result { let headers = req.headers(); - let auth_header = get_auth_header(headers); - - if let Err(error) = auth_header { - return Ok(error); - } + let auth_header = get_auth_header(headers)?; let guild_uuid = path.into_inner().0; - let authorized = check_access_token(auth_header.unwrap(), &data.pool).await; + let mut conn = data.pool.get().await?; - if let Err(error) = authorized { - return Ok(error); - } + let uuid = check_access_token(auth_header, &mut conn).await?; - let uuid = authorized.unwrap(); - - let member = Member::fetch_one(&data.pool, uuid, guild_uuid).await; - - if let Err(error) = member { - return Ok(error); - } + Member::fetch_one(&mut conn, uuid, guild_uuid).await?; // FIXME: Logic to check permissions, should probably be done in utils.rs @@ -116,9 +79,5 @@ pub async fn create( ) .await; - if let Err(error) = channel { - return Ok(error); - } - Ok(HttpResponse::Ok().json(channel.unwrap())) } diff --git a/src/api/v1/servers/uuid/channels/uuid/messages.rs b/src/api/v1/servers/uuid/channels/uuid/messages.rs index ff36a4f..954651e 100644 --- a/src/api/v1/servers/uuid/channels/uuid/messages.rs +++ b/src/api/v1/servers/uuid/channels/uuid/messages.rs @@ -1,12 +1,12 @@ use crate::{ + error::Error, Data, api::v1::auth::check_access_token, structs::{Channel, Member}, utils::get_auth_header, }; use ::uuid::Uuid; -use actix_web::{Error, HttpRequest, HttpResponse, get, web}; -use log::error; +use actix_web::{HttpRequest, HttpResponse, get, web}; use serde::Deserialize; #[derive(Deserialize)] @@ -24,60 +24,31 @@ pub async fn get( ) -> Result { let headers = req.headers(); - let auth_header = get_auth_header(headers); - - if let Err(error) = auth_header { - return Ok(error); - } + let auth_header = get_auth_header(headers)?; let (guild_uuid, channel_uuid) = path.into_inner(); - let authorized = check_access_token(auth_header.unwrap(), &data.pool).await; + let mut conn = data.pool.get().await?; - if let Err(error) = authorized { - return Ok(error); - } + let uuid = check_access_token(auth_header, &mut conn).await?; - let uuid = authorized.unwrap(); - - let member = Member::fetch_one(&data.pool, uuid, guild_uuid).await; - - if let Err(error) = member { - return Ok(error); - } - - let cache_result = data.get_cache_key(format!("{}", channel_uuid)).await; + Member::fetch_one(&mut conn, uuid, guild_uuid).await?; let channel: Channel; - if let Ok(cache_hit) = cache_result { - channel = serde_json::from_str(&cache_hit).unwrap() + if let Ok(cache_hit) = data.get_cache_key(format!("{}", channel_uuid)).await { + channel = serde_json::from_str(&cache_hit)? } else { - let channel_result = Channel::fetch_one(&data.pool, guild_uuid, channel_uuid).await; + channel = Channel::fetch_one(&mut conn, channel_uuid).await?; - if let Err(error) = channel_result { - return Ok(error); - } - - channel = channel_result.unwrap(); - - let cache_result = data + data .set_cache_key(format!("{}", channel_uuid), channel.clone(), 60) - .await; - - if let Err(error) = cache_result { - error!("{}", error); - return Ok(HttpResponse::InternalServerError().finish()); - } + .await?; } let messages = channel - .fetch_messages(&data.pool, message_request.amount, message_request.offset) - .await; + .fetch_messages(&mut conn, message_request.amount, message_request.offset) + .await?; - if let Err(error) = messages { - return Ok(error); - } - - Ok(HttpResponse::Ok().json(messages.unwrap())) + Ok(HttpResponse::Ok().json(messages)) } diff --git a/src/api/v1/servers/uuid/channels/uuid/mod.rs b/src/api/v1/servers/uuid/channels/uuid/mod.rs index c737509..4cf6013 100644 --- a/src/api/v1/servers/uuid/channels/uuid/mod.rs +++ b/src/api/v1/servers/uuid/channels/uuid/mod.rs @@ -2,14 +2,14 @@ pub mod messages; pub mod socket; use crate::{ + error::Error, Data, api::v1::auth::check_access_token, structs::{Channel, Member}, utils::get_auth_header, }; -use ::uuid::Uuid; -use actix_web::{Error, HttpRequest, HttpResponse, delete, get, web}; -use log::error; +use uuid::Uuid; +use actix_web::{HttpRequest, HttpResponse, delete, get, web}; #[get("{uuid}/channels/{channel_uuid}")] pub async fn get( @@ -19,52 +19,27 @@ pub async fn get( ) -> Result { let headers = req.headers(); - let auth_header = get_auth_header(headers); - - if let Err(error) = auth_header { - return Ok(error); - } + let auth_header = get_auth_header(headers)?; let (guild_uuid, channel_uuid) = path.into_inner(); - let authorized = check_access_token(auth_header.unwrap(), &data.pool).await; + let mut conn = data.pool.get().await?; - if let Err(error) = authorized { - return Ok(error); - } + let uuid = check_access_token(auth_header, &mut conn).await?; - let uuid = authorized.unwrap(); + Member::fetch_one(&mut conn, uuid, guild_uuid).await?; - let member = Member::fetch_one(&data.pool, uuid, guild_uuid).await; - - if let Err(error) = member { - return Ok(error); - } - - let cache_result = data.get_cache_key(format!("{}", channel_uuid)).await; - - if let Ok(cache_hit) = cache_result { + if let Ok(cache_hit) = data.get_cache_key(format!("{}", channel_uuid)).await { return Ok(HttpResponse::Ok() .content_type("application/json") .body(cache_hit)); } - let channel_result = Channel::fetch_one(&data.pool, guild_uuid, channel_uuid).await; + let channel = Channel::fetch_one(&mut conn, channel_uuid).await?; - if let Err(error) = channel_result { - return Ok(error); - } - - let channel = channel_result.unwrap(); - - let cache_result = data + data .set_cache_key(format!("{}", channel_uuid), channel.clone(), 60) - .await; - - if let Err(error) = cache_result { - error!("{}", error); - return Ok(HttpResponse::InternalServerError().finish()); - } + .await?; Ok(HttpResponse::Ok().json(channel)) } @@ -77,55 +52,27 @@ pub async fn delete( ) -> Result { let headers = req.headers(); - let auth_header = get_auth_header(headers); - - if let Err(error) = auth_header { - return Ok(error); - } + let auth_header = get_auth_header(headers)?; let (guild_uuid, channel_uuid) = path.into_inner(); - let authorized = check_access_token(auth_header.unwrap(), &data.pool).await; + let mut conn = data.pool.get().await?; - if let Err(error) = authorized { - return Ok(error); - } + let uuid = check_access_token(auth_header, &mut conn).await?; - let uuid = authorized.unwrap(); - - let member = Member::fetch_one(&data.pool, uuid, guild_uuid).await; - - if let Err(error) = member { - return Ok(error); - } - - let cache_result = data.get_cache_key(format!("{}", channel_uuid)).await; + Member::fetch_one(&mut conn, uuid, guild_uuid).await?; let channel: Channel; - if let Ok(cache_hit) = cache_result { + if let Ok(cache_hit) = data.get_cache_key(format!("{}", channel_uuid)).await { channel = serde_json::from_str(&cache_hit).unwrap(); - let result = data.del_cache_key(format!("{}", channel_uuid)).await; - - if let Err(error) = result { - error!("{}", error) - } + data.del_cache_key(format!("{}", channel_uuid)).await?; } else { - let channel_result = Channel::fetch_one(&data.pool, guild_uuid, channel_uuid).await; - - if let Err(error) = channel_result { - return Ok(error); - } - - channel = channel_result.unwrap(); + channel = Channel::fetch_one(&mut conn, channel_uuid).await?; } - let delete_result = channel.delete(&data.pool).await; - - if let Err(error) = delete_result { - return Ok(error); - } + channel.delete(&mut conn).await?; Ok(HttpResponse::Ok().finish()) } diff --git a/src/api/v1/servers/uuid/channels/uuid/socket.rs b/src/api/v1/servers/uuid/channels/uuid/socket.rs index b9b4ff7..14cb7d9 100644 --- a/src/api/v1/servers/uuid/channels/uuid/socket.rs +++ b/src/api/v1/servers/uuid/channels/uuid/socket.rs @@ -1,7 +1,6 @@ use actix_web::{Error, HttpRequest, HttpResponse, get, rt, web}; use actix_ws::AggregatedMessage; use futures_util::StreamExt as _; -use log::error; use uuid::Uuid; use crate::{ @@ -22,57 +21,30 @@ pub async fn echo( let headers = req.headers(); // Retrieve auth header - let auth_header = get_auth_header(headers); - - if let Err(error) = auth_header { - return Ok(error); - } + let auth_header = get_auth_header(headers)?; // Get uuids from path let (guild_uuid, channel_uuid) = path.into_inner(); + let mut conn = data.pool.get().await.map_err(|e| crate::error::Error::from(e))?; + // Authorize client using auth header - let authorized = check_access_token(auth_header.unwrap(), &data.pool).await; - - if let Err(error) = authorized { - return Ok(error); - } - - // Unwrap user uuid from authorization - let uuid = authorized.unwrap(); + let uuid = check_access_token(auth_header, &mut conn).await?; // Get server member from psql - let member = Member::fetch_one(&data.pool, uuid, guild_uuid).await; - - if let Err(error) = member { - return Ok(error); - } - - // Get cache for channel - let cache_result = data.get_cache_key(format!("{}", channel_uuid)).await; + Member::fetch_one(&mut conn, uuid, guild_uuid).await?; let channel: Channel; // Return channel cache or result from psql as `channel` variable - if let Ok(cache_hit) = cache_result { + if let Ok(cache_hit) = data.get_cache_key(format!("{}", channel_uuid)).await { channel = serde_json::from_str(&cache_hit).unwrap() } else { - let channel_result = Channel::fetch_one(&data.pool, guild_uuid, channel_uuid).await; + channel = Channel::fetch_one(&mut conn, channel_uuid).await?; - if let Err(error) = channel_result { - return Ok(error); - } - - channel = channel_result.unwrap(); - - let cache_result = data + data .set_cache_key(format!("{}", channel_uuid), channel.clone(), 60) - .await; - - if let Err(error) = cache_result { - error!("{}", error); - return Ok(HttpResponse::InternalServerError().finish()); - } + .await?; } let (res, mut session_1, stream) = actix_ws::handle(&req, stream)?; @@ -82,17 +54,11 @@ pub async fn echo( // aggregate continuation frames up to 1MiB .max_continuation_size(2_usize.pow(20)); - let pubsub_result = data.cache_pool.get_async_pubsub().await; - - if let Err(error) = pubsub_result { - error!("{}", error); - return Ok(HttpResponse::InternalServerError().finish()); - } + let mut pubsub = data.cache_pool.get_async_pubsub().await.map_err(|e| crate::error::Error::from(e))?; let mut session_2 = session_1.clone(); rt::spawn(async move { - let mut pubsub = pubsub_result.unwrap(); pubsub.subscribe(channel_uuid.to_string()).await.unwrap(); while let Some(msg) = pubsub.on_message().next().await { let payload: String = msg.get_payload().unwrap(); @@ -118,7 +84,7 @@ pub async fn echo( .await .unwrap(); channel - .new_message(&data.pool, uuid, text.to_string()) + .new_message(&mut data.pool.get().await.unwrap(), uuid, text.to_string()) .await .unwrap(); } diff --git a/src/api/v1/servers/uuid/invites/mod.rs b/src/api/v1/servers/uuid/invites/mod.rs index 2a07808..badb3e0 100644 --- a/src/api/v1/servers/uuid/invites/mod.rs +++ b/src/api/v1/servers/uuid/invites/mod.rs @@ -1,8 +1,9 @@ -use actix_web::{Error, HttpRequest, HttpResponse, get, post, web}; +use actix_web::{HttpRequest, HttpResponse, get, post, web}; use serde::Deserialize; use uuid::Uuid; use crate::{ + error::Error, Data, api::v1::auth::check_access_token, structs::{Guild, Member}, @@ -22,43 +23,21 @@ pub async fn get( ) -> Result { let headers = req.headers(); - let auth_header = get_auth_header(headers); - - if let Err(error) = auth_header { - return Ok(error); - } + let auth_header = get_auth_header(headers)?; let guild_uuid = path.into_inner().0; - let authorized = check_access_token(auth_header.unwrap(), &data.pool).await; + let mut conn = data.pool.get().await?; - if let Err(error) = authorized { - return Ok(error); - } + let uuid = check_access_token(auth_header, &mut conn).await?; - let uuid = authorized.unwrap(); + Member::fetch_one(&mut conn, uuid, guild_uuid).await?; - let member = Member::fetch_one(&data.pool, uuid, guild_uuid).await; + let guild = Guild::fetch_one(&mut conn, guild_uuid).await?; - if let Err(error) = member { - return Ok(error); - } + let invites = guild.get_invites(&mut conn).await?; - let guild_result = Guild::fetch_one(&data.pool, guild_uuid).await; - - if let Err(error) = guild_result { - return Ok(error); - } - - let guild = guild_result.unwrap(); - - let invites = guild.get_invites(&data.pool).await; - - if let Err(error) = invites { - return Ok(error); - } - - Ok(HttpResponse::Ok().json(invites.unwrap())) + Ok(HttpResponse::Ok().json(invites)) } #[post("{uuid}/invites")] @@ -70,45 +49,21 @@ pub async fn create( ) -> Result { let headers = req.headers(); - let auth_header = get_auth_header(headers); - - if let Err(error) = auth_header { - return Ok(error); - } + let auth_header = get_auth_header(headers)?; let guild_uuid = path.into_inner().0; - let authorized = check_access_token(auth_header.unwrap(), &data.pool).await; + let mut conn = data.pool.get().await?; - if let Err(error) = authorized { - return Ok(error); - } + let uuid = check_access_token(auth_header, &mut conn).await?; - let uuid = authorized.unwrap(); + let member = Member::fetch_one(&mut conn, uuid, guild_uuid).await?; - let member_result = Member::fetch_one(&data.pool, uuid, guild_uuid).await; - - if let Err(error) = member_result { - return Ok(error); - } - - let member = member_result.unwrap(); - - let guild_result = Guild::fetch_one(&data.pool, guild_uuid).await; - - if let Err(error) = guild_result { - return Ok(error); - } - - let guild = guild_result.unwrap(); + let guild = Guild::fetch_one(&mut conn, guild_uuid).await?; let custom_id = invite_request.as_ref().map(|ir| ir.custom_id.clone()); - let invite = guild.create_invite(&data.pool, &member, custom_id).await; + let invite = guild.create_invite(&mut conn, &member, custom_id).await?; - if let Err(error) = invite { - return Ok(error); - } - - Ok(HttpResponse::Ok().json(invite.unwrap())) + Ok(HttpResponse::Ok().json(invite)) } diff --git a/src/api/v1/servers/uuid/mod.rs b/src/api/v1/servers/uuid/mod.rs index 8f387aa..bac4004 100644 --- a/src/api/v1/servers/uuid/mod.rs +++ b/src/api/v1/servers/uuid/mod.rs @@ -1,4 +1,4 @@ -use actix_web::{Error, HttpRequest, HttpResponse, Scope, get, web}; +use actix_web::{HttpRequest, HttpResponse, Scope, get, web}; use uuid::Uuid; mod channels; @@ -6,6 +6,7 @@ mod invites; mod roles; use crate::{ + error::Error, Data, api::v1::auth::check_access_token, structs::{Guild, Member}, @@ -40,33 +41,17 @@ pub async fn res( ) -> Result { let headers = req.headers(); - let auth_header = get_auth_header(headers); - - if let Err(error) = auth_header { - return Ok(error); - } + let auth_header = get_auth_header(headers)?; let guild_uuid = path.into_inner().0; - let authorized = check_access_token(auth_header.unwrap(), &data.pool).await; + let mut conn = data.pool.get().await?; - if let Err(error) = authorized { - return Ok(error); - } + let uuid = check_access_token(auth_header, &mut conn).await?; - let uuid = authorized.unwrap(); + Member::fetch_one(&mut conn, uuid, guild_uuid).await?; - let member = Member::fetch_one(&data.pool, uuid, guild_uuid).await; + let guild = Guild::fetch_one(&mut conn, guild_uuid).await?; - if let Err(error) = member { - return Ok(error); - } - - let guild = Guild::fetch_one(&data.pool, guild_uuid).await; - - if let Err(error) = guild { - return Ok(error); - } - - Ok(HttpResponse::Ok().json(guild.unwrap())) + Ok(HttpResponse::Ok().json(guild)) } diff --git a/src/api/v1/servers/uuid/roles/mod.rs b/src/api/v1/servers/uuid/roles/mod.rs index 8d22813..a2912f9 100644 --- a/src/api/v1/servers/uuid/roles/mod.rs +++ b/src/api/v1/servers/uuid/roles/mod.rs @@ -1,13 +1,14 @@ +use ::uuid::Uuid; +use actix_web::{HttpRequest, HttpResponse, get, post, web}; +use serde::Deserialize; + use crate::{ + error::Error, Data, api::v1::auth::check_access_token, structs::{Member, Role}, utils::get_auth_header, }; -use ::uuid::Uuid; -use actix_web::{Error, HttpRequest, HttpResponse, get, post, web}; -use log::error; -use serde::Deserialize; pub mod uuid; @@ -24,52 +25,27 @@ pub async fn get( ) -> Result { let headers = req.headers(); - let auth_header = get_auth_header(headers); - - if let Err(error) = auth_header { - return Ok(error); - } + let auth_header = get_auth_header(headers)?; let guild_uuid = path.into_inner().0; - let authorized = check_access_token(auth_header.unwrap(), &data.pool).await; + let mut conn = data.pool.get().await?; - if let Err(error) = authorized { - return Ok(error); - } + let uuid = check_access_token(auth_header, &mut conn).await?; - let uuid = authorized.unwrap(); + Member::fetch_one(&mut conn, uuid, guild_uuid).await?; - let member = Member::fetch_one(&data.pool, uuid, guild_uuid).await; - - if let Err(error) = member { - return Ok(error); - } - - let cache_result = data.get_cache_key(format!("{}_roles", guild_uuid)).await; - - if let Ok(cache_hit) = cache_result { + if let Ok(cache_hit) = data.get_cache_key(format!("{}_roles", guild_uuid)).await { return Ok(HttpResponse::Ok() .content_type("application/json") .body(cache_hit)); } - let roles_result = Role::fetch_all(&data.pool, guild_uuid).await; + let roles = Role::fetch_all(&mut conn, guild_uuid).await?; - if let Err(error) = roles_result { - return Ok(error); - } - - let roles = roles_result.unwrap(); - - let cache_result = data + data .set_cache_key(format!("{}_roles", guild_uuid), roles.clone(), 1800) - .await; - - if let Err(error) = cache_result { - error!("{}", error); - return Ok(HttpResponse::InternalServerError().finish()); - } + .await?; Ok(HttpResponse::Ok().json(roles)) } @@ -83,35 +59,19 @@ pub async fn create( ) -> Result { let headers = req.headers(); - let auth_header = get_auth_header(headers); - - if let Err(error) = auth_header { - return Ok(error); - } + let auth_header = get_auth_header(headers)?; let guild_uuid = path.into_inner().0; - let authorized = check_access_token(auth_header.unwrap(), &data.pool).await; + let mut conn = data.pool.get().await.unwrap(); - if let Err(error) = authorized { - return Ok(error); - } + let uuid = check_access_token(auth_header, &mut conn).await?; - let uuid = authorized.unwrap(); - - let member = Member::fetch_one(&data.pool, uuid, guild_uuid).await; - - if let Err(error) = member { - return Ok(error); - } + Member::fetch_one(&mut conn, uuid, guild_uuid).await?; // FIXME: Logic to check permissions, should probably be done in utils.rs - let role = Role::new(&data.pool, guild_uuid, role_info.name.clone()).await; + let role = Role::new(&mut conn, guild_uuid, role_info.name.clone()).await?; - if let Err(error) = role { - return Ok(error); - } - - Ok(HttpResponse::Ok().json(role.unwrap())) + Ok(HttpResponse::Ok().json(role)) } diff --git a/src/api/v1/servers/uuid/roles/uuid.rs b/src/api/v1/servers/uuid/roles/uuid.rs index 38bdca9..3279d16 100644 --- a/src/api/v1/servers/uuid/roles/uuid.rs +++ b/src/api/v1/servers/uuid/roles/uuid.rs @@ -1,12 +1,12 @@ use crate::{ + error::Error, Data, api::v1::auth::check_access_token, structs::{Member, Role}, utils::get_auth_header, }; use ::uuid::Uuid; -use actix_web::{Error, HttpRequest, HttpResponse, get, web}; -use log::error; +use actix_web::{HttpRequest, HttpResponse, get, web}; #[get("{uuid}/roles/{role_uuid}")] pub async fn get( @@ -16,52 +16,27 @@ pub async fn get( ) -> Result { let headers = req.headers(); - let auth_header = get_auth_header(headers); - - if let Err(error) = auth_header { - return Ok(error); - } + let auth_header = get_auth_header(headers)?; let (guild_uuid, role_uuid) = path.into_inner(); - let authorized = check_access_token(auth_header.unwrap(), &data.pool).await; + let mut conn = data.pool.get().await?; - if let Err(error) = authorized { - return Ok(error); - } + let uuid = check_access_token(auth_header, &mut conn).await?; - let uuid = authorized.unwrap(); + Member::fetch_one(&mut conn, uuid, guild_uuid).await?; - let member = Member::fetch_one(&data.pool, uuid, guild_uuid).await; - - if let Err(error) = member { - return Ok(error); - } - - let cache_result = data.get_cache_key(format!("{}", role_uuid)).await; - - if let Ok(cache_hit) = cache_result { + if let Ok(cache_hit) = data.get_cache_key(format!("{}", role_uuid)).await { return Ok(HttpResponse::Ok() .content_type("application/json") .body(cache_hit)); } - let role_result = Role::fetch_one(&data.pool, guild_uuid, role_uuid).await; + let role = Role::fetch_one(&mut conn, role_uuid).await?; - if let Err(error) = role_result { - return Ok(error); - } - - let role = role_result.unwrap(); - - let cache_result = data + data .set_cache_key(format!("{}", role_uuid), role.clone(), 60) - .await; - - if let Err(error) = cache_result { - error!("{}", error); - return Ok(HttpResponse::InternalServerError().finish()); - } + .await?; Ok(HttpResponse::Ok().json(role)) } From dfe2ca9486ca04cb3b6329ae8327c4e041e279c0 Mon Sep 17 00:00:00 2001 From: Radical Date: Fri, 23 May 2025 12:56:51 +0200 Subject: [PATCH 20/92] feat: migrate to diesel and new error type in users --- src/api/v1/users/me.rs | 52 ++++++++++++++++------------------------ src/api/v1/users/mod.rs | 45 ++++++++++++++++------------------ src/api/v1/users/uuid.rs | 50 +++++++++++++------------------------- 3 files changed, 59 insertions(+), 88 deletions(-) diff --git a/src/api/v1/users/me.rs b/src/api/v1/users/me.rs index f641678..49f88ba 100644 --- a/src/api/v1/users/me.rs +++ b/src/api/v1/users/me.rs @@ -1,51 +1,41 @@ -use actix_web::{Error, HttpRequest, HttpResponse, get, web}; +use actix_web::{HttpRequest, HttpResponse, get, web}; +use diesel::{prelude::Queryable, ExpressionMethods, QueryDsl, Selectable, SelectableHelper}; +use diesel_async::RunQueryDsl; use log::error; use serde::Serialize; +use uuid::Uuid; -use crate::{Data, api::v1::auth::check_access_token, utils::get_auth_header}; +use crate::{error::Error, api::v1::auth::check_access_token, schema::users::{self, dsl}, utils::get_auth_header, Data}; -#[derive(Serialize)] +#[derive(Serialize, Queryable, Selectable)] +#[diesel(table_name = users)] +#[diesel(check_for_backend(diesel::pg::Pg))] struct Response { - uuid: String, + uuid: Uuid, username: String, - display_name: String, + display_name: Option, } #[get("/me")] pub async fn res(req: HttpRequest, data: web::Data) -> Result { let headers = req.headers(); - let auth_header = get_auth_header(headers); + let auth_header = get_auth_header(headers)?; - if let Err(error) = auth_header { - return Ok(error); - } + let mut conn = data.pool.get().await?; - let authorized = check_access_token(auth_header.unwrap(), &data.pool).await; + let uuid = check_access_token(auth_header, &mut conn).await?; - if let Err(error) = authorized { - return Ok(error); - } + let user: Result = dsl::users + .filter(dsl::uuid.eq(uuid)) + .select(Response::as_select()) + .get_result(&mut conn) + .await; - let uuid = authorized.unwrap(); - - let row = sqlx::query_as(&format!( - "SELECT username, display_name FROM users WHERE uuid = '{}'", - uuid - )) - .fetch_one(&data.pool) - .await; - - if let Err(error) = row { + if let Err(error) = user { error!("{}", error); - return Ok(HttpResponse::InternalServerError().finish()); + return Ok(HttpResponse::InternalServerError().finish()) } - let (username, display_name): (String, Option) = row.unwrap(); - - Ok(HttpResponse::Ok().json(Response { - uuid: uuid.to_string(), - username, - display_name: display_name.unwrap_or_default(), - })) + Ok(HttpResponse::Ok().json(user.unwrap())) } diff --git a/src/api/v1/users/mod.rs b/src/api/v1/users/mod.rs index d6eb6bd..37d884a 100644 --- a/src/api/v1/users/mod.rs +++ b/src/api/v1/users/mod.rs @@ -1,15 +1,19 @@ -use crate::{api::v1::auth::check_access_token, structs::StartAmountQuery, utils::get_auth_header, Data}; -use actix_web::{Error, HttpRequest, HttpResponse, Scope, get, web}; -use log::error; +use actix_web::{HttpRequest, HttpResponse, Scope, get, web}; +use diesel::{prelude::Queryable, QueryDsl, Selectable, SelectableHelper}; +use diesel_async::RunQueryDsl; use serde::Serialize; -use sqlx::prelude::FromRow; +use ::uuid::Uuid; + +use crate::{error::Error,api::v1::auth::check_access_token, schema::users::{self, dsl}, structs::StartAmountQuery, utils::get_auth_header, Data}; mod me; mod uuid; -#[derive(Serialize, FromRow)] +#[derive(Serialize, Queryable, Selectable)] +#[diesel(table_name = users)] +#[diesel(check_for_backend(diesel::pg::Pg))] struct Response { - uuid: String, + uuid: Uuid, username: String, display_name: Option, email: String, @@ -30,7 +34,7 @@ pub async fn res( ) -> Result { let headers = req.headers(); - let auth_header = get_auth_header(headers); + let auth_header = get_auth_header(headers)?; let start = request_query.start.unwrap_or(0); @@ -40,24 +44,17 @@ pub async fn res( return Ok(HttpResponse::BadRequest().finish()); } - let authorized = check_access_token(auth_header.unwrap(), &data.pool).await; + let mut conn = data.pool.get().await?; - if let Err(error) = authorized { - return Ok(error); - } + check_access_token(auth_header, &mut conn).await?; - 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 users: Vec = dsl::users + .order_by(dsl::username) + .offset(start) + .limit(amount) + .select(Response::as_select()) + .load(&mut conn) + .await?; - if let Err(error) = row { - error!("{}", error); - return Ok(HttpResponse::InternalServerError().finish()); - } - - let accounts: Vec = row.unwrap(); - - Ok(HttpResponse::Ok().json(accounts)) + Ok(HttpResponse::Ok().json(users)) } diff --git a/src/api/v1/users/uuid.rs b/src/api/v1/users/uuid.rs index 9edaffa..bfb0f69 100644 --- a/src/api/v1/users/uuid.rs +++ b/src/api/v1/users/uuid.rs @@ -1,15 +1,19 @@ -use actix_web::{Error, HttpRequest, HttpResponse, get, web}; +use actix_web::{HttpRequest, HttpResponse, get, web}; +use diesel::{ExpressionMethods, QueryDsl, Queryable, Selectable, SelectableHelper}; +use diesel_async::RunQueryDsl; use log::error; use serde::Serialize; use uuid::Uuid; -use crate::{Data, api::v1::auth::check_access_token, utils::get_auth_header}; +use crate::{error::Error, api::v1::auth::check_access_token, schema::users::{self, dsl}, utils::get_auth_header, Data}; -#[derive(Serialize, Clone)] +#[derive(Serialize, Queryable, Selectable, Clone)] +#[diesel(table_name = users)] +#[diesel(check_for_backend(diesel::pg::Pg))] struct Response { - uuid: String, + uuid: Uuid, username: String, - display_name: String, + display_name: Option, } #[get("/{uuid}")] @@ -22,17 +26,11 @@ pub async fn res( let uuid = path.into_inner().0; - let auth_header = get_auth_header(headers); + let auth_header = get_auth_header(headers)?; - if let Err(error) = auth_header { - return Ok(error); - } + let mut conn = data.pool.get().await?; - let authorized = check_access_token(auth_header.unwrap(), &data.pool).await; - - if let Err(error) = authorized { - return Ok(error); - } + check_access_token(auth_header, &mut conn).await?; let cache_result = data.get_cache_key(uuid.to_string()).await; @@ -42,25 +40,11 @@ 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; - - if let Err(error) = row { - error!("{}", error); - return Ok(HttpResponse::InternalServerError().finish()); - } - - 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: Response = dsl::users + .filter(dsl::uuid.eq(uuid)) + .select(Response::as_select()) + .get_result(&mut conn) + .await?; let cache_result = data .set_cache_key(uuid.to_string(), user.clone(), 1800) From 49e08af3d97170c90d24f532c501bf1d8df71b97 Mon Sep 17 00:00:00 2001 From: Radical Date: Fri, 23 May 2025 12:57:08 +0200 Subject: [PATCH 21/92] feat: migrate to diesel and new error type in invites --- src/api/v1/invites/id.rs | 63 +++++++++------------------------------- 1 file changed, 14 insertions(+), 49 deletions(-) diff --git a/src/api/v1/invites/id.rs b/src/api/v1/invites/id.rs index 2adb8d8..67f10af 100644 --- a/src/api/v1/invites/id.rs +++ b/src/api/v1/invites/id.rs @@ -1,6 +1,7 @@ -use actix_web::{Error, HttpRequest, HttpResponse, get, post, web}; +use actix_web::{HttpRequest, HttpResponse, get, post, web}; use crate::{ + error::Error, Data, api::v1::auth::check_access_token, structs::{Guild, Invite, Member}, @@ -15,29 +16,17 @@ pub async fn get( ) -> Result { let headers = req.headers(); - let auth_header = get_auth_header(headers); + let auth_header = get_auth_header(headers)?; - if let Err(error) = auth_header { - return Ok(error); - } + let mut conn = data.pool.get().await?; + + check_access_token(auth_header, &mut conn).await?; let invite_id = path.into_inner().0; - let result = Invite::fetch_one(&data.pool, invite_id).await; + let invite = Invite::fetch_one(&mut conn, invite_id).await?; - if let Err(error) = result { - return Ok(error); - } - - let invite = result.unwrap(); - - let guild_result = Guild::fetch_one(&data.pool, invite.guild_uuid).await; - - if let Err(error) = guild_result { - return Ok(error); - } - - let guild = guild_result.unwrap(); + let guild = Guild::fetch_one(&mut conn, invite.guild_uuid).await?; Ok(HttpResponse::Ok().json(guild)) } @@ -50,43 +39,19 @@ pub async fn join( ) -> Result { let headers = req.headers(); - let auth_header = get_auth_header(headers); - - if let Err(error) = auth_header { - return Ok(error); - } + let auth_header = get_auth_header(headers)?; let invite_id = path.into_inner().0; - let authorized = check_access_token(auth_header.unwrap(), &data.pool).await; + let mut conn = data.pool.get().await?; - if let Err(error) = authorized { - return Ok(error); - } + let uuid = check_access_token(auth_header, &mut conn).await?; - let uuid = authorized.unwrap(); + let invite = Invite::fetch_one(&mut conn, invite_id).await?; - let result = Invite::fetch_one(&data.pool, invite_id).await; + let guild = Guild::fetch_one(&mut conn, invite.guild_uuid).await?; - if let Err(error) = result { - return Ok(error); - } - - let invite = result.unwrap(); - - let guild_result = Guild::fetch_one(&data.pool, invite.guild_uuid).await; - - if let Err(error) = guild_result { - return Ok(error); - } - - let guild = guild_result.unwrap(); - - let member = Member::new(&data.pool, uuid, guild.uuid).await; - - if let Err(error) = member { - return Ok(error); - } + Member::new(&mut conn, uuid, guild.uuid).await?; Ok(HttpResponse::Ok().json(guild)) } From a670b32c86e99f8074d421d031187ebc8253ffc4 Mon Sep 17 00:00:00 2001 From: Radical Date: Fri, 23 May 2025 12:57:19 +0200 Subject: [PATCH 22/92] feat: migrate to diesel and new error type in stats --- src/api/v1/stats.rs | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/src/api/v1/stats.rs b/src/api/v1/stats.rs index 0ebf431..6ab8d64 100644 --- a/src/api/v1/stats.rs +++ b/src/api/v1/stats.rs @@ -1,31 +1,31 @@ use std::time::SystemTime; -use actix_web::{HttpResponse, Responder, get, web}; +use actix_web::{HttpResponse, get, web}; +use diesel::QueryDsl; +use diesel_async::RunQueryDsl; use serde::Serialize; +use crate::error::Error; use crate::Data; +use crate::schema::users::dsl::{users, uuid}; const VERSION: Option<&'static str> = option_env!("CARGO_PKG_VERSION"); #[derive(Serialize)] struct Response { - accounts: usize, + accounts: i64, uptime: u64, version: String, build_number: String, } #[get("/stats")] -pub async fn res(data: web::Data) -> impl Responder { - let accounts; - if let Ok(users) = sqlx::query("SELECT uuid FROM users") - .fetch_all(&data.pool) - .await - { - accounts = users.len(); - } else { - return HttpResponse::InternalServerError().finish(); - } +pub async fn res(data: web::Data) -> Result { + let accounts: i64 = users + .select(uuid) + .count() + .get_result(&mut data.pool.get().await?) + .await?; let response = Response { // TODO: Get number of accounts from db @@ -39,5 +39,5 @@ pub async fn res(data: web::Data) -> impl Responder { build_number: String::from("how do i implement this?"), }; - HttpResponse::Ok().json(response) + Ok(HttpResponse::Ok().json(response)) } From 81f7527c79e7c31e30938781e76cae9757147e18 Mon Sep 17 00:00:00 2001 From: Radical Date: Fri, 23 May 2025 20:32:43 +0200 Subject: [PATCH 23/92] 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 24/92] 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 25/92] 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)] From 8605b81e7b106d424f149b645d51c74ae2930050 Mon Sep 17 00:00:00 2001 From: Radical Date: Sat, 24 May 2025 01:09:17 +0200 Subject: [PATCH 26/92] style: cargo clippy && format --- build.rs | 4 +- src/api/v1/auth/login.rs | 33 +++-- src/api/v1/auth/mod.rs | 11 +- src/api/v1/auth/refresh.rs | 18 +-- src/api/v1/auth/register.rs | 18 ++- src/api/v1/auth/revoke.rs | 18 ++- src/api/v1/invites/id.rs | 2 +- src/api/v1/servers/mod.rs | 11 +- src/api/v1/servers/uuid/channels/mod.rs | 5 +- .../v1/servers/uuid/channels/uuid/messages.rs | 5 +- src/api/v1/servers/uuid/channels/uuid/mod.rs | 7 +- .../v1/servers/uuid/channels/uuid/socket.rs | 11 +- src/api/v1/servers/uuid/icon.rs | 21 ++- src/api/v1/servers/uuid/invites/mod.rs | 2 +- src/api/v1/servers/uuid/mod.rs | 4 +- src/api/v1/servers/uuid/roles/mod.rs | 5 +- src/api/v1/servers/uuid/roles/uuid.rs | 5 +- src/api/v1/stats.rs | 2 +- src/api/v1/users/me.rs | 22 ++- src/api/v1/users/mod.rs | 8 +- src/api/v1/users/uuid.rs | 8 +- src/config.rs | 2 +- src/error.rs | 25 ++-- src/main.rs | 38 +++-- src/structs.rs | 134 ++++++++++-------- src/utils.rs | 33 +++-- 26 files changed, 274 insertions(+), 178 deletions(-) diff --git a/build.rs b/build.rs index 284ad12..3a8149e 100644 --- a/build.rs +++ b/build.rs @@ -1,3 +1,3 @@ fn main() { - println!("cargo:rerun-if-changed=migrations"); -} \ No newline at end of file + println!("cargo:rerun-if-changed=migrations"); +} diff --git a/src/api/v1/auth/login.rs b/src/api/v1/auth/login.rs index 8ad345e..c3e8bc7 100644 --- a/src/api/v1/auth/login.rs +++ b/src/api/v1/auth/login.rs @@ -2,13 +2,17 @@ use std::time::{SystemTime, UNIX_EPOCH}; use actix_web::{HttpResponse, post, web}; use argon2::{PasswordHash, PasswordVerifier}; -use diesel::{dsl::insert_into, ExpressionMethods, QueryDsl}; +use diesel::{ExpressionMethods, QueryDsl, dsl::insert_into}; use diesel_async::RunQueryDsl; use serde::Deserialize; use uuid::Uuid; use crate::{ - error::Error, api::v1::auth::{EMAIL_REGEX, PASSWORD_REGEX, USERNAME_REGEX}, schema::*, utils::{generate_access_token, generate_refresh_token, refresh_token_cookie}, Data + Data, + api::v1::auth::{EMAIL_REGEX, PASSWORD_REGEX, USERNAME_REGEX}, + error::Error, + schema::*, + utils::{generate_access_token, generate_refresh_token, refresh_token_cookie}, }; use super::Response; @@ -79,34 +83,45 @@ async fn login( ) -> Result { let mut conn = data.pool.get().await?; - let parsed_hash = PasswordHash::new(&database_password).map_err(|e| Error::PasswordHashError(e.to_string()))?; + let parsed_hash = PasswordHash::new(&database_password) + .map_err(|e| Error::PasswordHashError(e.to_string()))?; if data .argon2 .verify_password(request_password.as_bytes(), &parsed_hash) .is_err() { - return Err(Error::Unauthorized("Wrong username or password".to_string())); + return Err(Error::Unauthorized( + "Wrong username or password".to_string(), + )); } let refresh_token = generate_refresh_token()?; let access_token = generate_access_token()?; - let current_time = SystemTime::now() - .duration_since(UNIX_EPOCH)? - .as_secs() as i64; + let current_time = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs() as i64; use refresh_tokens::dsl as rdsl; insert_into(refresh_tokens::table) - .values((rdsl::token.eq(&refresh_token), rdsl::uuid.eq(uuid), rdsl::created_at.eq(current_time), rdsl::device_name.eq(device_name))) + .values(( + rdsl::token.eq(&refresh_token), + rdsl::uuid.eq(uuid), + rdsl::created_at.eq(current_time), + rdsl::device_name.eq(device_name), + )) .execute(&mut conn) .await?; use access_tokens::dsl as adsl; insert_into(access_tokens::table) - .values((adsl::token.eq(&access_token), adsl::refresh_token.eq(&refresh_token), adsl::uuid.eq(uuid), adsl::created_at.eq(current_time))) + .values(( + adsl::token.eq(&access_token), + adsl::refresh_token.eq(&refresh_token), + adsl::uuid.eq(uuid), + adsl::created_at.eq(current_time), + )) .execute(&mut conn) .await?; diff --git a/src/api/v1/auth/mod.rs b/src/api/v1/auth/mod.rs index 249ec4b..2bc0d0b 100644 --- a/src/api/v1/auth/mod.rs +++ b/src/api/v1/auth/mod.rs @@ -10,7 +10,7 @@ use regex::Regex; use serde::Serialize; use uuid::Uuid; -use crate::{error::Error, Conn, schema::access_tokens::dsl}; +use crate::{Conn, error::Error, schema::access_tokens::dsl}; mod login; mod refresh; @@ -39,10 +39,7 @@ pub fn web() -> Scope { .service(revoke::res) } -pub async fn check_access_token( - access_token: &str, - conn: &mut Conn, -) -> Result { +pub async fn check_access_token(access_token: &str, conn: &mut Conn) -> Result { let (uuid, created_at): (Uuid, i64) = dsl::access_tokens .filter(dsl::token.eq(access_token)) .select((dsl::uuid, dsl::created_at)) @@ -56,9 +53,7 @@ pub async fn check_access_token( } })?; - let current_time = SystemTime::now() - .duration_since(UNIX_EPOCH)? - .as_secs() as i64; + let current_time = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs() as i64; let lifetime = current_time - created_at; diff --git a/src/api/v1/auth/refresh.rs b/src/api/v1/auth/refresh.rs index 468945d..303748a 100644 --- a/src/api/v1/auth/refresh.rs +++ b/src/api/v1/auth/refresh.rs @@ -1,11 +1,17 @@ use actix_web::{HttpRequest, HttpResponse, post, web}; -use diesel::{delete, update, ExpressionMethods, QueryDsl}; +use diesel::{ExpressionMethods, QueryDsl, delete, update}; use diesel_async::RunQueryDsl; use log::error; use std::time::{SystemTime, UNIX_EPOCH}; use crate::{ - error::Error, schema::{access_tokens::{self, dsl}, refresh_tokens::{self, dsl as rdsl}}, utils::{generate_access_token, generate_refresh_token, refresh_token_cookie}, Data + Data, + error::Error, + schema::{ + access_tokens::{self, dsl}, + refresh_tokens::{self, dsl as rdsl}, + }, + utils::{generate_access_token, generate_refresh_token, refresh_token_cookie}, }; use super::Response; @@ -20,9 +26,7 @@ pub async fn res(req: HttpRequest, data: web::Data) -> Result) -> Result 1987200 { let new_refresh_token = generate_refresh_token(); diff --git a/src/api/v1/auth/register.rs b/src/api/v1/auth/register.rs index 3add620..75aeb9d 100644 --- a/src/api/v1/auth/register.rs +++ b/src/api/v1/auth/register.rs @@ -5,14 +5,22 @@ use argon2::{ PasswordHasher, password_hash::{SaltString, rand_core::OsRng}, }; -use diesel::{dsl::insert_into, ExpressionMethods}; +use diesel::{ExpressionMethods, dsl::insert_into}; use diesel_async::RunQueryDsl; use serde::{Deserialize, Serialize}; use uuid::Uuid; use super::Response; use crate::{ - api::v1::auth::{EMAIL_REGEX, PASSWORD_REGEX, USERNAME_REGEX}, error::Error, schema::{access_tokens::{self, dsl as adsl}, refresh_tokens::{self, dsl as rdsl}, users::{self, dsl as udsl}}, utils::{generate_access_token, generate_refresh_token, refresh_token_cookie}, Data + Data, + api::v1::auth::{EMAIL_REGEX, PASSWORD_REGEX, USERNAME_REGEX}, + error::Error, + schema::{ + access_tokens::{self, dsl as adsl}, + refresh_tokens::{self, dsl as rdsl}, + users::{self, dsl as udsl}, + }, + utils::{generate_access_token, generate_refresh_token, refresh_token_cookie}, }; #[derive(Deserialize)] @@ -107,9 +115,7 @@ pub async fn res( let refresh_token = generate_refresh_token()?; let access_token = generate_access_token()?; - let current_time = SystemTime::now() - .duration_since(UNIX_EPOCH)? - .as_secs() as i64; + let current_time = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs() as i64; insert_into(refresh_tokens::table) .values(( @@ -133,7 +139,7 @@ pub async fn res( return Ok(HttpResponse::Ok() .cookie(refresh_token_cookie(refresh_token)) - .json(Response { access_token })) + .json(Response { access_token })); } Ok(HttpResponse::InternalServerError().finish()) diff --git a/src/api/v1/auth/revoke.rs b/src/api/v1/auth/revoke.rs index 116ed5c..2e95884 100644 --- a/src/api/v1/auth/revoke.rs +++ b/src/api/v1/auth/revoke.rs @@ -1,10 +1,17 @@ use actix_web::{HttpRequest, HttpResponse, post, web}; use argon2::{PasswordHash, PasswordVerifier}; -use diesel::{delete, ExpressionMethods, QueryDsl}; +use diesel::{ExpressionMethods, QueryDsl, delete}; use diesel_async::RunQueryDsl; use serde::Deserialize; -use crate::{api::v1::auth::check_access_token, error::Error, schema::users::dsl as udsl, schema::refresh_tokens::{self, dsl as rdsl}, utils::get_auth_header, Data}; +use crate::{ + Data, + api::v1::auth::check_access_token, + error::Error, + schema::refresh_tokens::{self, dsl as rdsl}, + schema::users::dsl as udsl, + utils::get_auth_header, +}; #[derive(Deserialize)] struct RevokeRequest { @@ -33,14 +40,17 @@ pub async fn res( .get_result(&mut conn) .await?; - let hashed_password = PasswordHash::new(&database_password).map_err(|e| Error::PasswordHashError(e.to_string()))?; + let hashed_password = PasswordHash::new(&database_password) + .map_err(|e| Error::PasswordHashError(e.to_string()))?; if data .argon2 .verify_password(revoke_request.password.as_bytes(), &hashed_password) .is_err() { - return Err(Error::Unauthorized("Wrong username or password".to_string())); + return Err(Error::Unauthorized( + "Wrong username or password".to_string(), + )); } delete(refresh_tokens::table) diff --git a/src/api/v1/invites/id.rs b/src/api/v1/invites/id.rs index 67f10af..601d9db 100644 --- a/src/api/v1/invites/id.rs +++ b/src/api/v1/invites/id.rs @@ -1,9 +1,9 @@ use actix_web::{HttpRequest, HttpResponse, get, post, web}; use crate::{ - error::Error, Data, api::v1::auth::check_access_token, + error::Error, structs::{Guild, Invite, Member}, utils::get_auth_header, }; diff --git a/src/api/v1/servers/mod.rs b/src/api/v1/servers/mod.rs index 8e2e186..76a4c16 100644 --- a/src/api/v1/servers/mod.rs +++ b/src/api/v1/servers/mod.rs @@ -1,9 +1,15 @@ -use actix_web::{get, post, web, HttpRequest, HttpResponse, Scope}; +use actix_web::{HttpRequest, HttpResponse, Scope, get, post, web}; use serde::Deserialize; mod uuid; -use crate::{error::Error, api::v1::auth::check_access_token, structs::{Guild, StartAmountQuery}, utils::get_auth_header, Data}; +use crate::{ + Data, + api::v1::auth::check_access_token, + error::Error, + structs::{Guild, StartAmountQuery}, + utils::get_auth_header, +}; #[derive(Deserialize)] struct GuildInfo { @@ -63,4 +69,3 @@ pub async fn get( Ok(HttpResponse::Ok().json(guilds)) } - diff --git a/src/api/v1/servers/uuid/channels/mod.rs b/src/api/v1/servers/uuid/channels/mod.rs index 4348422..0c515ef 100644 --- a/src/api/v1/servers/uuid/channels/mod.rs +++ b/src/api/v1/servers/uuid/channels/mod.rs @@ -1,7 +1,7 @@ use crate::{ - error::Error, Data, api::v1::auth::check_access_token, + error::Error, structs::{Channel, Member}, utils::get_auth_header, }; @@ -43,8 +43,7 @@ pub async fn get( let channels = Channel::fetch_all(&data.pool, guild_uuid).await?; - data - .set_cache_key(format!("{}_channels", guild_uuid), channels.clone(), 1800) + data.set_cache_key(format!("{}_channels", guild_uuid), channels.clone(), 1800) .await?; Ok(HttpResponse::Ok().json(channels)) diff --git a/src/api/v1/servers/uuid/channels/uuid/messages.rs b/src/api/v1/servers/uuid/channels/uuid/messages.rs index 954651e..66ec80d 100644 --- a/src/api/v1/servers/uuid/channels/uuid/messages.rs +++ b/src/api/v1/servers/uuid/channels/uuid/messages.rs @@ -1,7 +1,7 @@ use crate::{ - error::Error, Data, api::v1::auth::check_access_token, + error::Error, structs::{Channel, Member}, utils::get_auth_header, }; @@ -41,8 +41,7 @@ pub async fn get( } else { channel = Channel::fetch_one(&mut conn, channel_uuid).await?; - data - .set_cache_key(format!("{}", channel_uuid), channel.clone(), 60) + data.set_cache_key(format!("{}", channel_uuid), channel.clone(), 60) .await?; } diff --git a/src/api/v1/servers/uuid/channels/uuid/mod.rs b/src/api/v1/servers/uuid/channels/uuid/mod.rs index 4cf6013..54f90a7 100644 --- a/src/api/v1/servers/uuid/channels/uuid/mod.rs +++ b/src/api/v1/servers/uuid/channels/uuid/mod.rs @@ -2,14 +2,14 @@ pub mod messages; pub mod socket; use crate::{ - error::Error, Data, api::v1::auth::check_access_token, + error::Error, structs::{Channel, Member}, utils::get_auth_header, }; -use uuid::Uuid; use actix_web::{HttpRequest, HttpResponse, delete, get, web}; +use uuid::Uuid; #[get("{uuid}/channels/{channel_uuid}")] pub async fn get( @@ -37,8 +37,7 @@ pub async fn get( let channel = Channel::fetch_one(&mut conn, channel_uuid).await?; - data - .set_cache_key(format!("{}", channel_uuid), channel.clone(), 60) + data.set_cache_key(format!("{}", channel_uuid), channel.clone(), 60) .await?; Ok(HttpResponse::Ok().json(channel)) diff --git a/src/api/v1/servers/uuid/channels/uuid/socket.rs b/src/api/v1/servers/uuid/channels/uuid/socket.rs index 14cb7d9..8938842 100644 --- a/src/api/v1/servers/uuid/channels/uuid/socket.rs +++ b/src/api/v1/servers/uuid/channels/uuid/socket.rs @@ -26,7 +26,7 @@ pub async fn echo( // Get uuids from path let (guild_uuid, channel_uuid) = path.into_inner(); - let mut conn = data.pool.get().await.map_err(|e| crate::error::Error::from(e))?; + let mut conn = data.pool.get().await.map_err(crate::error::Error::from)?; // Authorize client using auth header let uuid = check_access_token(auth_header, &mut conn).await?; @@ -42,8 +42,7 @@ pub async fn echo( } else { channel = Channel::fetch_one(&mut conn, channel_uuid).await?; - data - .set_cache_key(format!("{}", channel_uuid), channel.clone(), 60) + data.set_cache_key(format!("{}", channel_uuid), channel.clone(), 60) .await?; } @@ -54,7 +53,11 @@ pub async fn echo( // aggregate continuation frames up to 1MiB .max_continuation_size(2_usize.pow(20)); - let mut pubsub = data.cache_pool.get_async_pubsub().await.map_err(|e| crate::error::Error::from(e))?; + let mut pubsub = data + .cache_pool + .get_async_pubsub() + .await + .map_err(crate::error::Error::from)?; let mut session_2 = session_1.clone(); diff --git a/src/api/v1/servers/uuid/icon.rs b/src/api/v1/servers/uuid/icon.rs index 297afa9..2155f55 100644 --- a/src/api/v1/servers/uuid/icon.rs +++ b/src/api/v1/servers/uuid/icon.rs @@ -1,8 +1,14 @@ -use actix_web::{put, web, HttpRequest, HttpResponse}; -use uuid::Uuid; +use actix_web::{HttpRequest, HttpResponse, put, web}; use futures_util::StreamExt as _; +use uuid::Uuid; -use crate::{error::Error, api::v1::auth::check_access_token, structs::{Guild, Member}, utils::get_auth_header, Data}; +use crate::{ + Data, + api::v1::auth::check_access_token, + error::Error, + structs::{Guild, Member}, + utils::get_auth_header, +}; #[put("{uuid}/icon")] pub async fn upload( @@ -30,7 +36,14 @@ pub async fn upload( bytes.extend_from_slice(&item?); } - guild.set_icon(&data.bunny_cdn, &mut conn, data.config.bunny.cdn_url.clone(), bytes).await?; + guild + .set_icon( + &data.bunny_cdn, + &mut conn, + data.config.bunny.cdn_url.clone(), + bytes, + ) + .await?; Ok(HttpResponse::Ok().finish()) } diff --git a/src/api/v1/servers/uuid/invites/mod.rs b/src/api/v1/servers/uuid/invites/mod.rs index badb3e0..13ad378 100644 --- a/src/api/v1/servers/uuid/invites/mod.rs +++ b/src/api/v1/servers/uuid/invites/mod.rs @@ -3,9 +3,9 @@ use serde::Deserialize; use uuid::Uuid; use crate::{ - error::Error, Data, api::v1::auth::check_access_token, + error::Error, structs::{Guild, Member}, utils::get_auth_header, }; diff --git a/src/api/v1/servers/uuid/mod.rs b/src/api/v1/servers/uuid/mod.rs index 47eff03..887fd06 100644 --- a/src/api/v1/servers/uuid/mod.rs +++ b/src/api/v1/servers/uuid/mod.rs @@ -2,14 +2,14 @@ use actix_web::{HttpRequest, HttpResponse, Scope, get, web}; use uuid::Uuid; mod channels; +mod icon; mod invites; mod roles; -mod icon; use crate::{ - error::Error, Data, api::v1::auth::check_access_token, + error::Error, structs::{Guild, Member}, utils::get_auth_header, }; diff --git a/src/api/v1/servers/uuid/roles/mod.rs b/src/api/v1/servers/uuid/roles/mod.rs index a2912f9..fe25d39 100644 --- a/src/api/v1/servers/uuid/roles/mod.rs +++ b/src/api/v1/servers/uuid/roles/mod.rs @@ -3,9 +3,9 @@ use actix_web::{HttpRequest, HttpResponse, get, post, web}; use serde::Deserialize; use crate::{ - error::Error, Data, api::v1::auth::check_access_token, + error::Error, structs::{Member, Role}, utils::get_auth_header, }; @@ -43,8 +43,7 @@ pub async fn get( let roles = Role::fetch_all(&mut conn, guild_uuid).await?; - data - .set_cache_key(format!("{}_roles", guild_uuid), roles.clone(), 1800) + data.set_cache_key(format!("{}_roles", guild_uuid), roles.clone(), 1800) .await?; Ok(HttpResponse::Ok().json(roles)) diff --git a/src/api/v1/servers/uuid/roles/uuid.rs b/src/api/v1/servers/uuid/roles/uuid.rs index 3279d16..8ca3cc5 100644 --- a/src/api/v1/servers/uuid/roles/uuid.rs +++ b/src/api/v1/servers/uuid/roles/uuid.rs @@ -1,7 +1,7 @@ use crate::{ - error::Error, Data, api::v1::auth::check_access_token, + error::Error, structs::{Member, Role}, utils::get_auth_header, }; @@ -34,8 +34,7 @@ pub async fn get( let role = Role::fetch_one(&mut conn, role_uuid).await?; - data - .set_cache_key(format!("{}", role_uuid), role.clone(), 60) + data.set_cache_key(format!("{}", role_uuid), role.clone(), 60) .await?; Ok(HttpResponse::Ok().json(role)) diff --git a/src/api/v1/stats.rs b/src/api/v1/stats.rs index 6ab8d64..0b9567e 100644 --- a/src/api/v1/stats.rs +++ b/src/api/v1/stats.rs @@ -5,8 +5,8 @@ use diesel::QueryDsl; use diesel_async::RunQueryDsl; use serde::Serialize; -use crate::error::Error; use crate::Data; +use crate::error::Error; use crate::schema::users::dsl::{users, uuid}; const VERSION: Option<&'static str> = option_env!("CARGO_PKG_VERSION"); diff --git a/src/api/v1/users/me.rs b/src/api/v1/users/me.rs index 9647002..83d02db 100644 --- a/src/api/v1/users/me.rs +++ b/src/api/v1/users/me.rs @@ -1,8 +1,10 @@ -use actix_web::{get, patch, web, HttpRequest, HttpResponse}; -use actix_multipart::form::{json::Json as MpJson, tempfile::TempFile, MultipartForm}; +use actix_multipart::form::{MultipartForm, json::Json as MpJson, tempfile::TempFile}; +use actix_web::{HttpRequest, HttpResponse, get, patch, web}; use serde::Deserialize; -use crate::{error::Error, structs::Me, api::v1::auth::check_access_token, utils::get_auth_header, Data}; +use crate::{ + Data, api::v1::auth::check_access_token, error::Error, structs::Me, utils::get_auth_header, +}; #[get("/me")] pub async fn res(req: HttpRequest, data: web::Data) -> Result { @@ -35,7 +37,11 @@ struct UploadForm { } #[patch("/me")] -pub async fn update(req: HttpRequest, MultipartForm(form): MultipartForm, 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)?; @@ -51,7 +57,13 @@ pub async fn update(req: HttpRequest, MultipartForm(form): MultipartForm HttpResponse { debug!("{:?}", self); - error!("{}: {}", self.status_code(), self.to_string()); + error!("{}: {}", self.status_code(), self); HttpResponse::build(self.status_code()) .insert_header(ContentType::json()) @@ -79,8 +86,6 @@ struct WebError { impl WebError { fn new(message: String) -> Self { - Self { - message, - } + Self { message } } } diff --git a/src/main.rs b/src/main.rs index 8fa2121..1209051 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,24 +2,25 @@ use actix_cors::Cors; use actix_web::{App, HttpServer, web}; use argon2::Argon2; use clap::Parser; -use error::Error; -use simple_logger::SimpleLogger; use diesel_async::pooled_connection::AsyncDieselConnectionManager; use diesel_async::pooled_connection::deadpool::Pool; +use error::Error; +use simple_logger::SimpleLogger; use std::time::SystemTime; mod config; use config::{Config, ConfigBuilder}; -use diesel_migrations::{embed_migrations, EmbeddedMigrations, MigrationHarness}; +use diesel_migrations::{EmbeddedMigrations, MigrationHarness, embed_migrations}; pub const MIGRATIONS: EmbeddedMigrations = embed_migrations!(); -type Conn = deadpool::managed::Object>; +type Conn = + deadpool::managed::Object>; mod api; +pub mod error; +pub mod schema; pub mod structs; pub mod utils; -pub mod schema; -pub mod error; #[derive(Parser, Debug)] #[command(version, about, long_about = None)] @@ -30,7 +31,10 @@ struct Args { #[derive(Clone)] pub struct Data { - pub pool: deadpool::managed::Pool, Conn>, + pub pool: deadpool::managed::Pool< + AsyncDieselConnectionManager, + Conn, + >, pub cache_pool: redis::Client, pub config: Config, pub argon2: Argon2<'static>, @@ -53,27 +57,33 @@ async fn main() -> Result<(), Error> { let web = config.web.clone(); // create a new connection pool with the default config - let pool_config = AsyncDieselConnectionManager::::new(config.database.url()); + let pool_config = + AsyncDieselConnectionManager::::new(config.database.url()); let pool = Pool::builder(pool_config).build()?; 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())?; + bunny_cdn.storage.init( + config.bunny.endpoint.clone(), + config.bunny.storage_zone.clone(), + )?; let database_url = config.database.url(); tokio::task::spawn_blocking(move || { - use diesel::prelude::Connection; - use diesel_async::async_connection_wrapper::AsyncConnectionWrapper; + use diesel::prelude::Connection; + use diesel_async::async_connection_wrapper::AsyncConnectionWrapper; - - let mut conn = AsyncConnectionWrapper::::establish(&database_url)?; + let mut conn = + AsyncConnectionWrapper::::establish(&database_url)?; conn.run_pending_migrations(MIGRATIONS)?; Ok::<_, Box>(()) - }).await?.unwrap(); + }) + .await? + .unwrap(); /* **Stored for later possible use** diff --git a/src/structs.rs b/src/structs.rs index e593996..c86bc5b 100644 --- a/src/structs.rs +++ b/src/structs.rs @@ -1,14 +1,20 @@ -use diesel::{delete, insert_into, prelude::{Insertable, Queryable}, update, ExpressionMethods, QueryDsl, Selectable, SelectableHelper}; +use actix_web::web::BytesMut; +use diesel::{ + ExpressionMethods, QueryDsl, Selectable, SelectableHelper, delete, insert_into, + prelude::{Insertable, Queryable}, + update, +}; +use diesel_async::{RunQueryDsl, pooled_connection::AsyncDieselConnectionManager}; use serde::{Deserialize, Serialize}; -use uuid::Uuid; -use diesel_async::{pooled_connection::AsyncDieselConnectionManager, RunQueryDsl}; use tokio::task; use url::Url; -use actix_web::web::BytesMut; +use uuid::Uuid; -use crate::{error::Error, schema::*, utils::image_check, Conn, Data}; +use crate::{Conn, Data, error::Error, schema::*, utils::image_check}; -fn load_or_empty(query_result: Result, diesel::result::Error>) -> Result, diesel::result::Error> { +fn load_or_empty( + query_result: Result, diesel::result::Error>, +) -> Result, diesel::result::Error> { match query_result { Ok(vec) => Ok(vec), Err(diesel::result::Error::NotFound) => Ok(Vec::new()), @@ -34,7 +40,7 @@ impl ChannelBuilder { .filter(channel_uuid.eq(self.uuid)) .select(ChannelPermission::as_select()) .load(conn) - .await + .await, )?; Ok(Channel { @@ -66,7 +72,10 @@ pub struct ChannelPermission { impl Channel { pub async fn fetch_all( - pool: &deadpool::managed::Pool, Conn>, + pool: &deadpool::managed::Pool< + AsyncDieselConnectionManager, + Conn, + >, guild_uuid: Uuid, ) -> Result, Error> { let mut conn = pool.get().await?; @@ -77,21 +86,18 @@ impl Channel { .filter(dsl::guild_uuid.eq(guild_uuid)) .select(ChannelBuilder::as_select()) .load(&mut conn) - .await + .await, )?; let channel_futures = channel_builders.iter().map(async move |c| { let mut conn = pool.get().await?; c.clone().build(&mut conn).await }); - + futures::future::try_join_all(channel_futures).await } - pub async fn fetch_one( - conn: &mut Conn, - channel_uuid: Uuid, - ) -> Result { + pub async fn fetch_one(conn: &mut Conn, channel_uuid: Uuid) -> Result { use channels::dsl; let channel_builder: ChannelBuilder = dsl::channels .filter(dsl::uuid.eq(channel_uuid)) @@ -114,7 +120,7 @@ impl Channel { let new_channel = ChannelBuilder { uuid: channel_uuid, - guild_uuid: guild_uuid, + guild_uuid, name: name.clone(), description: description.clone(), }; @@ -133,11 +139,11 @@ impl Channel { permissions: vec![], }; - data - .set_cache_key(channel_uuid.to_string(), channel.clone(), 1800) + data.set_cache_key(channel_uuid.to_string(), channel.clone(), 1800) .await?; - data.del_cache_key(format!("{}_channels", guild_uuid)).await?; + data.del_cache_key(format!("{}_channels", guild_uuid)) + .await?; Ok(channel) } @@ -166,7 +172,7 @@ impl Channel { .limit(amount) .offset(offset) .load(conn) - .await + .await, )?; Ok(messages) @@ -257,8 +263,8 @@ impl GuildBuilder { description: self.description, icon: self.icon.and_then(|i| i.parse().ok()), owner_uuid: self.owner_uuid, - roles: roles, - member_count: member_count, + roles, + member_count, }) } } @@ -287,7 +293,10 @@ impl Guild { } pub async fn fetch_amount( - pool: &deadpool::managed::Pool, Conn>, + pool: &deadpool::managed::Pool< + AsyncDieselConnectionManager, + Conn, + >, offset: i64, amount: i64, ) -> Result, Error> { @@ -302,7 +311,7 @@ impl Guild { .offset(offset) .limit(amount) .load(&mut conn) - .await + .await, )?; // Process each guild concurrently @@ -368,7 +377,7 @@ impl Guild { .filter(dsl::guild_uuid.eq(self.uuid)) .select(Invite::as_select()) .load(conn) - .await + .await, )?; Ok(invites) @@ -385,7 +394,7 @@ impl Guild { if let Some(id) = custom_id { invite_id = id; if invite_id.len() > 32 { - return Err(Error::BadRequest("MAX LENGTH".to_string())) + return Err(Error::BadRequest("MAX LENGTH".to_string())); } } else { let charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; @@ -408,14 +417,18 @@ 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> { + pub async fn set_icon( + &mut self, + bunny_cdn: &bunny_api_tokio::Client, + conn: &mut Conn, + cdn_url: Url, + icon: BytesMut, + ) -> Result<(), Error> { 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 - .path() - .trim_start_matches('/'); + let relative_url = icon.path().trim_start_matches('/'); bunny_cdn.storage.delete(relative_url).await?; } @@ -452,26 +465,20 @@ pub struct Role { } impl Role { - pub async fn fetch_all( - conn: &mut Conn, - guild_uuid: Uuid, - ) -> Result, Error> { + pub async fn fetch_all(conn: &mut Conn, guild_uuid: Uuid) -> Result, Error> { use roles::dsl; let roles: Vec = load_or_empty( dsl::roles .filter(dsl::guild_uuid.eq(guild_uuid)) .select(Role::as_select()) .load(conn) - .await + .await, )?; Ok(roles) } - pub async fn fetch_one( - conn: &mut Conn, - role_uuid: Uuid, - ) -> Result { + pub async fn fetch_one(conn: &mut Conn, role_uuid: Uuid) -> Result { use roles::dsl; let role: Role = dsl::roles .filter(dsl::uuid.eq(role_uuid)) @@ -482,11 +489,7 @@ impl Role { Ok(role) } - pub async fn new( - conn: &mut Conn, - guild_uuid: Uuid, - name: String, - ) -> Result { + pub async fn new(conn: &mut Conn, guild_uuid: Uuid, name: String) -> Result { let role_uuid = Uuid::now_v7(); let role = Role { @@ -534,22 +537,18 @@ impl Member { user_uuid: Uuid, guild_uuid: Uuid, ) -> Result { - use guild_members::dsl; - let member: Member = dsl::guild_members - .filter(dsl::user_uuid.eq(user_uuid)) - .filter(dsl::guild_uuid.eq(guild_uuid)) - .select(Member::as_select()) - .get_result(conn) - .await?; + use guild_members::dsl; + let member: Member = dsl::guild_members + .filter(dsl::user_uuid.eq(user_uuid)) + .filter(dsl::guild_uuid.eq(guild_uuid)) + .select(Member::as_select()) + .get_result(conn) + .await?; Ok(member) } - pub async fn new( - conn: &mut Conn, - user_uuid: Uuid, - guild_uuid: Uuid, - ) -> Result { + pub async fn new(conn: &mut Conn, user_uuid: Uuid, guild_uuid: Uuid) -> Result { let member_uuid = Uuid::now_v7(); let member = Member { @@ -629,7 +628,11 @@ impl User { Ok(user) } - pub async fn fetch_amount(conn: &mut Conn, offset: i64, amount: i64) -> Result, Error> { + pub async fn fetch_amount( + conn: &mut Conn, + offset: i64, + amount: i64, + ) -> Result, Error> { use users::dsl; let users: Vec = load_or_empty( dsl::users @@ -637,7 +640,7 @@ impl User { .offset(offset) .select(User::as_select()) .load(conn) - .await + .await, )?; Ok(users) @@ -668,23 +671,30 @@ 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> { + 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('/'); + 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?; + bunny_cdn + .storage + .upload(path.clone(), avatar.into()) + .await?; let avatar_url = cdn_url.join(&path)?; diff --git a/src/utils.rs b/src/utils.rs index 4e9d435..f9a5705 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,6 +1,7 @@ use actix_web::{ - cookie::{time::Duration, Cookie, SameSite}, - http::header::HeaderMap, web::BytesMut, + cookie::{Cookie, SameSite, time::Duration}, + http::header::HeaderMap, + web::BytesMut, }; use bindet::FileType; use getrandom::fill; @@ -8,29 +9,35 @@ use hex::encode; use redis::RedisError; use serde::Serialize; -use crate::{error::Error, Data}; +use crate::{Data, error::Error}; pub fn get_auth_header(headers: &HeaderMap) -> Result<&str, Error> { let auth_token = headers.get(actix_web::http::header::AUTHORIZATION); if auth_token.is_none() { - return Err(Error::Unauthorized("No authorization header provided".to_string())); + return Err(Error::Unauthorized( + "No authorization header provided".to_string(), + )); } let auth_raw = auth_token.unwrap().to_str()?; let mut auth = auth_raw.split_whitespace(); - let auth_type = auth.nth(0); + let auth_type = auth.next(); - let auth_value = auth.nth(0); + let auth_value = auth.next(); if auth_type.is_none() { - return Err(Error::BadRequest("Authorization header is empty".to_string())); + return Err(Error::BadRequest( + "Authorization header is empty".to_string(), + )); } else if auth_type.is_some_and(|at| at != "Bearer") { - return Err(Error::BadRequest("Only token auth is supported".to_string())); + return Err(Error::BadRequest( + "Only token auth is supported".to_string(), + )); } - + if auth_value.is_none() { return Err(Error::BadRequest("No token provided".to_string())); } @@ -67,13 +74,15 @@ pub fn image_check(icon: BytesMut) -> Result { if let Ok(Some(file_type)) = detect { if file_type.likely_to_be == vec![FileType::Jpg] { - return Ok(String::from("jpg")) + return Ok(String::from("jpg")); } else if file_type.likely_to_be == vec![FileType::Png] { - return Ok(String::from("png")) + return Ok(String::from("png")); } } - Err(Error::BadRequest("Uploaded file is not an image".to_string())) + Err(Error::BadRequest( + "Uploaded file is not an image".to_string(), + )) } impl Data { From b5b68c71ba86df5f76ef94b10c2d7f55f171a9d6 Mon Sep 17 00:00:00 2001 From: Radical Date: Sat, 24 May 2025 01:29:20 +0200 Subject: [PATCH 27/92] fix: return not found when CDN returns not found --- src/error.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/error.rs b/src/error.rs index a2f316d..ce586ac 100644 --- a/src/error.rs +++ b/src/error.rs @@ -8,6 +8,7 @@ use actix_web::{ header::{ContentType, ToStrError}, }, }; +use bunny_api_tokio::error::Error as BunnyError; use deadpool::managed::{BuildError, PoolError}; use diesel::{ConnectionError, result::Error as DieselError}; use diesel_async::pooled_connection::PoolError as DieselPoolError; @@ -46,7 +47,7 @@ pub enum Error { #[error(transparent)] RandomError(#[from] getrandom::Error), #[error(transparent)] - BunnyError(#[from] bunny_api_tokio::error::Error), + BunnyError(#[from] BunnyError), #[error(transparent)] UrlParseError(#[from] url::ParseError), #[error(transparent)] @@ -72,6 +73,7 @@ impl ResponseError for Error { fn status_code(&self) -> StatusCode { match *self { Error::SqlError(DieselError::NotFound) => StatusCode::NOT_FOUND, + Error::BunnyError(BunnyError::NotFound(_)) => StatusCode::NOT_FOUND, Error::BadRequest(_) => StatusCode::BAD_REQUEST, Error::Unauthorized(_) => StatusCode::UNAUTHORIZED, _ => StatusCode::INTERNAL_SERVER_ERROR, From 6fe116396938d52e00521238d693e1c80026df60 Mon Sep 17 00:00:00 2001 From: Radical Date: Sat, 24 May 2025 03:09:31 +0200 Subject: [PATCH 28/92] build: update bunny-api-tokio dependency --- Cargo.toml | 2 +- src/main.rs | 12 +++++++----- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 568466d..7a062d8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,7 +29,7 @@ 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" +bunny-api-tokio = "0.3.0" bindet = "0.3.2" deadpool = "0.12" diesel = { version = "2.2", features = ["uuid"] } diff --git a/src/main.rs b/src/main.rs index 1209051..5ad1dc8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -63,12 +63,14 @@ 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?; + let mut bunny_cdn = bunny_api_tokio::Client::new("").await?; - bunny_cdn.storage.init( - config.bunny.endpoint.clone(), - config.bunny.storage_zone.clone(), - )?; + let bunny = config.bunny.clone(); + + bunny_cdn + .storage + .init(bunny.api_key, bunny.endpoint, bunny.storage_zone) + .await?; let database_url = config.database.url(); From 6c47d22ae6a6810dd539cf798fdf3e4057c3b39a Mon Sep 17 00:00:00 2001 From: Radical Date: Sun, 25 May 2025 18:40:13 +0200 Subject: [PATCH 29/92] fix: add bunny config to docker --- Dockerfile | 6 +++++- compose.dev.yml | 4 ++++ compose.yml | 4 ++++ entrypoint.sh | 7 +++++++ 4 files changed, 20 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index d9a0389..9719b70 100644 --- a/Dockerfile +++ b/Dockerfile @@ -24,6 +24,10 @@ DATABASE="gorb" \ DATABASE_HOST="database" \ DATABASE_PORT="5432" \ CACHE_DB_HOST="valkey" \ -CACHE_DB_PORT="6379" +CACHE_DB_PORT="6379" \ +BUNNY_API_KEY="your_storage_zone_password_here" \ +BUNNY_ENDPOINT="Frankfurt" \ +BUNNY_ZONE="gorb" \ +BUNNY_CDN_URL="https://cdn.gorb.app" ENTRYPOINT ["/usr/bin/entrypoint.sh"] diff --git a/compose.dev.yml b/compose.dev.yml index d064beb..24b3997 100644 --- a/compose.dev.yml +++ b/compose.dev.yml @@ -23,6 +23,10 @@ services: - DATABASE=gorb - DATABASE_HOST=database - DATABASE_PORT=5432 + - BUNNY_API_KEY="your_storage_zone_password_here" + - BUNNY_ENDPOINT="Frankfurt" + - BUNNY_ZONE="gorb" + - BUNNY_CDN_URL="https://cdn.gorb.app" database: image: postgres:16 restart: always diff --git a/compose.yml b/compose.yml index 84e6695..4f0f543 100644 --- a/compose.yml +++ b/compose.yml @@ -21,6 +21,10 @@ services: - DATABASE=gorb - DATABASE_HOST=database - DATABASE_PORT=5432 + - BUNNY_API_KEY="your_storage_zone_password_here" + - BUNNY_ENDPOINT="Frankfurt" + - BUNNY_ZONE="gorb" + - BUNNY_CDN_URL="https://cdn.gorb.app" database: image: postgres:16 restart: always diff --git a/entrypoint.sh b/entrypoint.sh index a212f8e..8bd8a44 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -20,6 +20,13 @@ port = ${DATABASE_PORT} [cache_database] host = "${CACHE_DB_HOST}" port = ${CACHE_DB_PORT} + +[bunny] +api_key = "${BUNNY_API_KEY}" +endpoint = "${BUNNY_ENDPOINT}" +storage_zone = "${BUNNY_ZONE}" +cdn_url = "${BUNNY_CDN_URL}" + EOF fi From 6640d03b70fc9be85aae5001257cd9dacae8f6a8 Mon Sep 17 00:00:00 2001 From: Radical Date: Sun, 25 May 2025 19:20:02 +0200 Subject: [PATCH 30/92] fix: make container work properly Tested-by: Radical --- Dockerfile | 22 +++++++++++----------- compose.dev.yml | 8 ++++---- compose.yml | 8 ++++---- entrypoint.sh | 3 +++ run-dev.sh | 4 ++-- 5 files changed, 24 insertions(+), 21 deletions(-) diff --git a/Dockerfile b/Dockerfile index 9719b70..0f07fcb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,16 +18,16 @@ RUN useradd --create-home --home-dir /gorb gorb USER gorb -ENV DATABASE_USERNAME="gorb" \ -DATABASE_PASSWORD="gorb" \ -DATABASE="gorb" \ -DATABASE_HOST="database" \ -DATABASE_PORT="5432" \ -CACHE_DB_HOST="valkey" \ -CACHE_DB_PORT="6379" \ -BUNNY_API_KEY="your_storage_zone_password_here" \ -BUNNY_ENDPOINT="Frankfurt" \ -BUNNY_ZONE="gorb" \ -BUNNY_CDN_URL="https://cdn.gorb.app" +ENV DATABASE_USERNAME=gorb \ +DATABASE_PASSWORD=gorb \ +DATABASE=gorb \ +DATABASE_HOST=database \ +DATABASE_PORT=5432 \ +CACHE_DB_HOST=valkey \ +CACHE_DB_PORT=6379 \ +BUNNY_API_KEY=your_storage_zone_password_here \ +BUNNY_ENDPOINT=Frankfurt \ +BUNNY_ZONE=gorb \ +BUNNY_CDN_URL=https://cdn.gorb.app ENTRYPOINT ["/usr/bin/entrypoint.sh"] diff --git a/compose.dev.yml b/compose.dev.yml index 24b3997..3da7c89 100644 --- a/compose.dev.yml +++ b/compose.dev.yml @@ -23,10 +23,10 @@ services: - DATABASE=gorb - DATABASE_HOST=database - DATABASE_PORT=5432 - - BUNNY_API_KEY="your_storage_zone_password_here" - - BUNNY_ENDPOINT="Frankfurt" - - BUNNY_ZONE="gorb" - - BUNNY_CDN_URL="https://cdn.gorb.app" + - BUNNY_API_KEY=your_storage_zone_password_here + - BUNNY_ENDPOINT=Frankfurt + - BUNNY_ZONE=gorb + - BUNNY_CDN_URL=https://cdn.gorb.app database: image: postgres:16 restart: always diff --git a/compose.yml b/compose.yml index 4f0f543..f87411a 100644 --- a/compose.yml +++ b/compose.yml @@ -21,10 +21,10 @@ services: - DATABASE=gorb - DATABASE_HOST=database - DATABASE_PORT=5432 - - BUNNY_API_KEY="your_storage_zone_password_here" - - BUNNY_ENDPOINT="Frankfurt" - - BUNNY_ZONE="gorb" - - BUNNY_CDN_URL="https://cdn.gorb.app" + - BUNNY_API_KEY=your_storage_zone_password_here + - BUNNY_ENDPOINT=Frankfurt + - BUNNY_ZONE=gorb + - BUNNY_CDN_URL=https://cdn.gorb.app database: image: postgres:16 restart: always diff --git a/entrypoint.sh b/entrypoint.sh index 8bd8a44..a29e6bb 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -49,4 +49,7 @@ rotate_log() { rotate_log "/gorb/logs/backend.log" +# Give the DB time to start up before connecting +sleep 5 + /usr/bin/gorb-backend --config /gorb/config/config.toml 2>&1 | tee /gorb/logs/backend.log diff --git a/run-dev.sh b/run-dev.sh index 69067b8..242ca72 100755 --- a/run-dev.sh +++ b/run-dev.sh @@ -3,7 +3,7 @@ podman-compose --file compose.dev.yml up --build echo "SHUTTING DOWN CONTAINERS" -podman container stop backend_backend_1 backend_database_1 +podman container stop backend_backend_1 backend_database_1 backend_valkey_1 echo "DELETING CONTAINERS" -podman container rm backend_backend_1 backend_database_1 +podman container rm backend_backend_1 backend_database_1 backend_valkey_1 From 5d26f94cdd7fea5fbe336637be635eb986d312cb Mon Sep 17 00:00:00 2001 From: Radical Date: Mon, 26 May 2025 19:17:36 +0200 Subject: [PATCH 31/92] style: use ? operator instead of unwrap in websockets --- .../v1/servers/uuid/channels/uuid/socket.rs | 32 ++++++++++--------- src/error.rs | 2 ++ 2 files changed, 19 insertions(+), 15 deletions(-) diff --git a/src/api/v1/servers/uuid/channels/uuid/socket.rs b/src/api/v1/servers/uuid/channels/uuid/socket.rs index 8938842..3300e6c 100644 --- a/src/api/v1/servers/uuid/channels/uuid/socket.rs +++ b/src/api/v1/servers/uuid/channels/uuid/socket.rs @@ -62,49 +62,51 @@ pub async fn echo( let mut session_2 = session_1.clone(); rt::spawn(async move { - pubsub.subscribe(channel_uuid.to_string()).await.unwrap(); + pubsub.subscribe(channel_uuid.to_string()).await?; while let Some(msg) = pubsub.on_message().next().await { - let payload: String = msg.get_payload().unwrap(); - session_1.text(payload).await.unwrap(); + let payload: String = msg.get_payload()?; + session_1.text(payload).await?; } + + Ok::<(), crate::error::Error>(()) }); // start task but don't wait for it rt::spawn(async move { - let mut conn = data - .cache_pool - .get_multiplexed_tokio_connection() - .await - .unwrap(); // receive messages from websocket while let Some(msg) = stream.next().await { match msg { Ok(AggregatedMessage::Text(text)) => { - // echo text message + let mut conn = data + .cache_pool + .get_multiplexed_tokio_connection() + .await?; + redis::cmd("PUBLISH") .arg(&[channel_uuid.to_string(), text.to_string()]) .exec_async(&mut conn) - .await - .unwrap(); + .await?; + channel .new_message(&mut data.pool.get().await.unwrap(), uuid, text.to_string()) - .await - .unwrap(); + .await?; } Ok(AggregatedMessage::Binary(bin)) => { // echo binary message - session_2.binary(bin).await.unwrap(); + session_2.binary(bin).await?; } Ok(AggregatedMessage::Ping(msg)) => { // respond to PING frame with PONG frame - session_2.pong(&msg).await.unwrap(); + session_2.pong(&msg).await?; } _ => {} } } + + Ok::<(), crate::error::Error>(()) }); // respond immediately with response connected to WS session diff --git a/src/error.rs b/src/error.rs index ce586ac..fa9524f 100644 --- a/src/error.rs +++ b/src/error.rs @@ -52,6 +52,8 @@ pub enum Error { UrlParseError(#[from] url::ParseError), #[error(transparent)] PayloadError(#[from] PayloadError), + #[error(transparent)] + WsClosed(#[from] actix_ws::Closed), #[error("{0}")] PasswordHashError(String), #[error("{0}")] From efa0cd555f41858dbd35470860754cedad517625 Mon Sep 17 00:00:00 2001 From: Radical Date: Mon, 26 May 2025 19:41:32 +0200 Subject: [PATCH 32/92] fix: hack around websocket spec to make tokens work --- .../v1/servers/uuid/channels/uuid/socket.rs | 24 ++++++++----- src/utils.rs | 34 +++++++++++++++++++ 2 files changed, 50 insertions(+), 8 deletions(-) diff --git a/src/api/v1/servers/uuid/channels/uuid/socket.rs b/src/api/v1/servers/uuid/channels/uuid/socket.rs index 3300e6c..c16efa7 100644 --- a/src/api/v1/servers/uuid/channels/uuid/socket.rs +++ b/src/api/v1/servers/uuid/channels/uuid/socket.rs @@ -1,4 +1,8 @@ -use actix_web::{Error, HttpRequest, HttpResponse, get, rt, web}; +use actix_web::{ + Error, HttpRequest, HttpResponse, get, + http::header::{HeaderValue, SEC_WEBSOCKET_PROTOCOL}, + rt, web, +}; use actix_ws::AggregatedMessage; use futures_util::StreamExt as _; use uuid::Uuid; @@ -7,7 +11,7 @@ use crate::{ Data, api::v1::auth::check_access_token, structs::{Channel, Member}, - utils::get_auth_header, + utils::get_ws_protocol_header, }; #[get("{uuid}/channels/{channel_uuid}/socket")] @@ -21,7 +25,7 @@ pub async fn echo( let headers = req.headers(); // Retrieve auth header - let auth_header = get_auth_header(headers)?; + let auth_header = get_ws_protocol_header(headers)?; // Get uuids from path let (guild_uuid, channel_uuid) = path.into_inner(); @@ -46,7 +50,7 @@ pub async fn echo( .await?; } - let (res, mut session_1, stream) = actix_ws::handle(&req, stream)?; + let (mut res, mut session_1, stream) = actix_ws::handle(&req, stream)?; let mut stream = stream .aggregate_continuations() @@ -77,10 +81,7 @@ pub async fn echo( while let Some(msg) = stream.next().await { match msg { Ok(AggregatedMessage::Text(text)) => { - let mut conn = data - .cache_pool - .get_multiplexed_tokio_connection() - .await?; + let mut conn = data.cache_pool.get_multiplexed_tokio_connection().await?; redis::cmd("PUBLISH") .arg(&[channel_uuid.to_string(), text.to_string()]) @@ -109,6 +110,13 @@ pub async fn echo( Ok::<(), crate::error::Error>(()) }); + let headers = res.headers_mut(); + + headers.append( + SEC_WEBSOCKET_PROTOCOL, + HeaderValue::from_str("Authorization")?, + ); + // respond immediately with response connected to WS session Ok(res) } diff --git a/src/utils.rs b/src/utils.rs index f9a5705..631b003 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -45,6 +45,40 @@ pub fn get_auth_header(headers: &HeaderMap) -> Result<&str, Error> { Ok(auth_value.unwrap()) } +pub fn get_ws_protocol_header(headers: &HeaderMap) -> Result<&str, Error> { + let auth_token = headers.get(actix_web::http::header::SEC_WEBSOCKET_PROTOCOL); + + if auth_token.is_none() { + return Err(Error::Unauthorized( + "No authorization header provided".to_string(), + )); + } + + let auth_raw = auth_token.unwrap().to_str()?; + + let mut auth = auth_raw.split_whitespace(); + + let response_proto = auth.next(); + + let auth_value = auth.next(); + + if response_proto.is_none() { + return Err(Error::BadRequest( + "Sec-WebSocket-Protocol header is empty".to_string(), + )); + } else if response_proto.is_some_and(|rp| rp != "Authorization,") { + return Err(Error::BadRequest( + "First protocol should be Authorization".to_string(), + )); + } + + if auth_value.is_none() { + return Err(Error::BadRequest("No token provided".to_string())); + } + + Ok(auth_value.unwrap()) +} + pub fn refresh_token_cookie(refresh_token: String) -> Cookie<'static> { Cookie::build("refresh_token", refresh_token) .http_only(true) From bcb82d0f4609c2333a0dea608d25a3438883855b Mon Sep 17 00:00:00 2001 From: Radical Date: Mon, 26 May 2025 21:32:43 +0200 Subject: [PATCH 33/92] fix: return message struct to websocket connection --- src/api/v1/servers/uuid/channels/uuid/socket.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/api/v1/servers/uuid/channels/uuid/socket.rs b/src/api/v1/servers/uuid/channels/uuid/socket.rs index c16efa7..744f017 100644 --- a/src/api/v1/servers/uuid/channels/uuid/socket.rs +++ b/src/api/v1/servers/uuid/channels/uuid/socket.rs @@ -83,13 +83,13 @@ pub async fn echo( Ok(AggregatedMessage::Text(text)) => { let mut conn = data.cache_pool.get_multiplexed_tokio_connection().await?; - redis::cmd("PUBLISH") - .arg(&[channel_uuid.to_string(), text.to_string()]) - .exec_async(&mut conn) + let message = channel + .new_message(&mut data.pool.get().await?, uuid, text.to_string()) .await?; - channel - .new_message(&mut data.pool.get().await.unwrap(), uuid, text.to_string()) + redis::cmd("PUBLISH") + .arg(&[channel_uuid.to_string(), serde_json::to_string(&message)?]) + .exec_async(&mut conn) .await?; } From d8541b2eea923f67070700ae05e14b54eaa4ad4d Mon Sep 17 00:00:00 2001 From: Radical Date: Mon, 26 May 2025 22:26:16 +0200 Subject: [PATCH 34/92] feat: add channel ordering --- .../down.sql | 2 + .../up.sql | 2 + src/api/v1/servers/uuid/channels/mod.rs | 16 ++++--- src/error.rs | 2 + src/schema.rs | 1 + src/structs.rs | 42 ++++++++++++++++--- src/utils.rs | 24 ++++++++++- 7 files changed, 74 insertions(+), 15 deletions(-) create mode 100644 migrations/2025-05-26-181536_add_channel_ordering/down.sql create mode 100644 migrations/2025-05-26-181536_add_channel_ordering/up.sql diff --git a/migrations/2025-05-26-181536_add_channel_ordering/down.sql b/migrations/2025-05-26-181536_add_channel_ordering/down.sql new file mode 100644 index 0000000..0a70d35 --- /dev/null +++ b/migrations/2025-05-26-181536_add_channel_ordering/down.sql @@ -0,0 +1,2 @@ +-- This file should undo anything in `up.sql` +ALTER TABLE channels DROP COLUMN is_above; diff --git a/migrations/2025-05-26-181536_add_channel_ordering/up.sql b/migrations/2025-05-26-181536_add_channel_ordering/up.sql new file mode 100644 index 0000000..e18e5e2 --- /dev/null +++ b/migrations/2025-05-26-181536_add_channel_ordering/up.sql @@ -0,0 +1,2 @@ +-- Your SQL goes here +ALTER TABLE channels ADD COLUMN is_above UUID UNIQUE REFERENCES channels(uuid) DEFAULT NULL; diff --git a/src/api/v1/servers/uuid/channels/mod.rs b/src/api/v1/servers/uuid/channels/mod.rs index 0c515ef..3021ded 100644 --- a/src/api/v1/servers/uuid/channels/mod.rs +++ b/src/api/v1/servers/uuid/channels/mod.rs @@ -1,9 +1,5 @@ use crate::{ - Data, - api::v1::auth::check_access_token, - error::Error, - structs::{Channel, Member}, - utils::get_auth_header, + api::v1::auth::check_access_token, error::Error, structs::{Channel, Member}, utils::{get_auth_header, order_channels}, Data }; use ::uuid::Uuid; use actix_web::{HttpRequest, HttpResponse, get, post, web}; @@ -43,10 +39,12 @@ pub async fn get( let channels = Channel::fetch_all(&data.pool, guild_uuid).await?; - data.set_cache_key(format!("{}_channels", guild_uuid), channels.clone(), 1800) + let channels_ordered = order_channels(channels).await?; + + data.set_cache_key(format!("{}_channels", guild_uuid), channels_ordered.clone(), 1800) .await?; - Ok(HttpResponse::Ok().json(channels)) + Ok(HttpResponse::Ok().json(channels_ordered)) } #[post("{uuid}/channels")] @@ -76,7 +74,7 @@ pub async fn create( channel_info.name.clone(), channel_info.description.clone(), ) - .await; + .await?; - Ok(HttpResponse::Ok().json(channel.unwrap())) + Ok(HttpResponse::Ok().json(channel)) } diff --git a/src/error.rs b/src/error.rs index fa9524f..3907f7c 100644 --- a/src/error.rs +++ b/src/error.rs @@ -60,6 +60,8 @@ pub enum Error { BadRequest(String), #[error("{0}")] Unauthorized(String), + #[error("{0}")] + InternalServerError(String), } impl ResponseError for Error { diff --git a/src/schema.rs b/src/schema.rs index 33935f7..8a85a2e 100644 --- a/src/schema.rs +++ b/src/schema.rs @@ -27,6 +27,7 @@ diesel::table! { name -> Varchar, #[max_length = 500] description -> Nullable, + is_above -> Nullable, } } diff --git a/src/structs.rs b/src/structs.rs index c86bc5b..b423b20 100644 --- a/src/structs.rs +++ b/src/structs.rs @@ -5,12 +5,18 @@ use diesel::{ update, }; use diesel_async::{RunQueryDsl, pooled_connection::AsyncDieselConnectionManager}; +use log::debug; use serde::{Deserialize, Serialize}; use tokio::task; use url::Url; use uuid::Uuid; -use crate::{Conn, Data, error::Error, schema::*, utils::image_check}; +use crate::{ + Conn, Data, + error::Error, + schema::*, + utils::{image_check, order_channels}, +}; fn load_or_empty( query_result: Result, diesel::result::Error>, @@ -22,7 +28,7 @@ fn load_or_empty( } } -#[derive(Queryable, Selectable, Insertable, Clone)] +#[derive(Queryable, Selectable, Insertable, Clone, Debug)] #[diesel(table_name = channels)] #[diesel(check_for_backend(diesel::pg::Pg))] struct ChannelBuilder { @@ -30,6 +36,7 @@ struct ChannelBuilder { guild_uuid: Uuid, name: String, description: Option, + is_above: Option, } impl ChannelBuilder { @@ -48,21 +55,23 @@ impl ChannelBuilder { guild_uuid: self.guild_uuid, name: self.name, description: self.description, + is_above: self.is_above, permissions: channel_permission, }) } } -#[derive(Serialize, Deserialize, Clone)] +#[derive(Serialize, Deserialize, Clone, Debug)] pub struct Channel { pub uuid: Uuid, pub guild_uuid: Uuid, name: String, description: Option, + pub is_above: Option, pub permissions: Vec, } -#[derive(Serialize, Deserialize, Clone, Queryable, Selectable)] +#[derive(Serialize, Deserialize, Clone, Queryable, Selectable, Debug)] #[diesel(table_name = channel_permissions)] #[diesel(check_for_backend(diesel::pg::Pg))] pub struct ChannelPermission { @@ -118,24 +127,47 @@ impl Channel { let channel_uuid = Uuid::now_v7(); + let channels = Self::fetch_all(&data.pool, guild_uuid).await?; + + debug!("{:?}", channels); + + let channels_ordered = order_channels(channels).await?; + + debug!("{:?}", channels_ordered); + + let last_channel = channels_ordered.last(); + let new_channel = ChannelBuilder { uuid: channel_uuid, guild_uuid, name: name.clone(), description: description.clone(), + is_above: None, }; + debug!("New Channel: {:?}", new_channel); + insert_into(channels::table) - .values(new_channel) + .values(new_channel.clone()) .execute(&mut conn) .await?; + if let Some(old_last_channel) = last_channel { + use channels::dsl; + update(channels::table) + .filter(dsl::uuid.eq(old_last_channel.uuid)) + .set(dsl::is_above.eq(new_channel.uuid)) + .execute(&mut conn) + .await?; + } + // returns different object because there's no reason to build the channelbuilder (wastes 1 database request) let channel = Self { uuid: channel_uuid, guild_uuid, name, description, + is_above: None, permissions: vec![], }; diff --git a/src/utils.rs b/src/utils.rs index 631b003..e68f38a 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -9,7 +9,7 @@ use hex::encode; use redis::RedisError; use serde::Serialize; -use crate::{Data, error::Error}; +use crate::{Data, error::Error, structs::Channel}; pub fn get_auth_header(headers: &HeaderMap) -> Result<&str, Error> { let auth_token = headers.get(actix_web::http::header::AUTHORIZATION); @@ -119,6 +119,28 @@ pub fn image_check(icon: BytesMut) -> Result { )) } +pub async fn order_channels(mut channels: Vec) -> Result, Error> { + let mut ordered = Vec::new(); + + // Find head + let head_pos = channels + .iter() + .position(|channel| !channels.iter().any(|i| i.is_above == Some(channel.uuid))); + + if let Some(pos) = head_pos { + ordered.push(channels.swap_remove(pos)); + + while let Some(next_pos) = channels + .iter() + .position(|channel| Some(channel.uuid) == ordered.last().unwrap().is_above) + { + ordered.push(channels.swap_remove(next_pos)); + } + } + + Ok(ordered) +} + impl Data { pub async fn set_cache_key( &self, From 1cda34d16bccc6d0d1f7606ded545200d88ae1ba Mon Sep 17 00:00:00 2001 From: Radical Date: Mon, 26 May 2025 22:26:47 +0200 Subject: [PATCH 35/92] fix: remove more unwraps found more unwraps that needed to be changed to ? --- src/api/v1/auth/refresh.rs | 9 ++------- src/api/v1/servers/mod.rs | 2 +- src/api/v1/servers/uuid/channels/mod.rs | 14 +++++++++++--- src/api/v1/servers/uuid/channels/uuid/mod.rs | 2 +- src/api/v1/servers/uuid/channels/uuid/socket.rs | 2 +- src/api/v1/servers/uuid/roles/mod.rs | 2 +- 6 files changed, 17 insertions(+), 14 deletions(-) diff --git a/src/api/v1/auth/refresh.rs b/src/api/v1/auth/refresh.rs index 303748a..fceabf5 100644 --- a/src/api/v1/auth/refresh.rs +++ b/src/api/v1/auth/refresh.rs @@ -59,14 +59,9 @@ pub async fn res(req: HttpRequest, data: web::Data) -> Result 1987200 { - let new_refresh_token = generate_refresh_token(); + let new_refresh_token = generate_refresh_token()?; - if new_refresh_token.is_err() { - error!("{}", new_refresh_token.unwrap_err()); - return Ok(HttpResponse::InternalServerError().finish()); - } - - let new_refresh_token = new_refresh_token.unwrap(); + let new_refresh_token = new_refresh_token; match update(refresh_tokens::table) .filter(rdsl::token.eq(&refresh_token)) diff --git a/src/api/v1/servers/mod.rs b/src/api/v1/servers/mod.rs index 76a4c16..91980e3 100644 --- a/src/api/v1/servers/mod.rs +++ b/src/api/v1/servers/mod.rs @@ -63,7 +63,7 @@ pub async fn get( let amount = request_query.amount.unwrap_or(10); - check_access_token(auth_header, &mut data.pool.get().await.unwrap()).await?; + check_access_token(auth_header, &mut data.pool.get().await?).await?; let guilds = Guild::fetch_amount(&data.pool, start, amount).await?; diff --git a/src/api/v1/servers/uuid/channels/mod.rs b/src/api/v1/servers/uuid/channels/mod.rs index 3021ded..c3640fe 100644 --- a/src/api/v1/servers/uuid/channels/mod.rs +++ b/src/api/v1/servers/uuid/channels/mod.rs @@ -1,5 +1,9 @@ use crate::{ - api::v1::auth::check_access_token, error::Error, structs::{Channel, Member}, utils::{get_auth_header, order_channels}, Data + Data, + api::v1::auth::check_access_token, + error::Error, + structs::{Channel, Member}, + utils::{get_auth_header, order_channels}, }; use ::uuid::Uuid; use actix_web::{HttpRequest, HttpResponse, get, post, web}; @@ -41,8 +45,12 @@ pub async fn get( let channels_ordered = order_channels(channels).await?; - data.set_cache_key(format!("{}_channels", guild_uuid), channels_ordered.clone(), 1800) - .await?; + data.set_cache_key( + format!("{}_channels", guild_uuid), + channels_ordered.clone(), + 1800, + ) + .await?; Ok(HttpResponse::Ok().json(channels_ordered)) } diff --git a/src/api/v1/servers/uuid/channels/uuid/mod.rs b/src/api/v1/servers/uuid/channels/uuid/mod.rs index 54f90a7..b1c749f 100644 --- a/src/api/v1/servers/uuid/channels/uuid/mod.rs +++ b/src/api/v1/servers/uuid/channels/uuid/mod.rs @@ -64,7 +64,7 @@ pub async fn delete( let channel: Channel; if let Ok(cache_hit) = data.get_cache_key(format!("{}", channel_uuid)).await { - channel = serde_json::from_str(&cache_hit).unwrap(); + channel = serde_json::from_str(&cache_hit)?; data.del_cache_key(format!("{}", channel_uuid)).await?; } else { diff --git a/src/api/v1/servers/uuid/channels/uuid/socket.rs b/src/api/v1/servers/uuid/channels/uuid/socket.rs index 744f017..a90cb86 100644 --- a/src/api/v1/servers/uuid/channels/uuid/socket.rs +++ b/src/api/v1/servers/uuid/channels/uuid/socket.rs @@ -42,7 +42,7 @@ pub async fn echo( // Return channel cache or result from psql as `channel` variable if let Ok(cache_hit) = data.get_cache_key(format!("{}", channel_uuid)).await { - channel = serde_json::from_str(&cache_hit).unwrap() + channel = serde_json::from_str(&cache_hit)? } else { channel = Channel::fetch_one(&mut conn, channel_uuid).await?; diff --git a/src/api/v1/servers/uuid/roles/mod.rs b/src/api/v1/servers/uuid/roles/mod.rs index fe25d39..db6c7cf 100644 --- a/src/api/v1/servers/uuid/roles/mod.rs +++ b/src/api/v1/servers/uuid/roles/mod.rs @@ -62,7 +62,7 @@ pub async fn create( let guild_uuid = path.into_inner().0; - let mut conn = data.pool.get().await.unwrap(); + let mut conn = data.pool.get().await?; let uuid = check_access_token(auth_header, &mut conn).await?; From b8cf21903ebd2c362775f47b4804020caf7ce7cd Mon Sep 17 00:00:00 2001 From: Radical Date: Mon, 26 May 2025 23:41:20 +0200 Subject: [PATCH 36/92] feat: allow disabling of registration --- src/api/v1/auth/register.rs | 4 ++++ src/config.rs | 8 ++++++++ src/error.rs | 2 ++ 3 files changed, 14 insertions(+) diff --git a/src/api/v1/auth/register.rs b/src/api/v1/auth/register.rs index 75aeb9d..66be90a 100644 --- a/src/api/v1/auth/register.rs +++ b/src/api/v1/auth/register.rs @@ -67,6 +67,10 @@ pub async fn res( account_information: web::Json, data: web::Data, ) -> Result { + if !data.config.instance.registration { + return Err(Error::Forbidden("registration is disabled on this instance".to_string())) + } + let uuid = Uuid::now_v7(); if !EMAIL_REGEX.is_match(&account_information.email) { diff --git a/src/config.rs b/src/config.rs index 102318f..079ce35 100644 --- a/src/config.rs +++ b/src/config.rs @@ -10,6 +10,7 @@ pub struct ConfigBuilder { database: Database, cache_database: CacheDatabase, web: Option, + instance: Option, bunny: BunnyBuilder, } @@ -38,6 +39,11 @@ struct WebBuilder { _ssl: Option, } +#[derive(Debug, Deserialize, Clone)] +pub struct Instance { + pub registration: bool, +} + #[derive(Debug, Deserialize)] struct BunnyBuilder { api_key: String, @@ -93,6 +99,7 @@ impl ConfigBuilder { database: self.database, cache_database: self.cache_database, web, + instance: self.instance.unwrap_or(Instance { registration: true }), bunny, } } @@ -103,6 +110,7 @@ pub struct Config { pub database: Database, pub cache_database: CacheDatabase, pub web: Web, + pub instance: Instance, pub bunny: Bunny, } diff --git a/src/error.rs b/src/error.rs index 3907f7c..fb990c7 100644 --- a/src/error.rs +++ b/src/error.rs @@ -61,6 +61,8 @@ pub enum Error { #[error("{0}")] Unauthorized(String), #[error("{0}")] + Forbidden(String), + #[error("{0}")] InternalServerError(String), } From 39d01bb0d04883b1d4789b2b64c8039b493e4441 Mon Sep 17 00:00:00 2001 From: Radical Date: Tue, 27 May 2025 07:46:10 +0000 Subject: [PATCH 37/92] feat: move me endpoint to /me and add /me/servers --- src/api/v1/me/friends/mod.rs | 0 src/api/v1/me/friends/pending.rs | 0 src/api/v1/{users/me.rs => me/mod.rs} | 16 +++++++++--- src/api/v1/me/servers.rs | 35 +++++++++++++++++++++++++++ src/api/v1/mod.rs | 2 ++ src/api/v1/users/mod.rs | 3 --- src/structs.rs | 13 +++++++++- 7 files changed, 61 insertions(+), 8 deletions(-) create mode 100644 src/api/v1/me/friends/mod.rs create mode 100644 src/api/v1/me/friends/pending.rs rename src/api/v1/{users/me.rs => me/mod.rs} (88%) create mode 100644 src/api/v1/me/servers.rs diff --git a/src/api/v1/me/friends/mod.rs b/src/api/v1/me/friends/mod.rs new file mode 100644 index 0000000..e69de29 diff --git a/src/api/v1/me/friends/pending.rs b/src/api/v1/me/friends/pending.rs new file mode 100644 index 0000000..e69de29 diff --git a/src/api/v1/users/me.rs b/src/api/v1/me/mod.rs similarity index 88% rename from src/api/v1/users/me.rs rename to src/api/v1/me/mod.rs index 83d02db..14067c3 100644 --- a/src/api/v1/users/me.rs +++ b/src/api/v1/me/mod.rs @@ -1,13 +1,21 @@ use actix_multipart::form::{MultipartForm, json::Json as MpJson, tempfile::TempFile}; -use actix_web::{HttpRequest, HttpResponse, get, patch, web}; +use actix_web::{get, patch, web, HttpRequest, HttpResponse, Scope}; use serde::Deserialize; use crate::{ Data, api::v1::auth::check_access_token, error::Error, structs::Me, utils::get_auth_header, }; -#[get("/me")] -pub async fn res(req: HttpRequest, data: web::Data) -> Result { +mod servers; + +pub fn web() -> Scope { + web::scope("/me") + .service(get) + .service(update) +} + +#[get("")] +pub async fn get(req: HttpRequest, data: web::Data) -> Result { let headers = req.headers(); let auth_header = get_auth_header(headers)?; @@ -36,7 +44,7 @@ struct UploadForm { json: Option>, } -#[patch("/me")] +#[patch("")] pub async fn update( req: HttpRequest, MultipartForm(form): MultipartForm, diff --git a/src/api/v1/me/servers.rs b/src/api/v1/me/servers.rs new file mode 100644 index 0000000..73ab139 --- /dev/null +++ b/src/api/v1/me/servers.rs @@ -0,0 +1,35 @@ +//! Contains endpoint related to guild memberships + +use actix_web::{get, web, HttpRequest, HttpResponse}; + +use crate::{api::v1::auth::check_access_token, error::Error, structs::Me, utils::get_auth_header, Data}; + + +/// `GET /api/v1/me/servers` Returns all guild memberships in a list +/// +/// Example Response: +/// ``` +/// json!({ +/// "uuid": "22006503-fb01-46e6-8e0e-70336dac6c63", +/// "nickname": "This field is nullable", +/// "user_uuid": "522bca17-de63-4706-9d18-0971867ad1e0", +/// "guild_uuid": "0911e468-3e9e-47bf-8381-59b30e8b68a8" +/// }); +/// ``` +/// NOTE: UUIDs in this response are made using `uuidgen`, UUIDs made by the actual backend will be UUIDv7 and have extractable timestamps +#[get("/servers")] +pub async fn get(req: HttpRequest, data: web::Data) -> Result { + let headers = req.headers(); + + let auth_header = get_auth_header(headers)?; + + let mut conn = data.pool.get().await?; + + let uuid = check_access_token(auth_header, &mut conn).await?; + + let me = Me::get(&mut conn, uuid).await?; + + let memberships = me.fetch_memberships(&mut conn).await?; + + Ok(HttpResponse::Ok().json(memberships)) +} \ No newline at end of file diff --git a/src/api/v1/mod.rs b/src/api/v1/mod.rs index 749774d..f711fe5 100644 --- a/src/api/v1/mod.rs +++ b/src/api/v1/mod.rs @@ -5,6 +5,7 @@ mod invites; mod servers; mod stats; mod users; +mod me; pub fn web() -> Scope { web::scope("/v1") @@ -13,4 +14,5 @@ pub fn web() -> Scope { .service(users::web()) .service(servers::web()) .service(invites::web()) + .service(me::web()) } diff --git a/src/api/v1/users/mod.rs b/src/api/v1/users/mod.rs index a0b1ea6..a81491c 100644 --- a/src/api/v1/users/mod.rs +++ b/src/api/v1/users/mod.rs @@ -8,14 +8,11 @@ use crate::{ utils::get_auth_header, }; -mod me; mod uuid; 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 b423b20..fc1d9ca 100644 --- a/src/structs.rs +++ b/src/structs.rs @@ -542,7 +542,7 @@ impl Role { } } -#[derive(Queryable, Selectable, Insertable)] +#[derive(Serialize, Queryable, Selectable, Insertable)] #[diesel(table_name = guild_members)] #[diesel(check_for_backend(diesel::pg::Pg))] pub struct Member { @@ -703,6 +703,17 @@ impl Me { Ok(me) } + pub async fn fetch_memberships(&self, conn: &mut Conn) -> Result, Error> { + use guild_members::dsl; + let memberships: Vec = dsl::guild_members + .filter(dsl::user_uuid.eq(self.uuid)) + .select(Member::as_select()) + .load(conn) + .await?; + + Ok(memberships) + } + pub async fn set_avatar( &mut self, bunny_cdn: &bunny_api_tokio::Client, From 1aa38631b8b0f125599ef83d90eba5d66d28d2d0 Mon Sep 17 00:00:00 2001 From: Radical Date: Tue, 27 May 2025 11:16:33 +0000 Subject: [PATCH 38/92] feat: implement is_above for roles and reuse same functions from channels! --- .../down.sql | 3 + .../up.sql | 3 + src/api/v1/servers/mod.rs | 2 - src/api/v1/servers/uuid/channels/mod.rs | 8 +- src/api/v1/servers/uuid/roles/mod.rs | 12 ++- src/schema.rs | 2 +- src/structs.rs | 73 ++++++++++++++----- src/utils.rs | 19 +++-- 8 files changed, 81 insertions(+), 41 deletions(-) create mode 100644 migrations/2025-05-27-105059_redo_role_ordering/down.sql create mode 100644 migrations/2025-05-27-105059_redo_role_ordering/up.sql diff --git a/migrations/2025-05-27-105059_redo_role_ordering/down.sql b/migrations/2025-05-27-105059_redo_role_ordering/down.sql new file mode 100644 index 0000000..6b38e1e --- /dev/null +++ b/migrations/2025-05-27-105059_redo_role_ordering/down.sql @@ -0,0 +1,3 @@ +-- This file should undo anything in `up.sql` +ALTER TABLE roles ADD COLUMN position int NOT NULL DEFAULT 0; +ALTER TABLE roles DROP COLUMN is_above; diff --git a/migrations/2025-05-27-105059_redo_role_ordering/up.sql b/migrations/2025-05-27-105059_redo_role_ordering/up.sql new file mode 100644 index 0000000..d426ab7 --- /dev/null +++ b/migrations/2025-05-27-105059_redo_role_ordering/up.sql @@ -0,0 +1,3 @@ +-- Your SQL goes here +ALTER TABLE roles DROP COLUMN position; +ALTER TABLE roles ADD COLUMN is_above UUID UNIQUE REFERENCES roles(uuid) DEFAULT NULL; diff --git a/src/api/v1/servers/mod.rs b/src/api/v1/servers/mod.rs index 91980e3..987d439 100644 --- a/src/api/v1/servers/mod.rs +++ b/src/api/v1/servers/mod.rs @@ -14,7 +14,6 @@ use crate::{ #[derive(Deserialize)] struct GuildInfo { name: String, - description: Option, } pub fn web() -> Scope { @@ -41,7 +40,6 @@ pub async fn create( let guild = Guild::new( &mut conn, guild_info.name.clone(), - guild_info.description.clone(), uuid, ) .await?; diff --git a/src/api/v1/servers/uuid/channels/mod.rs b/src/api/v1/servers/uuid/channels/mod.rs index c3640fe..d87aba2 100644 --- a/src/api/v1/servers/uuid/channels/mod.rs +++ b/src/api/v1/servers/uuid/channels/mod.rs @@ -1,9 +1,5 @@ use crate::{ - Data, - api::v1::auth::check_access_token, - error::Error, - structs::{Channel, Member}, - utils::{get_auth_header, order_channels}, + api::v1::auth::check_access_token, error::Error, structs::{Channel, Member}, utils::{get_auth_header, order_by_is_above}, Data }; use ::uuid::Uuid; use actix_web::{HttpRequest, HttpResponse, get, post, web}; @@ -43,7 +39,7 @@ pub async fn get( let channels = Channel::fetch_all(&data.pool, guild_uuid).await?; - let channels_ordered = order_channels(channels).await?; + let channels_ordered = order_by_is_above(channels).await?; data.set_cache_key( format!("{}_channels", guild_uuid), diff --git a/src/api/v1/servers/uuid/roles/mod.rs b/src/api/v1/servers/uuid/roles/mod.rs index db6c7cf..c0ebaa2 100644 --- a/src/api/v1/servers/uuid/roles/mod.rs +++ b/src/api/v1/servers/uuid/roles/mod.rs @@ -3,11 +3,7 @@ use actix_web::{HttpRequest, HttpResponse, get, post, web}; use serde::Deserialize; use crate::{ - Data, - api::v1::auth::check_access_token, - error::Error, - structs::{Member, Role}, - utils::get_auth_header, + api::v1::auth::check_access_token, error::Error, structs::{Member, Role}, utils::{get_auth_header, order_by_is_above}, Data }; pub mod uuid; @@ -43,10 +39,12 @@ pub async fn get( let roles = Role::fetch_all(&mut conn, guild_uuid).await?; - data.set_cache_key(format!("{}_roles", guild_uuid), roles.clone(), 1800) + let roles_ordered = order_by_is_above(roles).await?; + + data.set_cache_key(format!("{}_roles", guild_uuid), roles_ordered.clone(), 1800) .await?; - Ok(HttpResponse::Ok().json(roles)) + Ok(HttpResponse::Ok().json(roles_ordered)) } #[post("{uuid}/roles")] diff --git a/src/schema.rs b/src/schema.rs index 8a85a2e..cc5e97c 100644 --- a/src/schema.rs +++ b/src/schema.rs @@ -105,8 +105,8 @@ diesel::table! { #[max_length = 50] name -> Varchar, color -> Int4, - position -> Int4, permissions -> Int8, + is_above -> Nullable, } } diff --git a/src/structs.rs b/src/structs.rs index fc1d9ca..be4bd43 100644 --- a/src/structs.rs +++ b/src/structs.rs @@ -5,7 +5,6 @@ use diesel::{ update, }; use diesel_async::{RunQueryDsl, pooled_connection::AsyncDieselConnectionManager}; -use log::debug; use serde::{Deserialize, Serialize}; use tokio::task; use url::Url; @@ -15,9 +14,17 @@ use crate::{ Conn, Data, error::Error, schema::*, - utils::{image_check, order_channels}, + utils::{image_check, order_by_is_above}, }; +pub trait HasUuid { + fn uuid(&self) -> &Uuid; +} + +pub trait HasIsAbove { + fn is_above(&self) -> Option<&Uuid>; +} + fn load_or_empty( query_result: Result, diesel::result::Error>, ) -> Result, diesel::result::Error> { @@ -79,6 +86,18 @@ pub struct ChannelPermission { pub permissions: i64, } +impl HasUuid for Channel { + fn uuid(&self) -> &Uuid { + self.uuid.as_ref() + } +} + +impl HasIsAbove for Channel { + fn is_above(&self) -> Option<&Uuid> { + self.is_above.as_ref() + } +} + impl Channel { pub async fn fetch_all( pool: &deadpool::managed::Pool< @@ -129,11 +148,7 @@ impl Channel { let channels = Self::fetch_all(&data.pool, guild_uuid).await?; - debug!("{:?}", channels); - - let channels_ordered = order_channels(channels).await?; - - debug!("{:?}", channels_ordered); + let channels_ordered = order_by_is_above(channels).await?; let last_channel = channels_ordered.last(); @@ -145,8 +160,6 @@ impl Channel { is_above: None, }; - debug!("New Channel: {:?}", new_channel); - insert_into(channels::table) .values(new_channel.clone()) .execute(&mut conn) @@ -359,7 +372,6 @@ impl Guild { pub async fn new( conn: &mut Conn, name: String, - description: Option, owner_uuid: Uuid, ) -> Result { let guild_uuid = Uuid::now_v7(); @@ -367,7 +379,7 @@ impl Guild { let guild_builder = GuildBuilder { uuid: guild_uuid, name: name.clone(), - description: description.clone(), + description: None, icon: None, owner_uuid, }; @@ -394,7 +406,7 @@ impl Guild { Ok(Guild { uuid: guild_uuid, name, - description, + description: None, icon: None, owner_uuid, roles: vec![], @@ -492,10 +504,22 @@ pub struct Role { guild_uuid: Uuid, name: String, color: i32, - position: i32, + is_above: Option, permissions: i64, } +impl HasUuid for Role { + fn uuid(&self) -> &Uuid { + self.uuid.as_ref() + } +} + +impl HasIsAbove for Role { + fn is_above(&self) -> Option<&Uuid> { + self.is_above.as_ref() + } +} + impl Role { pub async fn fetch_all(conn: &mut Conn, guild_uuid: Uuid) -> Result, Error> { use roles::dsl; @@ -524,21 +548,36 @@ impl Role { pub async fn new(conn: &mut Conn, guild_uuid: Uuid, name: String) -> Result { let role_uuid = Uuid::now_v7(); - let role = Role { + let roles = Self::fetch_all(conn, guild_uuid).await?; + + let roles_ordered = order_by_is_above(roles).await?; + + let last_role = roles_ordered.last(); + + let new_role = Role { uuid: role_uuid, guild_uuid, name, color: 16777215, - position: 0, + is_above: None, permissions: 0, }; insert_into(roles::table) - .values(role.clone()) + .values(new_role.clone()) .execute(conn) .await?; - Ok(role) + if let Some(old_last_role) = last_role { + use roles::dsl; + update(roles::table) + .filter(dsl::uuid.eq(old_last_role.uuid)) + .set(dsl::is_above.eq(new_role.uuid)) + .execute(conn) + .await?; + } + + Ok(new_role) } } diff --git a/src/utils.rs b/src/utils.rs index e68f38a..3edaab8 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -9,7 +9,7 @@ use hex::encode; use redis::RedisError; use serde::Serialize; -use crate::{Data, error::Error, structs::Channel}; +use crate::{error::Error, structs::{HasIsAbove, HasUuid}, Data}; pub fn get_auth_header(headers: &HeaderMap) -> Result<&str, Error> { let auth_token = headers.get(actix_web::http::header::AUTHORIZATION); @@ -119,22 +119,25 @@ pub fn image_check(icon: BytesMut) -> Result { )) } -pub async fn order_channels(mut channels: Vec) -> Result, Error> { +pub async fn order_by_is_above(mut items: Vec) -> Result, Error> +where + T: HasUuid + HasIsAbove, +{ let mut ordered = Vec::new(); // Find head - let head_pos = channels + let head_pos = items .iter() - .position(|channel| !channels.iter().any(|i| i.is_above == Some(channel.uuid))); + .position(|item| !items.iter().any(|i| i.is_above() == Some(item.uuid()))); if let Some(pos) = head_pos { - ordered.push(channels.swap_remove(pos)); + ordered.push(items.swap_remove(pos)); - while let Some(next_pos) = channels + while let Some(next_pos) = items .iter() - .position(|channel| Some(channel.uuid) == ordered.last().unwrap().is_above) + .position(|item| Some(item.uuid()) == ordered.last().unwrap().is_above()) { - ordered.push(channels.swap_remove(next_pos)); + ordered.push(items.swap_remove(next_pos)); } } From 16ccf94631f36be237d2aa94a7f125bec72f1b02 Mon Sep 17 00:00:00 2001 From: Radical Date: Tue, 27 May 2025 11:52:17 +0000 Subject: [PATCH 39/92] docs: partially document codebase Should make it easier for frontend to figure out what stuff actually does, more will be added as the project goes on --- src/api/mod.rs | 4 +- src/api/v1/me/servers.rs | 26 +++++++---- src/api/v1/mod.rs | 2 + src/api/v1/servers/mod.rs | 77 ++++++++++++++++++++++++++++++++- src/api/v1/servers/uuid/icon.rs | 7 +++ src/api/v1/servers/uuid/mod.rs | 39 ++++++++++++++++- src/api/v1/stats.rs | 15 +++++++ src/api/v1/users/mod.rs | 38 ++++++++++++++-- src/api/v1/users/uuid.rs | 20 ++++++++- src/api/versions.rs | 16 ++++++- 10 files changed, 226 insertions(+), 18 deletions(-) diff --git a/src/api/mod.rs b/src/api/mod.rs index 25391eb..87c1c14 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -1,3 +1,5 @@ +//! `/api` Contains the entire API + use actix_web::Scope; use actix_web::web; @@ -5,5 +7,5 @@ mod v1; mod versions; pub fn web() -> Scope { - web::scope("/api").service(v1::web()).service(versions::res) + web::scope("/api").service(v1::web()).service(versions::get) } diff --git a/src/api/v1/me/servers.rs b/src/api/v1/me/servers.rs index 73ab139..390c31e 100644 --- a/src/api/v1/me/servers.rs +++ b/src/api/v1/me/servers.rs @@ -1,4 +1,4 @@ -//! Contains endpoint related to guild memberships +//! `/api/v1/me/servers` Contains endpoint related to guild memberships use actix_web::{get, web, HttpRequest, HttpResponse}; @@ -7,14 +7,24 @@ use crate::{api::v1::auth::check_access_token, error::Error, structs::Me, utils: /// `GET /api/v1/me/servers` Returns all guild memberships in a list /// -/// Example Response: +/// requires auth: yes +/// +/// ### Example Response /// ``` -/// json!({ -/// "uuid": "22006503-fb01-46e6-8e0e-70336dac6c63", -/// "nickname": "This field is nullable", -/// "user_uuid": "522bca17-de63-4706-9d18-0971867ad1e0", -/// "guild_uuid": "0911e468-3e9e-47bf-8381-59b30e8b68a8" -/// }); +/// json!([ +/// { +/// "uuid": "22006503-fb01-46e6-8e0e-70336dac6c63", +/// "nickname": "This field is nullable", +/// "user_uuid": "522bca17-de63-4706-9d18-0971867ad1e0", +/// "guild_uuid": "0911e468-3e9e-47bf-8381-59b30e8b68a8" +/// }, +/// { +/// "uuid": "bf95361e-3b64-4704-969c-3c5a80d10514", +/// "nickname": null, +/// "user_uuid": "522bca17-de63-4706-9d18-0971867ad1e0", +/// "guild_uuid": "69ec2ce5-3d8b-4451-b644-c2d969905458" +/// } +/// ]); /// ``` /// NOTE: UUIDs in this response are made using `uuidgen`, UUIDs made by the actual backend will be UUIDv7 and have extractable timestamps #[get("/servers")] diff --git a/src/api/v1/mod.rs b/src/api/v1/mod.rs index f711fe5..f30ad58 100644 --- a/src/api/v1/mod.rs +++ b/src/api/v1/mod.rs @@ -1,3 +1,5 @@ +//! `/api/v1` Contains version 1 of the api + use actix_web::{Scope, web}; mod auth; diff --git a/src/api/v1/servers/mod.rs b/src/api/v1/servers/mod.rs index 987d439..e864d0a 100644 --- a/src/api/v1/servers/mod.rs +++ b/src/api/v1/servers/mod.rs @@ -1,3 +1,5 @@ +//! `/api/v1/servers` Guild related endpoints + use actix_web::{HttpRequest, HttpResponse, Scope, get, post, web}; use serde::Deserialize; @@ -18,13 +20,37 @@ struct GuildInfo { pub fn web() -> Scope { web::scope("/servers") - .service(create) + .service(post) .service(get) .service(uuid::web()) } +/// `POST /api/v1/servers` Creates a new guild +/// +/// requires auth: yes +/// +/// ### Request Example +/// ``` +/// json!({ +/// "name": "My new server!" +/// }); +/// ``` +/// +/// ### Response Example +/// ``` +/// json!({ +/// "uuid": "383d2afa-082f-4dd3-9050-ca6ed91487b6", +/// "name": "My new server!", +/// "description": null, +/// "icon": null, +/// "owner_uuid": "155d2291-fb23-46bd-a656-ae7c5d8218e6", +/// "roles": [], +/// "member_count": 1 +/// }); +/// ``` +/// NOTE: UUIDs in this response are made using `uuidgen`, UUIDs made by the actual backend will be UUIDv7 and have extractable timestamps #[post("")] -pub async fn create( +pub async fn post( req: HttpRequest, guild_info: web::Json, data: web::Data, @@ -47,6 +73,53 @@ pub async fn create( Ok(HttpResponse::Ok().json(guild)) } +/// `GET /api/v1/servers` Fetches all guilds +/// +/// requires auth: yes +/// +/// requires admin: yes +/// +/// ### Response Example +/// ``` +/// json!([ +/// { +/// "uuid": "383d2afa-082f-4dd3-9050-ca6ed91487b6", +/// "name": "My new server!", +/// "description": null, +/// "icon": null, +/// "owner_uuid": "155d2291-fb23-46bd-a656-ae7c5d8218e6", +/// "roles": [], +/// "member_count": 1 +/// }, +/// { +/// "uuid": "5ba61ec7-5f97-43e1-89a5-d4693c155612", +/// "name": "My first server!", +/// "description": "This is a cool and nullable description!", +/// "icon": "https://nullable-url/path/to/icon.png", +/// "owner_uuid": "155d2291-fb23-46bd-a656-ae7c5d8218e6", +/// "roles": [ +/// { +/// "uuid": "be0e4da4-cf73-4f45-98f8-bb1c73d1ab8b", +/// "guild_uuid": "5ba61ec7-5f97-43e1-89a5-d4693c155612", +/// "name": "Cool people", +/// "color": 15650773, +/// "is_above": c7432f1c-f4ad-4ad3-8216-51388b6abb5b, +/// "permissions": 0 +/// } +/// { +/// "uuid": "c7432f1c-f4ad-4ad3-8216-51388b6abb5b", +/// "guild_uuid": "5ba61ec7-5f97-43e1-89a5-d4693c155612", +/// "name": "Equally cool people", +/// "color": 16777215, +/// "is_above": null, +/// "permissions": 0 +/// } +/// ], +/// "member_count": 20 +/// } +/// ]); +/// ``` +/// NOTE: UUIDs in this response are made using `uuidgen`, UUIDs made by the actual backend will be UUIDv7 and have extractable timestamps #[get("")] pub async fn get( req: HttpRequest, diff --git a/src/api/v1/servers/uuid/icon.rs b/src/api/v1/servers/uuid/icon.rs index 2155f55..af9ab46 100644 --- a/src/api/v1/servers/uuid/icon.rs +++ b/src/api/v1/servers/uuid/icon.rs @@ -1,3 +1,5 @@ +//! `/api/v1/servers/{uuid}/icon` icon related endpoints, will probably be replaced by a multipart post to above endpoint + use actix_web::{HttpRequest, HttpResponse, put, web}; use futures_util::StreamExt as _; use uuid::Uuid; @@ -10,6 +12,11 @@ use crate::{ utils::get_auth_header, }; +/// `PUT /api/v1/servers/{uuid}/icon` Icon upload +/// +/// requires auth: no +/// +/// put request expects a file and nothing else #[put("{uuid}/icon")] pub async fn upload( req: HttpRequest, diff --git a/src/api/v1/servers/uuid/mod.rs b/src/api/v1/servers/uuid/mod.rs index 887fd06..80b3068 100644 --- a/src/api/v1/servers/uuid/mod.rs +++ b/src/api/v1/servers/uuid/mod.rs @@ -1,3 +1,5 @@ +//! `/api/v1/servers/{uuid}` Specific server endpoints + use actix_web::{HttpRequest, HttpResponse, Scope, get, web}; use uuid::Uuid; @@ -17,7 +19,7 @@ use crate::{ pub fn web() -> Scope { web::scope("") // Servers - .service(res) + .service(get) // Channels .service(channels::get) .service(channels::create) @@ -36,8 +38,41 @@ pub fn web() -> Scope { .service(icon::upload) } +/// `GET /api/v1/servers/{uuid}` DESCRIPTION +/// +/// requires auth: yes +/// +/// ### Response Example +/// ``` +/// json!({ +/// "uuid": "5ba61ec7-5f97-43e1-89a5-d4693c155612", +/// "name": "My first server!", +/// "description": "This is a cool and nullable description!", +/// "icon": "https://nullable-url/path/to/icon.png", +/// "owner_uuid": "155d2291-fb23-46bd-a656-ae7c5d8218e6", +/// "roles": [ +/// { +/// "uuid": "be0e4da4-cf73-4f45-98f8-bb1c73d1ab8b", +/// "guild_uuid": "5ba61ec7-5f97-43e1-89a5-d4693c155612", +/// "name": "Cool people", +/// "color": 15650773, +/// "is_above": c7432f1c-f4ad-4ad3-8216-51388b6abb5b, +/// "permissions": 0 +/// } +/// { +/// "uuid": "c7432f1c-f4ad-4ad3-8216-51388b6abb5b", +/// "guild_uuid": "5ba61ec7-5f97-43e1-89a5-d4693c155612", +/// "name": "Equally cool people", +/// "color": 16777215, +/// "is_above": null, +/// "permissions": 0 +/// } +/// ], +/// "member_count": 20 +/// }); +/// ``` #[get("/{uuid}")] -pub async fn res( +pub async fn get( req: HttpRequest, path: web::Path<(Uuid,)>, data: web::Data, diff --git a/src/api/v1/stats.rs b/src/api/v1/stats.rs index 0b9567e..4e8aa32 100644 --- a/src/api/v1/stats.rs +++ b/src/api/v1/stats.rs @@ -1,3 +1,5 @@ +//! `/api/v1/stats` Returns stats about the server + use std::time::SystemTime; use actix_web::{HttpResponse, get, web}; @@ -19,6 +21,19 @@ struct Response { build_number: String, } +/// `GET /api/v1/` Returns stats about the server +/// +/// requires auth: no +/// +/// ### Response Example +/// ``` +/// json!({ +/// "accounts": 3, +/// "uptime": 50000, +/// "version": "0.1.0", +/// "build_number": "39d01bb" +/// }); +/// ``` #[get("/stats")] pub async fn res(data: web::Data) -> Result { let accounts: i64 = users diff --git a/src/api/v1/users/mod.rs b/src/api/v1/users/mod.rs index a81491c..0b80fd1 100644 --- a/src/api/v1/users/mod.rs +++ b/src/api/v1/users/mod.rs @@ -1,3 +1,5 @@ +//! `/api/v1/users` Contains endpoints related to all users + use actix_web::{HttpRequest, HttpResponse, Scope, get, web}; use crate::{ @@ -12,12 +14,42 @@ mod uuid; pub fn web() -> Scope { web::scope("/users") - .service(res) - .service(uuid::res) + .service(get) + .service(uuid::get) } +/// `GET /api/v1/users` Returns all users on this instance +/// +/// requires auth: yes +/// +/// requires admin: yes +/// +/// ### Response Example +/// ``` +/// json!([ +/// { +/// "uuid": "155d2291-fb23-46bd-a656-ae7c5d8218e6", +/// "username": "user1", +/// "display_name": "Nullable Name", +/// "avatar": "https://nullable-url.com/path/to/image.png" +/// }, +/// { +/// "uuid": "d48a3317-7b4d-443f-a250-ea9ab2bb8661", +/// "username": "user2", +/// "display_name": "John User 2", +/// "avatar": "https://also-supports-jpg.com/path/to/image.jpg" +/// }, +/// { +/// "uuid": "12c4b3f8-a25b-4b9b-8136-b275c855ed4a", +/// "username": "user3", +/// "display_name": null, +/// "avatar": null +/// } +/// ]); +/// ``` +/// NOTE: UUIDs in this response are made using `uuidgen`, UUIDs made by the actual backend will be UUIDv7 and have extractable timestamps #[get("")] -pub async fn res( +pub async fn get( req: HttpRequest, request_query: web::Query, data: web::Data, diff --git a/src/api/v1/users/uuid.rs b/src/api/v1/users/uuid.rs index 6d9f904..27e3326 100644 --- a/src/api/v1/users/uuid.rs +++ b/src/api/v1/users/uuid.rs @@ -1,3 +1,5 @@ +//! `/api/v1/users/{uuid}` Specific user endpoints + use actix_web::{HttpRequest, HttpResponse, get, web}; use uuid::Uuid; @@ -5,8 +7,24 @@ use crate::{ Data, api::v1::auth::check_access_token, error::Error, structs::User, utils::get_auth_header, }; +/// `GET /api/v1/users/{uuid}` Returns user with the given UUID +/// +/// requires auth: yes +/// +/// requires relation: yes +/// +/// ### Response Example +/// ``` +/// json!({ +/// "uuid": "155d2291-fb23-46bd-a656-ae7c5d8218e6", +/// "username": "user1", +/// "display_name": "Nullable Name", +/// "avatar": "https://nullable-url.com/path/to/image.png" +/// }); +/// ``` +/// NOTE: UUIDs in this response are made using `uuidgen`, UUIDs made by the actual backend will be UUIDv7 and have extractable timestamps #[get("/{uuid}")] -pub async fn res( +pub async fn get( req: HttpRequest, path: web::Path<(Uuid,)>, data: web::Data, diff --git a/src/api/versions.rs b/src/api/versions.rs index e5695be..809d6ed 100644 --- a/src/api/versions.rs +++ b/src/api/versions.rs @@ -1,3 +1,4 @@ +//! `/api/v1/versions` Returns info about api versions use actix_web::{HttpResponse, Responder, get}; use serde::Serialize; @@ -10,8 +11,21 @@ struct Response { #[derive(Serialize)] struct UnstableFeatures; +/// `GET /api/versions` Returns info about api versions. +/// +/// requires auth: no +/// +/// ### Response Example +/// ``` +/// json!({ +/// "unstable_features": {}, +/// "versions": [ +/// "1" +/// ] +/// }); +/// ``` #[get("/versions")] -pub async fn res() -> impl Responder { +pub async fn get() -> impl Responder { let response = Response { unstable_features: UnstableFeatures, // TODO: Find a way to dynamically update this possibly? From 862e2d6709be8d652de282260cf40a0baa738ddc Mon Sep 17 00:00:00 2001 From: Radical Date: Tue, 27 May 2025 13:59:06 +0000 Subject: [PATCH 40/92] feat: add mail client Untested --- Cargo.toml | 1 + src/api/v1/me/mod.rs | 6 +---- src/config.rs | 24 ++++++++++++++++++ src/error.rs | 5 ++++ src/main.rs | 7 ++++++ src/structs.rs | 58 ++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 96 insertions(+), 5 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 7a062d8..af5b2ff 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,6 +37,7 @@ diesel-async = { version = "0.5", features = ["deadpool", "postgres", "async-con diesel_migrations = { version = "2.2.0", features = ["postgres"] } thiserror = "2.0.12" actix-multipart = "0.7.2" +lettre = { version = "0.11.16", features = ["tokio1", "tokio1-native-tls"] } [dependencies.tokio] version = "1.44" diff --git a/src/api/v1/me/mod.rs b/src/api/v1/me/mod.rs index 14067c3..58eaa02 100644 --- a/src/api/v1/me/mod.rs +++ b/src/api/v1/me/mod.rs @@ -33,7 +33,7 @@ pub async fn get(req: HttpRequest, data: web::Data) -> Result, display_name: Option, - password: Option, + //password: Option, will probably be handled through a reset password link email: Option, } @@ -83,10 +83,6 @@ pub async fn update( todo!(); } - if let Some(password) = &new_info.password { - todo!(); - } - if let Some(email) = &new_info.email { todo!(); } diff --git a/src/config.rs b/src/config.rs index 079ce35..4d2e96b 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,5 +1,6 @@ use crate::error::Error; use bunny_api_tokio::edge_storage::Endpoint; +use lettre::transport::smtp::authentication::Credentials; use log::debug; use serde::Deserialize; use tokio::fs::read_to_string; @@ -12,6 +13,7 @@ pub struct ConfigBuilder { web: Option, instance: Option, bunny: BunnyBuilder, + mail: Mail, } #[derive(Debug, Deserialize, Clone)] @@ -52,6 +54,20 @@ struct BunnyBuilder { cdn_url: Url, } +#[derive(Debug, Deserialize, Clone)] +pub struct Mail { + pub smtp: Smtp, + pub from: String, + pub tls: String, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct Smtp { + pub server: String, + username: String, + password: String, +} + impl ConfigBuilder { pub async fn load(path: String) -> Result { debug!("loading config from: {}", path); @@ -101,6 +117,7 @@ impl ConfigBuilder { web, instance: self.instance.unwrap_or(Instance { registration: true }), bunny, + mail: self.mail, } } } @@ -112,6 +129,7 @@ pub struct Config { pub web: Web, pub instance: Instance, pub bunny: Bunny, + pub mail: Mail, } #[derive(Debug, Clone)] @@ -179,3 +197,9 @@ impl CacheDatabase { url } } + +impl Smtp { + pub fn credentials(&self) -> Credentials { + Credentials::new(self.username.clone(), self.password.clone()) + } +} diff --git a/src/error.rs b/src/error.rs index fb990c7..ca05c98 100644 --- a/src/error.rs +++ b/src/error.rs @@ -19,6 +19,7 @@ use serde_json::Error as JsonError; use thiserror::Error; use tokio::task::JoinError; use toml::de::Error as TomlError; +use lettre::{address::AddressError, transport::smtp::Error as SmtpError}; #[derive(Debug, Error)] pub enum Error { @@ -54,6 +55,10 @@ pub enum Error { PayloadError(#[from] PayloadError), #[error(transparent)] WsClosed(#[from] actix_ws::Closed), + #[error(transparent)] + SmtpError(#[from] SmtpError), + #[error(transparent)] + SmtpAddressError(#[from] AddressError), #[error("{0}")] PasswordHashError(String), #[error("{0}")] diff --git a/src/main.rs b/src/main.rs index 5ad1dc8..8bc1c68 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,6 +6,7 @@ use diesel_async::pooled_connection::AsyncDieselConnectionManager; use diesel_async::pooled_connection::deadpool::Pool; use error::Error; use simple_logger::SimpleLogger; +use structs::MailClient; use std::time::SystemTime; mod config; use config::{Config, ConfigBuilder}; @@ -40,6 +41,7 @@ pub struct Data { pub argon2: Argon2<'static>, pub start_time: SystemTime, pub bunny_cdn: bunny_api_tokio::Client, + pub mail_client: MailClient, } #[tokio::main] @@ -72,6 +74,10 @@ async fn main() -> Result<(), Error> { .init(bunny.api_key, bunny.endpoint, bunny.storage_zone) .await?; + let mail = config.mail.clone(); + + let mail_client = MailClient::new(mail.smtp.credentials(), mail.smtp.server, mail.from, mail.tls)?; + let database_url = config.database.url(); tokio::task::spawn_blocking(move || { @@ -112,6 +118,7 @@ async fn main() -> Result<(), Error> { argon2: Argon2::default(), start_time: SystemTime::now(), bunny_cdn, + mail_client, }; HttpServer::new(move || { diff --git a/src/structs.rs b/src/structs.rs index be4bd43..541d6ec 100644 --- a/src/structs.rs +++ b/src/structs.rs @@ -5,6 +5,8 @@ use diesel::{ update, }; use diesel_async::{RunQueryDsl, pooled_connection::AsyncDieselConnectionManager}; +use lettre::{message::{Mailbox, MessageBuilder as EmailBuilder}, transport::smtp::authentication::Credentials, AsyncSmtpTransport, AsyncTransport, Message as Email, Tokio1Executor}; +use log::debug; use serde::{Deserialize, Serialize}; use tokio::task; use url::Url; @@ -35,6 +37,62 @@ fn load_or_empty( } } +#[derive(PartialEq, Eq, Clone)] +pub enum MailTls { + StartTls, + Tls, +} + +impl From for MailTls { + fn from(value: String) -> Self { + match &*value.to_lowercase() { + "starttls" => Self::StartTls, + _ => Self::Tls, + } + } +} + +#[derive(Clone)] +pub struct MailClient { + creds: Credentials, + smtp_server: String, + mbox: Mailbox, + tls: MailTls, +} + +impl MailClient { + pub fn new>(creds: Credentials, smtp_server: String, mbox: String, tls: T) -> Result { + Ok(Self { + creds, + smtp_server, + mbox: mbox.parse()?, + tls: tls.into(), + }) + } + + pub async fn message_builder(&self) -> EmailBuilder { + Email::builder() + .from(self.mbox.clone()) + } + + pub async fn send_mail(&self, email: Email) -> Result<(), Error> { + let mailer: AsyncSmtpTransport = match self.tls { + MailTls::StartTls => AsyncSmtpTransport::::starttls_relay(&self.smtp_server)? + .credentials(self.creds.clone()) + .build(), + MailTls::Tls => AsyncSmtpTransport::::relay(&self.smtp_server)? + .credentials(self.creds.clone()) + .build(), + }; + + let response = mailer.send(email).await?; + + debug!("mail sending response: {:?}", response); + + Ok(()) + } +} + #[derive(Queryable, Selectable, Insertable, Clone, Debug)] #[diesel(table_name = channels)] #[diesel(check_for_backend(diesel::pg::Pg))] From 83f031779f5b307ccd8e653ac79ff05655f94a9a Mon Sep 17 00:00:00 2001 From: Radical Date: Tue, 27 May 2025 21:57:08 +0200 Subject: [PATCH 41/92] feat: add email verification system Co-Authored-By: JustTemmie --- Cargo.toml | 7 +- Dockerfile | 10 +- compose.dev.yml | 6 + compose.yml | 6 + entrypoint.sh | 12 ++ .../down.sql | 2 + .../up.sql | 7 ++ src/api/v1/auth/mod.rs | 3 + src/api/v1/auth/verify_email.rs | 103 ++++++++++++++++++ src/config.rs | 24 ++-- src/error.rs | 8 +- src/main.rs | 4 +- src/schema.rs | 11 ++ src/structs.rs | 95 ++++++++++++++-- 14 files changed, 265 insertions(+), 33 deletions(-) create mode 100644 migrations/2025-05-27-162114_create_email_tokens/down.sql create mode 100644 migrations/2025-05-27-162114_create_email_tokens/up.sql create mode 100644 src/api/v1/auth/verify_email.rs diff --git a/Cargo.toml b/Cargo.toml index af5b2ff..492a284 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,20 +25,21 @@ redis = { version = "0.31.0", features= ["tokio-comp"] } tokio-tungstenite = { version = "0.26", features = ["native-tls", "url"] } toml = "0.8" url = { version = "2.5", features = ["serde"] } -uuid = { version = "1.16", features = ["serde", "v7"] } +uuid = { version = "1.17", features = ["serde", "v7"] } random-string = "1.1" actix-ws = "0.3.0" futures-util = "0.3.31" bunny-api-tokio = "0.3.0" bindet = "0.3.2" deadpool = "0.12" -diesel = { version = "2.2", features = ["uuid"] } +diesel = { version = "2.2", features = ["uuid", "chrono"] } 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" lettre = { version = "0.11.16", features = ["tokio1", "tokio1-native-tls"] } +chrono = { version = "0.4.41", features = ["serde"] } [dependencies.tokio] -version = "1.44" +version = "1.45" features = ["full"] diff --git a/Dockerfile b/Dockerfile index 0f07fcb..8ea076f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,7 +18,8 @@ RUN useradd --create-home --home-dir /gorb gorb USER gorb -ENV DATABASE_USERNAME=gorb \ +ENV WEB_URL=https://gorb.app/web/ \ +DATABASE_USERNAME=gorb \ DATABASE_PASSWORD=gorb \ DATABASE=gorb \ DATABASE_HOST=database \ @@ -28,6 +29,11 @@ CACHE_DB_PORT=6379 \ BUNNY_API_KEY=your_storage_zone_password_here \ BUNNY_ENDPOINT=Frankfurt \ BUNNY_ZONE=gorb \ -BUNNY_CDN_URL=https://cdn.gorb.app +BUNNY_CDN_URL=https://cdn.gorb.app \ +MAIL_ADDRESS=Gorb \ +MAIL_TLS=tls \ +SMTP_SERVER=mail.gorb.app \ +SMTP_USERNAME=your_smtp_username \ +SMTP_PASSWORD=your_smtp_password \ ENTRYPOINT ["/usr/bin/entrypoint.sh"] diff --git a/compose.dev.yml b/compose.dev.yml index 3da7c89..e80f2a7 100644 --- a/compose.dev.yml +++ b/compose.dev.yml @@ -18,6 +18,7 @@ services: - gorb-backend:/gorb environment: #- RUST_LOG=debug + - WEB_URL=https://gorb.app/web/ - DATABASE_USERNAME=gorb - DATABASE_PASSWORD=gorb - DATABASE=gorb @@ -27,6 +28,11 @@ services: - BUNNY_ENDPOINT=Frankfurt - BUNNY_ZONE=gorb - BUNNY_CDN_URL=https://cdn.gorb.app + - MAIL_ADDRESS=Gorb + - MAIL_TLS=tls + - SMTP_SERVER=mail.gorb.app + - SMTP_USERNAME=your_smtp_username + - SMTP_PASSWORD=your_smtp_password database: image: postgres:16 restart: always diff --git a/compose.yml b/compose.yml index f87411a..2bc7339 100644 --- a/compose.yml +++ b/compose.yml @@ -16,6 +16,7 @@ services: - gorb-backend:/gorb environment: #- RUST_LOG=debug + - WEB_URL=https://gorb.app/web/ - DATABASE_USERNAME=gorb - DATABASE_PASSWORD=gorb - DATABASE=gorb @@ -25,6 +26,11 @@ services: - BUNNY_ENDPOINT=Frankfurt - BUNNY_ZONE=gorb - BUNNY_CDN_URL=https://cdn.gorb.app + - MAIL_ADDRESS=Gorb + - MAIL_TLS=tls + - SMTP_SERVER=mail.gorb.app + - SMTP_USERNAME=your_smtp_username + - SMTP_PASSWORD=your_smtp_password database: image: postgres:16 restart: always diff --git a/entrypoint.sh b/entrypoint.sh index a29e6bb..9c7a401 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -10,6 +10,9 @@ fi if [ ! -f "/gorb/config/config.toml" ]; then cat > /gorb/config/config.toml < Scope { .service(login::response) .service(refresh::res) .service(revoke::res) + .service(verify_email::get) + .service(verify_email::post) } pub async fn check_access_token(access_token: &str, conn: &mut Conn) -> Result { diff --git a/src/api/v1/auth/verify_email.rs b/src/api/v1/auth/verify_email.rs new file mode 100644 index 0000000..d8df8c3 --- /dev/null +++ b/src/api/v1/auth/verify_email.rs @@ -0,0 +1,103 @@ +//! `/api/v1/auth/verify-email` Endpoints for verifying user emails + +use actix_web::{HttpRequest, HttpResponse, get, post, web}; +use chrono::{Duration, Utc}; +use serde::Deserialize; + +use crate::{ + api::v1::auth::check_access_token, error::Error, structs::{EmailToken, Me}, utils::get_auth_header, Data +}; + +#[derive(Deserialize)] +struct Query { + token: String, +} + +/// `GET /api/v1/auth/verify-email` Verifies user email address +/// +/// requires auth? yes +/// +/// ### Query Parameters +/// token +/// +/// ### Responses +/// 200 Success +/// 410 Token Expired +/// 404 Not Found +/// 401 Unauthorized +/// +#[get("/verify-email")] +pub async fn get( + req: HttpRequest, + query: web::Query, + data: web::Data, +) -> Result { + let headers = req.headers(); + + let auth_header = get_auth_header(headers)?; + + let mut conn = data.pool.get().await?; + + let uuid = check_access_token(auth_header, &mut conn).await?; + + let me = Me::get(&mut conn, uuid).await?; + + let email_token = EmailToken::get(&mut conn, me.uuid).await?; + + if query.token != email_token.token { + return Ok(HttpResponse::Unauthorized().finish()); + } + + if Utc::now().signed_duration_since(email_token.created_at) > Duration::hours(24) { + email_token.delete(&mut conn).await?; + return Ok(HttpResponse::Gone().finish()); + } + + me.verify_email(&mut conn).await?; + + email_token.delete(&mut conn).await?; + + Ok(HttpResponse::Ok().finish()) +} + +/// `POST /api/v1/auth/verify-email` Sends user verification email +/// +/// requires auth? yes +/// +/// ### Responses +/// 200 Email sent +/// 204 Already verified +/// 429 Too Many Requests +/// 401 Unauthorized +/// +#[post("/verify-email")] +pub async fn post( + req: HttpRequest, + data: web::Data, +) -> Result { + let headers = req.headers(); + + let auth_header = get_auth_header(headers)?; + + let mut conn = data.pool.get().await?; + + let uuid = check_access_token(auth_header, &mut conn).await?; + + let me = Me::get(&mut conn, uuid).await?; + + if me.email_verified { + return Ok(HttpResponse::NoContent().finish()) + } + + if let Ok(email_token) = EmailToken::get(&mut conn, me.uuid).await { + if Utc::now().signed_duration_since(email_token.created_at) > Duration::hours(1) { + email_token.delete(&mut conn).await?; + } else { + return Err(Error::TooManyRequests("Please allow 1 hour before sending a new email".to_string())) + } + } + + EmailToken::new(&data, me).await?; + + Ok(HttpResponse::Ok().finish()) +} diff --git a/src/config.rs b/src/config.rs index 4d2e96b..9ffd9c2 100644 --- a/src/config.rs +++ b/src/config.rs @@ -10,7 +10,7 @@ use url::Url; pub struct ConfigBuilder { database: Database, cache_database: CacheDatabase, - web: Option, + web: WebBuilder, instance: Option, bunny: BunnyBuilder, mail: Mail, @@ -36,8 +36,9 @@ pub struct CacheDatabase { #[derive(Debug, Deserialize)] struct WebBuilder { - url: Option, + ip: Option, port: Option, + url: Url, _ssl: Option, } @@ -57,7 +58,7 @@ struct BunnyBuilder { #[derive(Debug, Deserialize, Clone)] pub struct Mail { pub smtp: Smtp, - pub from: String, + pub address: String, pub tls: String, } @@ -79,16 +80,10 @@ impl ConfigBuilder { } pub fn build(self) -> Config { - let web = if let Some(web) = self.web { - Web { - url: web.url.unwrap_or(String::from("0.0.0.0")), - port: web.port.unwrap_or(8080), - } - } else { - Web { - url: String::from("0.0.0.0"), - port: 8080, - } + let web = Web { + ip: self.web.ip.unwrap_or(String::from("0.0.0.0")), + port: self.web.port.unwrap_or(8080), + url: self.web.url, }; let endpoint = match &*self.bunny.endpoint { @@ -134,8 +129,9 @@ pub struct Config { #[derive(Debug, Clone)] pub struct Web { - pub url: String, + pub ip: String, pub port: u16, + pub url: Url, } #[derive(Debug, Clone)] diff --git a/src/error.rs b/src/error.rs index ca05c98..984f57e 100644 --- a/src/error.rs +++ b/src/error.rs @@ -19,7 +19,7 @@ use serde_json::Error as JsonError; use thiserror::Error; use tokio::task::JoinError; use toml::de::Error as TomlError; -use lettre::{address::AddressError, transport::smtp::Error as SmtpError}; +use lettre::{error::Error as EmailError, address::AddressError, transport::smtp::Error as SmtpError}; #[derive(Debug, Error)] pub enum Error { @@ -56,6 +56,8 @@ pub enum Error { #[error(transparent)] WsClosed(#[from] actix_ws::Closed), #[error(transparent)] + EmailError(#[from] EmailError), + #[error(transparent)] SmtpError(#[from] SmtpError), #[error(transparent)] SmtpAddressError(#[from] AddressError), @@ -68,6 +70,8 @@ pub enum Error { #[error("{0}")] Forbidden(String), #[error("{0}")] + TooManyRequests(String), + #[error("{0}")] InternalServerError(String), } @@ -87,6 +91,8 @@ impl ResponseError for Error { Error::BunnyError(BunnyError::NotFound(_)) => StatusCode::NOT_FOUND, Error::BadRequest(_) => StatusCode::BAD_REQUEST, Error::Unauthorized(_) => StatusCode::UNAUTHORIZED, + Error::Forbidden(_) => StatusCode::FORBIDDEN, + Error::TooManyRequests(_) => StatusCode::TOO_MANY_REQUESTS, _ => StatusCode::INTERNAL_SERVER_ERROR, } } diff --git a/src/main.rs b/src/main.rs index 8bc1c68..0f94be8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -76,7 +76,7 @@ async fn main() -> Result<(), Error> { let mail = config.mail.clone(); - let mail_client = MailClient::new(mail.smtp.credentials(), mail.smtp.server, mail.from, mail.tls)?; + let mail_client = MailClient::new(mail.smtp.credentials(), mail.smtp.server, mail.address, mail.tls)?; let database_url = config.database.url(); @@ -152,7 +152,7 @@ async fn main() -> Result<(), Error> { .wrap(cors) .service(api::web()) }) - .bind((web.url, web.port))? + .bind((web.ip, web.port))? .run() .await?; diff --git a/src/schema.rs b/src/schema.rs index cc5e97c..744ce10 100644 --- a/src/schema.rs +++ b/src/schema.rs @@ -31,6 +31,15 @@ diesel::table! { } } +diesel::table! { + email_tokens (token, user_uuid) { + #[max_length = 64] + token -> Varchar, + user_uuid -> Uuid, + created_at -> Timestamptz, + } +} + diesel::table! { guild_members (uuid) { uuid -> Uuid, @@ -133,6 +142,7 @@ diesel::joinable!(access_tokens -> refresh_tokens (refresh_token)); diesel::joinable!(access_tokens -> users (uuid)); diesel::joinable!(channel_permissions -> channels (channel_uuid)); diesel::joinable!(channels -> guilds (guild_uuid)); +diesel::joinable!(email_tokens -> users (user_uuid)); diesel::joinable!(guild_members -> guilds (guild_uuid)); diesel::joinable!(guild_members -> users (user_uuid)); diesel::joinable!(guilds -> users (owner_uuid)); @@ -149,6 +159,7 @@ diesel::allow_tables_to_appear_in_same_query!( access_tokens, channel_permissions, channels, + email_tokens, guild_members, guilds, instance_permissions, diff --git a/src/structs.rs b/src/structs.rs index 541d6ec..50b5ac5 100644 --- a/src/structs.rs +++ b/src/structs.rs @@ -1,11 +1,10 @@ use actix_web::web::BytesMut; +use chrono::Utc; use diesel::{ - ExpressionMethods, QueryDsl, Selectable, SelectableHelper, delete, insert_into, - prelude::{Insertable, Queryable}, - update, + delete, dsl::now, insert_into, prelude::{Insertable, Queryable}, update, ExpressionMethods, QueryDsl, Selectable, SelectableHelper }; use diesel_async::{RunQueryDsl, pooled_connection::AsyncDieselConnectionManager}; -use lettre::{message::{Mailbox, MessageBuilder as EmailBuilder}, transport::smtp::authentication::Credentials, AsyncSmtpTransport, AsyncTransport, Message as Email, Tokio1Executor}; +use lettre::{message::{Mailbox, MessageBuilder as EmailBuilder, MultiPart}, transport::smtp::authentication::Credentials, AsyncSmtpTransport, AsyncTransport, Message as Email, Tokio1Executor}; use log::debug; use serde::{Deserialize, Serialize}; use tokio::task; @@ -13,10 +12,7 @@ use url::Url; use uuid::Uuid; use crate::{ - Conn, Data, - error::Error, - schema::*, - utils::{image_check, order_by_is_above}, + error::Error, schema::*, utils::{generate_refresh_token, image_check, order_by_is_above}, Conn, Data }; pub trait HasUuid { @@ -70,7 +66,7 @@ impl MailClient { }) } - pub async fn message_builder(&self) -> EmailBuilder { + pub fn message_builder(&self) -> EmailBuilder { Email::builder() .from(self.mbox.clone()) } @@ -780,12 +776,12 @@ impl User { #[diesel(table_name = users)] #[diesel(check_for_backend(diesel::pg::Pg))] pub struct Me { - uuid: Uuid, + pub uuid: Uuid, username: String, display_name: Option, avatar: Option, email: String, - email_verified: bool, + pub email_verified: bool, } impl Me { @@ -849,6 +845,17 @@ impl Me { Ok(()) } + + pub async fn verify_email(&self, conn: &mut Conn) -> Result<(), Error> { + use users::dsl; + update(users::table) + .filter(dsl::uuid.eq(self.uuid)) + .set(dsl::email_verified.eq(true)) + .execute(conn) + .await?; + + Ok(()) + } } #[derive(Deserialize)] @@ -856,3 +863,69 @@ pub struct StartAmountQuery { pub start: Option, pub amount: Option, } + +#[derive(Selectable, Queryable)] +#[diesel(table_name = email_tokens)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct EmailToken { + user_uuid: Uuid, + pub token: String, + pub created_at: chrono::DateTime, +} + +impl EmailToken { + pub async fn get(conn: &mut Conn, user_uuid: Uuid) -> Result { + use email_tokens::dsl; + let email_token = dsl::email_tokens + .filter(dsl::user_uuid.eq(user_uuid)) + .select(EmailToken::as_select()) + .get_result(conn) + .await?; + + Ok(email_token) + } + + pub async fn new(data: &Data, me: Me) -> Result<(), Error> { + let token = generate_refresh_token()?; + + let mut conn = data.pool.get().await?; + + use email_tokens::dsl; + insert_into(email_tokens::table) + .values((dsl::user_uuid.eq(me.uuid), dsl::token.eq(&token), dsl::created_at.eq(now))) + .execute(&mut conn) + .await?; + + let mut verify_endpoint = data.config.web.url.join("verify-email")?; + + verify_endpoint.set_query(Some(&format!("token={}", token))); + + let email = data + .mail_client + .message_builder() + .to(me.email.parse()?) + .subject("Gorb E-mail Verification") + .multipart(MultiPart::alternative_plain_html( + format!("Verify your gorb.app account\n\nHello, {}!\nThanks for creating a new account on Gorb.\nThe final step to create your account is to verify your email address by visiting the page, within 24 hours.\n\n{}\n\nIf you didn't ask to verify this address, you can safely ignore this email\n\nThanks, The gorb team.", me.username, verify_endpoint), + format!(r#"

Verify your {} Account

Hello, {}!

Thanks for creating a new account on Gorb.

The final step to create your account is to verify your email address by clicking the button below, within 24 hours.

VERIFY ACCOUNT

If you didn't ask to verify this address, you can safely ignore this email.

"#, data.config.web.url.domain().unwrap(), me.username, verify_endpoint) + ))?; + + data + .mail_client + .send_mail(email) + .await?; + + Ok(()) + } + + pub async fn delete(&self, conn: &mut Conn) -> Result<(), Error> { + use email_tokens::dsl; + delete(email_tokens::table) + .filter(dsl::user_uuid.eq(self.user_uuid)) + .filter(dsl::token.eq(&self.token)) + .execute(conn) + .await?; + + Ok(()) + } +} From 1ff3fa69a7f5f953ca9fa81ec7d43a5635cb6693 Mon Sep 17 00:00:00 2001 From: Radical Date: Tue, 27 May 2025 22:13:15 +0200 Subject: [PATCH 42/92] ci: automatically create docs --- .woodpecker/publish-docs.yml | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 .woodpecker/publish-docs.yml diff --git a/.woodpecker/publish-docs.yml b/.woodpecker/publish-docs.yml new file mode 100644 index 0000000..e6ce482 --- /dev/null +++ b/.woodpecker/publish-docs.yml @@ -0,0 +1,19 @@ +when: + - event: push + branch: main + +steps: + - name: build-docs + image: rust:bookworm + commands: + - cargo doc --release --no-deps + + - name: publish-docs + image: debian:12 + commands: + - apt update -y && apt install -y rsync openssh-client + - printf "Host *\n StrictHostKeyChecking no" >> /etc/ssh/ssh_config + - ssh-agent bash -c "ssh-add <(echo '$KEY' | base64 -d) && rsync --archive --verbose --compress --hard-links --delete-during --partial --progress ./target/doc/ root@gorb.app:/var/www/docs.gorb.app/api && ssh root@gorb.app systemctl reload caddy.service" + environment: + KEY: + from_secret: ssh_key From 9728769b8c71c07ae5d5cf6f5ac4d61676b33217 Mon Sep 17 00:00:00 2001 From: Radical Date: Wed, 28 May 2025 17:36:23 +0200 Subject: [PATCH 43/92] feat: add changing username, email and display_name to /me endpoint --- src/api/v1/auth/login.rs | 6 ++-- src/api/v1/auth/mod.rs | 9 ----- src/api/v1/auth/register.rs | 12 ++++--- src/api/v1/me/mod.rs | 12 +++---- src/structs.rs | 66 +++++++++++++++++++++++++++++++++---- src/utils.rs | 19 ++++++++++- 6 files changed, 95 insertions(+), 29 deletions(-) diff --git a/src/api/v1/auth/login.rs b/src/api/v1/auth/login.rs index c3e8bc7..81ef117 100644 --- a/src/api/v1/auth/login.rs +++ b/src/api/v1/auth/login.rs @@ -9,10 +9,12 @@ use uuid::Uuid; use crate::{ Data, - api::v1::auth::{EMAIL_REGEX, PASSWORD_REGEX, USERNAME_REGEX}, error::Error, schema::*, - utils::{generate_access_token, generate_refresh_token, refresh_token_cookie}, + utils::{ + EMAIL_REGEX, PASSWORD_REGEX, USERNAME_REGEX, generate_access_token, generate_refresh_token, + refresh_token_cookie, + }, }; use super::Response; diff --git a/src/api/v1/auth/mod.rs b/src/api/v1/auth/mod.rs index 2bc0d0b..216e216 100644 --- a/src/api/v1/auth/mod.rs +++ b/src/api/v1/auth/mod.rs @@ -22,15 +22,6 @@ struct Response { access_token: String, } -static EMAIL_REGEX: LazyLock = LazyLock::new(|| { - Regex::new(r"[-A-Za-z0-9!#$%&'*+/=?^_`{|}~]+(?:\.[-A-Za-z0-9!#$%&'*+/=?^_`{|}~]+)*@(?:[A-Za-z0-9](?:[-A-Za-z0-9]*[A-Za-z0-9])?\.)+[A-Za-z0-9](?:[-A-Za-z0-9]*[A-Za-z0-9])?").unwrap() -}); - -static USERNAME_REGEX: LazyLock = LazyLock::new(|| Regex::new(r"^[a-z0-9_.-]+$").unwrap()); - -// Password is expected to be hashed using SHA3-384 -static PASSWORD_REGEX: LazyLock = LazyLock::new(|| Regex::new(r"[0-9a-f]{96}").unwrap()); - pub fn web() -> Scope { web::scope("/auth") .service(register::res) diff --git a/src/api/v1/auth/register.rs b/src/api/v1/auth/register.rs index 66be90a..0e170ee 100644 --- a/src/api/v1/auth/register.rs +++ b/src/api/v1/auth/register.rs @@ -13,14 +13,16 @@ use uuid::Uuid; use super::Response; use crate::{ Data, - api::v1::auth::{EMAIL_REGEX, PASSWORD_REGEX, USERNAME_REGEX}, error::Error, schema::{ access_tokens::{self, dsl as adsl}, refresh_tokens::{self, dsl as rdsl}, users::{self, dsl as udsl}, }, - utils::{generate_access_token, generate_refresh_token, refresh_token_cookie}, + utils::{ + EMAIL_REGEX, PASSWORD_REGEX, USERNAME_REGEX, generate_access_token, generate_refresh_token, + refresh_token_cookie, + }, }; #[derive(Deserialize)] @@ -68,9 +70,11 @@ pub async fn res( data: web::Data, ) -> Result { if !data.config.instance.registration { - return Err(Error::Forbidden("registration is disabled on this instance".to_string())) + return Err(Error::Forbidden( + "registration is disabled on this instance".to_string(), + )); } - + let uuid = Uuid::now_v7(); if !EMAIL_REGEX.is_match(&account_information.email) { diff --git a/src/api/v1/me/mod.rs b/src/api/v1/me/mod.rs index 14067c3..6764875 100644 --- a/src/api/v1/me/mod.rs +++ b/src/api/v1/me/mod.rs @@ -1,5 +1,5 @@ use actix_multipart::form::{MultipartForm, json::Json as MpJson, tempfile::TempFile}; -use actix_web::{get, patch, web, HttpRequest, HttpResponse, Scope}; +use actix_web::{HttpRequest, HttpResponse, Scope, get, patch, web}; use serde::Deserialize; use crate::{ @@ -9,9 +9,7 @@ use crate::{ mod servers; pub fn web() -> Scope { - web::scope("/me") - .service(get) - .service(update) + web::scope("/me").service(get).service(update) } #[get("")] @@ -76,11 +74,11 @@ pub async fn update( if let Some(new_info) = form.json { if let Some(username) = &new_info.username { - todo!(); + me.set_username(&mut conn, username.clone()).await?; } if let Some(display_name) = &new_info.display_name { - todo!(); + me.set_display_name(&mut conn, display_name.clone()).await?; } if let Some(password) = &new_info.password { @@ -88,7 +86,7 @@ pub async fn update( } if let Some(email) = &new_info.email { - todo!(); + me.set_email(&mut conn, email.to_string()).await?; } } diff --git a/src/structs.rs b/src/structs.rs index be4bd43..b5bfa5d 100644 --- a/src/structs.rs +++ b/src/structs.rs @@ -14,7 +14,7 @@ use crate::{ Conn, Data, error::Error, schema::*, - utils::{image_check, order_by_is_above}, + utils::{EMAIL_REGEX, USERNAME_REGEX, image_check, order_by_is_above}, }; pub trait HasUuid { @@ -369,11 +369,7 @@ impl Guild { futures::future::try_join_all(guild_futures).await } - pub async fn new( - conn: &mut Conn, - name: String, - owner_uuid: Uuid, - ) -> Result { + pub async fn new(conn: &mut Conn, name: String, owner_uuid: Uuid) -> Result { let guild_uuid = Uuid::now_v7(); let guild_builder = GuildBuilder { @@ -791,6 +787,64 @@ impl Me { Ok(()) } + + pub async fn set_username( + &mut self, + conn: &mut Conn, + new_username: String, + ) -> Result<(), Error> { + if !USERNAME_REGEX.is_match(&new_username) { + return Err(Error::BadRequest("Invalid username".to_string())); + } + + use users::dsl; + update(users::table) + .filter(dsl::uuid.eq(self.uuid)) + .set(dsl::username.eq(new_username.as_str())) + .execute(conn) + .await?; + + self.username = new_username; + + Ok(()) + } + + pub async fn set_display_name( + &mut self, + conn: &mut Conn, + new_display_name: String, + ) -> Result<(), Error> { + use users::dsl; + update(users::table) + .filter(dsl::uuid.eq(self.uuid)) + .set(dsl::display_name.eq(new_display_name.as_str())) + .execute(conn) + .await?; + + self.display_name = Some(new_display_name); + + Ok(()) + } + + pub async fn set_email(&mut self, conn: &mut Conn, new_email: String) -> Result<(), Error> { + if !EMAIL_REGEX.is_match(&new_email) { + return Err(Error::BadRequest("Invalid username".to_string())); + } + + use users::dsl; + update(users::table) + .filter(dsl::uuid.eq(self.uuid)) + .set(( + dsl::email.eq(new_email.as_str()), + dsl::email_verified.eq(false), + )) + .execute(conn) + .await?; + + self.email = new_email; + + Ok(()) + } } #[derive(Deserialize)] diff --git a/src/utils.rs b/src/utils.rs index 3edaab8..143b544 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,3 +1,5 @@ +use std::sync::LazyLock; + use actix_web::{ cookie::{Cookie, SameSite, time::Duration}, http::header::HeaderMap, @@ -7,9 +9,24 @@ use bindet::FileType; use getrandom::fill; use hex::encode; use redis::RedisError; +use regex::Regex; use serde::Serialize; -use crate::{error::Error, structs::{HasIsAbove, HasUuid}, Data}; +use crate::{ + Data, + error::Error, + structs::{HasIsAbove, HasUuid}, +}; + +pub static EMAIL_REGEX: LazyLock = LazyLock::new(|| { + Regex::new(r"[-A-Za-z0-9!#$%&'*+/=?^_`{|}~]+(?:\.[-A-Za-z0-9!#$%&'*+/=?^_`{|}~]+)*@(?:[A-Za-z0-9](?:[-A-Za-z0-9]*[A-Za-z0-9])?\.)+[A-Za-z0-9](?:[-A-Za-z0-9]*[A-Za-z0-9])?").unwrap() +}); + +pub static USERNAME_REGEX: LazyLock = + LazyLock::new(|| Regex::new(r"^[a-z0-9_.-]+$").unwrap()); + +// Password is expected to be hashed using SHA3-384 +pub static PASSWORD_REGEX: LazyLock = LazyLock::new(|| Regex::new(r"[0-9a-f]{96}").unwrap()); pub fn get_auth_header(headers: &HeaderMap) -> Result<&str, Error> { let auth_token = headers.get(actix_web::http::header::AUTHORIZATION); From 501141b584e1ed9627fa6ff9fc3158c0f61de309 Mon Sep 17 00:00:00 2001 From: Radical Date: Wed, 28 May 2025 23:13:41 +0200 Subject: [PATCH 44/92] feat: add password reset --- .../down.sql | 2 + .../up.sql | 7 + src/api/v1/auth/login.rs | 59 ++----- src/api/v1/auth/mod.rs | 9 +- src/api/v1/auth/reset_password.rs | 90 +++++++++++ src/schema.rs | 11 ++ src/structs.rs | 153 +++++++++++++++++- src/utils.rs | 31 +++- 8 files changed, 301 insertions(+), 61 deletions(-) create mode 100644 migrations/2025-05-28-175918_create_password_reset_tokens/down.sql create mode 100644 migrations/2025-05-28-175918_create_password_reset_tokens/up.sql create mode 100644 src/api/v1/auth/reset_password.rs diff --git a/migrations/2025-05-28-175918_create_password_reset_tokens/down.sql b/migrations/2025-05-28-175918_create_password_reset_tokens/down.sql new file mode 100644 index 0000000..dcccc77 --- /dev/null +++ b/migrations/2025-05-28-175918_create_password_reset_tokens/down.sql @@ -0,0 +1,2 @@ +-- This file should undo anything in `up.sql` +DROP TABLE password_reset_tokens; diff --git a/migrations/2025-05-28-175918_create_password_reset_tokens/up.sql b/migrations/2025-05-28-175918_create_password_reset_tokens/up.sql new file mode 100644 index 0000000..f788b77 --- /dev/null +++ b/migrations/2025-05-28-175918_create_password_reset_tokens/up.sql @@ -0,0 +1,7 @@ +-- Your SQL goes here +CREATE TABLE password_reset_tokens ( + token VARCHAR(64) NOT NULL, + user_uuid uuid UNIQUE NOT NULL REFERENCES users(uuid), + created_at TIMESTAMPTZ NOT NULL, + PRIMARY KEY (token, user_uuid) +); diff --git a/src/api/v1/auth/login.rs b/src/api/v1/auth/login.rs index 81ef117..254b913 100644 --- a/src/api/v1/auth/login.rs +++ b/src/api/v1/auth/login.rs @@ -5,15 +5,14 @@ use argon2::{PasswordHash, PasswordVerifier}; use diesel::{ExpressionMethods, QueryDsl, dsl::insert_into}; use diesel_async::RunQueryDsl; use serde::Deserialize; -use uuid::Uuid; use crate::{ Data, error::Error, schema::*, utils::{ - EMAIL_REGEX, PASSWORD_REGEX, USERNAME_REGEX, generate_access_token, generate_refresh_token, - refresh_token_cookie, + PASSWORD_REGEX, generate_access_token, generate_refresh_token, + refresh_token_cookie, user_uuid_from_identifier }, }; @@ -39,58 +38,20 @@ pub async fn response( let mut conn = data.pool.get().await?; - if EMAIL_REGEX.is_match(&login_information.username) { - // FIXME: error handling, right now i just want this to work - let (uuid, password): (Uuid, String) = dsl::users - .filter(dsl::email.eq(&login_information.username)) - .select((dsl::uuid, dsl::password)) - .get_result(&mut conn) - .await?; + let uuid = user_uuid_from_identifier(&mut conn, &login_information.username).await?; - return login( - data.clone(), - uuid, - login_information.password.clone(), - password, - login_information.device_name.clone(), - ) - .await; - } else if USERNAME_REGEX.is_match(&login_information.username) { - // FIXME: error handling, right now i just want this to work - let (uuid, password): (Uuid, String) = dsl::users - .filter(dsl::username.eq(&login_information.username)) - .select((dsl::uuid, dsl::password)) - .get_result(&mut conn) - .await?; - - return login( - data.clone(), - uuid, - login_information.password.clone(), - password, - login_information.device_name.clone(), - ) - .await; - } - - Ok(HttpResponse::Unauthorized().finish()) -} - -async fn login( - data: actix_web::web::Data, - uuid: Uuid, - request_password: String, - database_password: String, - device_name: String, -) -> Result { - let mut conn = data.pool.get().await?; + let database_password: String = dsl::users + .filter(dsl::uuid.eq(uuid)) + .select(dsl::password) + .get_result(&mut conn) + .await?; let parsed_hash = PasswordHash::new(&database_password) .map_err(|e| Error::PasswordHashError(e.to_string()))?; if data .argon2 - .verify_password(request_password.as_bytes(), &parsed_hash) + .verify_password(login_information.password.as_bytes(), &parsed_hash) .is_err() { return Err(Error::Unauthorized( @@ -110,7 +71,7 @@ async fn login( rdsl::token.eq(&refresh_token), rdsl::uuid.eq(uuid), rdsl::created_at.eq(current_time), - rdsl::device_name.eq(device_name), + rdsl::device_name.eq(&login_information.device_name), )) .execute(&mut conn) .await?; diff --git a/src/api/v1/auth/mod.rs b/src/api/v1/auth/mod.rs index 1689d2b..cabe114 100644 --- a/src/api/v1/auth/mod.rs +++ b/src/api/v1/auth/mod.rs @@ -1,12 +1,8 @@ -use std::{ - sync::LazyLock, - time::{SystemTime, UNIX_EPOCH}, -}; +use std::time::{SystemTime, UNIX_EPOCH}; use actix_web::{Scope, web}; use diesel::{ExpressionMethods, QueryDsl}; use diesel_async::RunQueryDsl; -use regex::Regex; use serde::Serialize; use uuid::Uuid; @@ -17,6 +13,7 @@ mod refresh; mod register; mod revoke; mod verify_email; +mod reset_password; #[derive(Serialize)] struct Response { @@ -31,6 +28,8 @@ pub fn web() -> Scope { .service(revoke::res) .service(verify_email::get) .service(verify_email::post) + .service(reset_password::get) + .service(reset_password::post) } pub async fn check_access_token(access_token: &str, conn: &mut Conn) -> Result { diff --git a/src/api/v1/auth/reset_password.rs b/src/api/v1/auth/reset_password.rs new file mode 100644 index 0000000..6c6dee7 --- /dev/null +++ b/src/api/v1/auth/reset_password.rs @@ -0,0 +1,90 @@ +//! `/api/v1/auth/reset-password` Endpoints for resetting user password + +use actix_web::{HttpResponse, get, post, web}; +use chrono::{Duration, Utc}; +use serde::Deserialize; + +use crate::{ + error::Error, structs::PasswordResetToken, Data +}; + +#[derive(Deserialize)] +struct Query { + identifier: String, +} + +/// `GET /api/v1/auth/reset-password` Sends password reset email to user +/// +/// requires auth? no +/// +/// ### Query Parameters +/// identifier: Email or username +/// +/// ### Responses +/// 200 Email sent +/// 429 Too Many Requests +/// 404 Not found +/// 400 Bad request +/// +#[get("/reset-password")] +pub async fn get( + query: web::Query, + data: web::Data, +) -> Result { + let mut conn = data.pool.get().await?; + + if let Ok(password_reset_token) = PasswordResetToken::get_with_identifier(&mut conn, query.identifier.clone()).await { + if Utc::now().signed_duration_since(password_reset_token.created_at) > Duration::hours(1) { + password_reset_token.delete(&mut conn).await?; + } else { + return Err(Error::TooManyRequests("Please allow 1 hour before sending a new email".to_string())) + } + } + + PasswordResetToken::new(&data, query.identifier.clone()).await?; + + Ok(HttpResponse::Ok().finish()) +} + +#[derive(Deserialize)] +struct ResetPassword { + password: String, + token: String, +} + +/// `POST /api/v1/auth/reset-password` Resets user password +/// +/// requires auth? no +/// +/// ### Request Example: +/// ``` +/// json!({ +/// "password": "1608c17a27f6ae3891c23d680c73ae91528f20a54dcf4973e2c3126b9734f48b7253047f2395b51bb8a44a6daa188003", +/// "token": "a3f7e29c1b8d0456e2c9f83b7a1d6e4f5028c3b9a7e1f2d5c6b8a0d3e7f4a2b" +/// }); +/// ``` +/// +/// ### Responses +/// 200 Success +/// 410 Token Expired +/// 404 Not Found +/// 400 Bad Request +/// +#[post("/reset-password")] +pub async fn post( + reset_password: web::Json, + data: web::Data, +) -> Result { + let mut conn = data.pool.get().await?; + + let password_reset_token = PasswordResetToken::get(&mut conn, reset_password.token.clone()).await?; + + if Utc::now().signed_duration_since(password_reset_token.created_at) > Duration::hours(24) { + password_reset_token.delete(&mut conn).await?; + return Ok(HttpResponse::Gone().finish()); + } + + password_reset_token.set_password(&data, reset_password.password.clone()).await?; + + Ok(HttpResponse::Ok().finish()) +} diff --git a/src/schema.rs b/src/schema.rs index 744ce10..1b34400 100644 --- a/src/schema.rs +++ b/src/schema.rs @@ -89,6 +89,15 @@ diesel::table! { } } +diesel::table! { + password_reset_tokens (token, user_uuid) { + #[max_length = 64] + token -> Varchar, + user_uuid -> Uuid, + created_at -> Timestamptz, + } +} + diesel::table! { refresh_tokens (token) { #[max_length = 64] @@ -151,6 +160,7 @@ diesel::joinable!(invites -> guilds (guild_uuid)); diesel::joinable!(invites -> users (user_uuid)); diesel::joinable!(messages -> channels (channel_uuid)); diesel::joinable!(messages -> users (user_uuid)); +diesel::joinable!(password_reset_tokens -> users (user_uuid)); diesel::joinable!(refresh_tokens -> users (uuid)); diesel::joinable!(role_members -> guild_members (member_uuid)); diesel::joinable!(roles -> guilds (guild_uuid)); @@ -165,6 +175,7 @@ diesel::allow_tables_to_appear_in_same_query!( instance_permissions, invites, messages, + password_reset_tokens, refresh_tokens, role_members, roles, diff --git a/src/structs.rs b/src/structs.rs index e4d2610..e8002bf 100644 --- a/src/structs.rs +++ b/src/structs.rs @@ -10,12 +10,13 @@ use serde::{Deserialize, Serialize}; use tokio::task; use url::Url; use uuid::Uuid; +use argon2::{ + PasswordHasher, + password_hash::{SaltString, rand_core::OsRng}, +}; use crate::{ - Conn, Data, - error::Error, - schema::*, - utils::{EMAIL_REGEX, USERNAME_REGEX, generate_refresh_token, image_check, order_by_is_above}, + error::Error, schema::*, utils::{generate_refresh_token, image_check, order_by_is_above, user_uuid_from_identifier, EMAIL_REGEX, PASSWORD_REGEX, USERNAME_REGEX}, Conn, Data }; pub trait HasUuid { @@ -986,3 +987,147 @@ impl EmailToken { Ok(()) } } + +#[derive(Selectable, Queryable)] +#[diesel(table_name = password_reset_tokens)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct PasswordResetToken { + user_uuid: Uuid, + pub token: String, + pub created_at: chrono::DateTime, +} + +impl PasswordResetToken { + pub async fn get(conn: &mut Conn, token: String) -> Result { + use password_reset_tokens::dsl; + let password_reset_token = dsl::password_reset_tokens + .filter(dsl::token.eq(token)) + .select(PasswordResetToken::as_select()) + .get_result(conn) + .await?; + + Ok(password_reset_token) + } + + pub async fn get_with_identifier(conn: &mut Conn, identifier: String) -> Result { + let user_uuid = user_uuid_from_identifier(conn, &identifier).await?; + + use password_reset_tokens::dsl; + let password_reset_token = dsl::password_reset_tokens + .filter(dsl::user_uuid.eq(user_uuid)) + .select(PasswordResetToken::as_select()) + .get_result(conn) + .await?; + + Ok(password_reset_token) + } + + pub async fn new(data: &Data, identifier: String) -> Result<(), Error> { + let token = generate_refresh_token()?; + + let mut conn = data.pool.get().await?; + + let user_uuid = user_uuid_from_identifier(&mut conn, &identifier).await?; + + use users::dsl as udsl; + let (username, email_address): (String, String) = udsl::users + .filter(udsl::uuid.eq(user_uuid)) + .select((udsl::username, udsl::email)) + .get_result(&mut conn) + .await?; + + use password_reset_tokens::dsl; + insert_into(password_reset_tokens::table) + .values((dsl::user_uuid.eq(user_uuid), dsl::token.eq(&token), dsl::created_at.eq(now))) + .execute(&mut conn) + .await?; + + let mut reset_endpoint = data.config.web.url.join("reset-password")?; + + reset_endpoint.set_query(Some(&format!("token={}", token))); + + //TODO: Correct this email + let email = data + .mail_client + .message_builder() + .to(email_address.parse()?) + // twig: change this + .subject("Gorb E-mail Verification") + .multipart(MultiPart::alternative_plain_html( + // twig: change this line + format!("Verify your gorb.app account\n\nHello, {}!\nThanks for creating a new account on Gorb.\nThe final step to create your account is to verify your email address by visiting the page, within 24 hours.\n\n{}\n\nIf you didn't ask to verify this address, you can safely ignore this email\n\nThanks, The gorb team.", username, reset_endpoint), + // twig: and this one + format!(r#"

Verify your {} Account

Hello, {}!

Thanks for creating a new account on Gorb.

The final step to create your account is to verify your email address by clicking the button below, within 24 hours.

VERIFY ACCOUNT

If you didn't ask to verify this address, you can safely ignore this email.

"#, data.config.web.url.domain().unwrap(), username, reset_endpoint) + ))?; + + data + .mail_client + .send_mail(email) + .await?; + + Ok(()) + } + + pub async fn set_password(&self, data: &Data, password: String) -> Result<(), Error> { + if !PASSWORD_REGEX.is_match(&password) { + return Err(Error::BadRequest("Please provide a valid password".to_string())) + } + + let salt = SaltString::generate(&mut OsRng); + + let hashed_password = data + .argon2 + .hash_password(password.as_bytes(), &salt) + .map_err(|e| Error::PasswordHashError(e.to_string()))?; + + let mut conn = data.pool.get().await?; + + use users::dsl; + update(users::table) + .filter(dsl::uuid.eq(self.user_uuid)) + .set(dsl::password.eq(hashed_password.to_string())) + .execute(&mut conn) + .await?; + + let (username, email_address): (String, String) = dsl::users + .filter(dsl::uuid.eq(self.user_uuid)) + .select((dsl::username, dsl::email)) + .get_result(&mut conn) + .await?; + + let login_page = data.config.web.url.join("login")?; + + //TODO: Correct this email + let email = data + .mail_client + .message_builder() + .to(email_address.parse()?) + // twig: change this (post password change email) + .subject("Gorb E-mail Verification") + .multipart(MultiPart::alternative_plain_html( + // twig: change this line + format!("Verify your gorb.app account\n\nHello, {}!\nThanks for creating a new account on Gorb.\nThe final step to create your account is to verify your email address by visiting the page, within 24 hours.\n\n{}\n\nIf you didn't ask to verify this address, you can safely ignore this email\n\nThanks, The gorb team.", username, login_page), + // twig: and this one + format!(r#"

Verify your {} Account

Hello, {}!

Thanks for creating a new account on Gorb.

The final step to create your account is to verify your email address by clicking the button below, within 24 hours.

VERIFY ACCOUNT

If you didn't ask to verify this address, you can safely ignore this email.

"#, data.config.web.url.domain().unwrap(), username, login_page) + ))?; + + data + .mail_client + .send_mail(email) + .await?; + + self.delete(&mut conn).await + } + + pub async fn delete(&self, conn: &mut Conn) -> Result<(), Error> { + use password_reset_tokens::dsl; + delete(password_reset_tokens::table) + .filter(dsl::user_uuid.eq(self.user_uuid)) + .filter(dsl::token.eq(&self.token)) + .execute(conn) + .await?; + + Ok(()) + } +} + diff --git a/src/utils.rs b/src/utils.rs index 143b544..1d39fdb 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -6,16 +6,17 @@ use actix_web::{ web::BytesMut, }; use bindet::FileType; +use diesel::{ExpressionMethods, QueryDsl}; +use diesel_async::RunQueryDsl; use getrandom::fill; use hex::encode; use redis::RedisError; use regex::Regex; use serde::Serialize; +use uuid::Uuid; use crate::{ - Data, - error::Error, - structs::{HasIsAbove, HasUuid}, + error::Error, schema::users, structs::{HasIsAbove, HasUuid}, Conn, Data }; pub static EMAIL_REGEX: LazyLock = LazyLock::new(|| { @@ -136,6 +137,30 @@ pub fn image_check(icon: BytesMut) -> Result { )) } +pub async fn user_uuid_from_identifier(conn: &mut Conn, identifier: &String) -> Result { + if EMAIL_REGEX.is_match(identifier) { + use users::dsl; + let user_uuid = dsl::users + .filter(dsl::email.eq(identifier)) + .select(dsl::uuid) + .get_result(conn) + .await?; + + Ok(user_uuid) + } else if USERNAME_REGEX.is_match(identifier) { + use users::dsl; + let user_uuid = dsl::users + .filter(dsl::username.eq(identifier)) + .select(dsl::uuid) + .get_result(conn) + .await?; + + Ok(user_uuid) + } else { + Err(Error::BadRequest("Please provide a valid username or email".to_string())) + } +} + pub async fn order_by_is_above(mut items: Vec) -> Result, Error> where T: HasUuid + HasIsAbove, From cf2398ed66bf2f148154d9b653d856ad98a63acf Mon Sep 17 00:00:00 2001 From: JustTemmie <47639983+JustTemmie@users.noreply.github.com> Date: Wed, 28 May 2025 23:36:18 +0200 Subject: [PATCH 45/92] fix: fix incorrect email templates --- src/structs.rs | 24 ++++++++---------------- 1 file changed, 8 insertions(+), 16 deletions(-) diff --git a/src/structs.rs b/src/structs.rs index e8002bf..019d553 100644 --- a/src/structs.rs +++ b/src/structs.rs @@ -962,9 +962,9 @@ impl EmailToken { .mail_client .message_builder() .to(me.email.parse()?) - .subject("Gorb E-mail Verification") + .subject(format!("{} E-mail Verification", data.config.web.url.domain().unwrap())) .multipart(MultiPart::alternative_plain_html( - format!("Verify your gorb.app account\n\nHello, {}!\nThanks for creating a new account on Gorb.\nThe final step to create your account is to verify your email address by visiting the page, within 24 hours.\n\n{}\n\nIf you didn't ask to verify this address, you can safely ignore this email\n\nThanks, The gorb team.", me.username, verify_endpoint), + format!("Verify your {} account\n\nHello, {}!\nThanks for creating a new account on Gorb.\nThe final step to create your account is to verify your email address by visiting the page, within 24 hours.\n\n{}\n\nIf you didn't ask to verify this address, you can safely ignore this email\n\nThanks, The gorb team.", data.config.web.url.domain().unwrap(), me.username, verify_endpoint), format!(r#"

Verify your {} Account

Hello, {}!

Thanks for creating a new account on Gorb.

The final step to create your account is to verify your email address by clicking the button below, within 24 hours.

VERIFY ACCOUNT

If you didn't ask to verify this address, you can safely ignore this email.

"#, data.config.web.url.domain().unwrap(), me.username, verify_endpoint) ))?; @@ -1046,18 +1046,14 @@ impl PasswordResetToken { reset_endpoint.set_query(Some(&format!("token={}", token))); - //TODO: Correct this email let email = data .mail_client .message_builder() .to(email_address.parse()?) - // twig: change this - .subject("Gorb E-mail Verification") + .subject(format!("{} Password Reset", data.config.web.url.domain().unwrap())) .multipart(MultiPart::alternative_plain_html( - // twig: change this line - format!("Verify your gorb.app account\n\nHello, {}!\nThanks for creating a new account on Gorb.\nThe final step to create your account is to verify your email address by visiting the page, within 24 hours.\n\n{}\n\nIf you didn't ask to verify this address, you can safely ignore this email\n\nThanks, The gorb team.", username, reset_endpoint), - // twig: and this one - format!(r#"

Verify your {} Account

Hello, {}!

Thanks for creating a new account on Gorb.

The final step to create your account is to verify your email address by clicking the button below, within 24 hours.

VERIFY ACCOUNT

If you didn't ask to verify this address, you can safely ignore this email.

"#, data.config.web.url.domain().unwrap(), username, reset_endpoint) + format!("{} Password Reset\n\nHello, {}!\nSomeone requested a password reset for your Gorb account.\nClick the button below within 24 hours to reset your password.\n\n{}\n\nIf you didn't request a password reset, don't worry, your account is safe and you can safely ignore this email.\n\nThanks, The gorb team.", data.config.web.url.domain().unwrap(), username, reset_endpoint), + format!(r#"

{} Password Reset

Hello, {}!

Someone requested a password reset for your Gorb account.

Click the button below within 24 hours to reset your password.

RESET PASSWORD

If you didn't request a password reset, don't worry, your account is safe and you can safely ignore this email.

"#, data.config.web.url.domain().unwrap(), username, reset_endpoint) ))?; data @@ -1097,18 +1093,14 @@ impl PasswordResetToken { let login_page = data.config.web.url.join("login")?; - //TODO: Correct this email let email = data .mail_client .message_builder() .to(email_address.parse()?) - // twig: change this (post password change email) - .subject("Gorb E-mail Verification") + .subject(format!("Your {} Password has been Reset", data.config.web.url.domain().unwrap())) .multipart(MultiPart::alternative_plain_html( - // twig: change this line - format!("Verify your gorb.app account\n\nHello, {}!\nThanks for creating a new account on Gorb.\nThe final step to create your account is to verify your email address by visiting the page, within 24 hours.\n\n{}\n\nIf you didn't ask to verify this address, you can safely ignore this email\n\nThanks, The gorb team.", username, login_page), - // twig: and this one - format!(r#"

Verify your {} Account

Hello, {}!

Thanks for creating a new account on Gorb.

The final step to create your account is to verify your email address by clicking the button below, within 24 hours.

VERIFY ACCOUNT

If you didn't ask to verify this address, you can safely ignore this email.

"#, data.config.web.url.domain().unwrap(), username, login_page) + format!("{} Password Reset Confirmation\n\nHello, {}!\nYour password has been successfully reset for your Gorb account.\nIf you did not initiate this change, please click the link below to reset your password immediately.\n\n{}\n\nThanks, The gorb team.", data.config.web.url.domain().unwrap(), username, login_page), + format!(r#"

{} Password Reset Confirmation

Hello, {}!

Your password has been successfully reset for your Gorb account.

If you did not initiate this change, please click the button below to reset your password immediately.

RESET PASSWORD
"#, data.config.web.url.domain().unwrap(), username, login_page) ))?; data From 65918ae5f24afdb747dead6ca6ea893e26f1bcf3 Mon Sep 17 00:00:00 2001 From: Radical Date: Thu, 29 May 2025 02:07:36 +0200 Subject: [PATCH 46/92] ci: make build system happy --- Dockerfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 8ea076f..3184195 100644 --- a/Dockerfile +++ b/Dockerfile @@ -30,10 +30,10 @@ BUNNY_API_KEY=your_storage_zone_password_here \ BUNNY_ENDPOINT=Frankfurt \ BUNNY_ZONE=gorb \ BUNNY_CDN_URL=https://cdn.gorb.app \ -MAIL_ADDRESS=Gorb \ +MAIL_ADDRESS=noreply@gorb.app \ MAIL_TLS=tls \ SMTP_SERVER=mail.gorb.app \ SMTP_USERNAME=your_smtp_username \ -SMTP_PASSWORD=your_smtp_password \ +SMTP_PASSWORD=your_smtp_password ENTRYPOINT ["/usr/bin/entrypoint.sh"] From 4d7aabc8ac1eae7846ff6724c5899042be99212b Mon Sep 17 00:00:00 2001 From: Radical Date: Thu, 29 May 2025 02:39:05 +0200 Subject: [PATCH 47/92] feat: include user in message response --- .../v1/servers/uuid/channels/uuid/messages.rs | 34 +++++++++- .../v1/servers/uuid/channels/uuid/socket.rs | 4 +- src/api/v1/servers/uuid/mod.rs | 2 +- src/api/v1/users/uuid.rs | 11 +--- src/structs.rs | 66 +++++++++++++++---- 5 files changed, 90 insertions(+), 27 deletions(-) diff --git a/src/api/v1/servers/uuid/channels/uuid/messages.rs b/src/api/v1/servers/uuid/channels/uuid/messages.rs index 66ec80d..ae96048 100644 --- a/src/api/v1/servers/uuid/channels/uuid/messages.rs +++ b/src/api/v1/servers/uuid/channels/uuid/messages.rs @@ -1,3 +1,5 @@ +//! `/api/v1/servers/{uuid}/channels/{uuid}/messages` Endpoints related to channel messages + use crate::{ Data, api::v1::auth::check_access_token, @@ -15,6 +17,36 @@ struct MessageRequest { offset: i64, } +/// `GET /api/v1/servers/{uuid}/channels/{uuid}/messages` Returns user with the given UUID +/// +/// requires auth: yes +/// +/// requires relation: yes +/// +/// ### Request Example +/// ``` +/// json!({ +/// "amount": 100, +/// "offset": 0 +/// }) +/// ``` +/// +/// ### Response Example +/// ``` +/// json!({ +/// "uuid": "01971976-8618-74c0-b040-7ffbc44823f6", +/// "channel_uuid": "0196fcb1-e886-7de3-b685-0ee46def9a7b", +/// "user_uuid": "0196fc96-a822-76b0-b9bf-a9de232f54b7", +/// "message": "test", +/// "user": { +/// "uuid": "0196fc96-a822-76b0-b9bf-a9de232f54b7", +/// "username": "1234", +/// "display_name": null, +/// "avatar": "https://cdn.gorb.app/avatar/0196fc96-a822-76b0-b9bf-a9de232f54b7/avatar.jpg" +/// } +/// }); +/// ``` +/// #[get("{uuid}/channels/{channel_uuid}/messages")] pub async fn get( req: HttpRequest, @@ -46,7 +78,7 @@ pub async fn get( } let messages = channel - .fetch_messages(&mut conn, message_request.amount, message_request.offset) + .fetch_messages(&data, message_request.amount, message_request.offset) .await?; Ok(HttpResponse::Ok().json(messages)) diff --git a/src/api/v1/servers/uuid/channels/uuid/socket.rs b/src/api/v1/servers/uuid/channels/uuid/socket.rs index a90cb86..03dfdfc 100644 --- a/src/api/v1/servers/uuid/channels/uuid/socket.rs +++ b/src/api/v1/servers/uuid/channels/uuid/socket.rs @@ -15,7 +15,7 @@ use crate::{ }; #[get("{uuid}/channels/{channel_uuid}/socket")] -pub async fn echo( +pub async fn ws( req: HttpRequest, path: web::Path<(Uuid, Uuid)>, stream: web::Payload, @@ -84,7 +84,7 @@ pub async fn echo( let mut conn = data.cache_pool.get_multiplexed_tokio_connection().await?; let message = channel - .new_message(&mut data.pool.get().await?, uuid, text.to_string()) + .new_message(&data, uuid, text.to_string()) .await?; redis::cmd("PUBLISH") diff --git a/src/api/v1/servers/uuid/mod.rs b/src/api/v1/servers/uuid/mod.rs index 80b3068..aa82d11 100644 --- a/src/api/v1/servers/uuid/mod.rs +++ b/src/api/v1/servers/uuid/mod.rs @@ -26,7 +26,7 @@ pub fn web() -> Scope { .service(channels::uuid::get) .service(channels::uuid::delete) .service(channels::uuid::messages::get) - .service(channels::uuid::socket::echo) + .service(channels::uuid::socket::ws) // Roles .service(roles::get) .service(roles::create) diff --git a/src/api/v1/users/uuid.rs b/src/api/v1/users/uuid.rs index 27e3326..1e4b9e5 100644 --- a/src/api/v1/users/uuid.rs +++ b/src/api/v1/users/uuid.rs @@ -39,16 +39,7 @@ pub async fn get( check_access_token(auth_header, &mut conn).await?; - if let Ok(cache_hit) = data.get_cache_key(uuid.to_string()).await { - return Ok(HttpResponse::Ok() - .content_type("application/json") - .body(cache_hit)); - } - - let user = User::fetch_one(&mut conn, uuid).await?; - - data.set_cache_key(uuid.to_string(), user.clone(), 1800) - .await?; + let user = User::fetch_one(&data, uuid).await?; Ok(HttpResponse::Ok().json(user)) } diff --git a/src/structs.rs b/src/structs.rs index 019d553..1efa820 100644 --- a/src/structs.rs +++ b/src/structs.rs @@ -263,45 +263,53 @@ impl Channel { pub async fn fetch_messages( &self, - conn: &mut Conn, + data: &Data, amount: i64, offset: i64, ) -> Result, Error> { + let mut conn = data.pool.get().await?; + use messages::dsl; - let messages: Vec = load_or_empty( + let messages: Vec = load_or_empty( dsl::messages .filter(dsl::channel_uuid.eq(self.uuid)) - .select(Message::as_select()) + .select(MessageBuilder::as_select()) .limit(amount) .offset(offset) - .load(conn) + .load(&mut conn) .await, )?; - Ok(messages) + let message_futures = messages.iter().map(async move |b| { + b.build(data).await + }); + + futures::future::try_join_all(message_futures).await } pub async fn new_message( &self, - conn: &mut Conn, + data: &Data, user_uuid: Uuid, message: String, ) -> Result { let message_uuid = Uuid::now_v7(); - let message = Message { + let message = MessageBuilder { uuid: message_uuid, channel_uuid: self.uuid, user_uuid, message, }; + let mut conn = data.pool.get().await?; + insert_into(messages::table) .values(message.clone()) - .execute(conn) + .execute(&mut conn) .await?; - Ok(message) + message.build(data).await } } @@ -697,14 +705,37 @@ impl Member { } } -#[derive(Clone, Serialize, Queryable, Selectable, Insertable)] +#[derive(Clone, Queryable, Selectable, Insertable)] #[diesel(table_name = messages)] #[diesel(check_for_backend(diesel::pg::Pg))] +pub struct MessageBuilder { + uuid: Uuid, + channel_uuid: Uuid, + user_uuid: Uuid, + message: String, +} + +impl MessageBuilder { + pub async fn build(&self, data: &Data) -> Result { + let user = User::fetch_one(data, self.user_uuid).await?; + + Ok(Message { + uuid: self.uuid, + channel_uuid: self.channel_uuid, + user_uuid: self.user_uuid, + message: self.message.clone(), + user, + }) + } +} + +#[derive(Clone, Serialize)] pub struct Message { uuid: Uuid, channel_uuid: Uuid, user_uuid: Uuid, message: String, + user: User, } /// Server invite struct @@ -731,7 +762,7 @@ impl Invite { } } -#[derive(Serialize, Clone, Queryable, Selectable)] +#[derive(Deserialize, Serialize, Clone, Queryable, Selectable)] #[diesel(table_name = users)] #[diesel(check_for_backend(diesel::pg::Pg))] pub struct User { @@ -742,12 +773,21 @@ pub struct User { } impl User { - pub async fn fetch_one(conn: &mut Conn, user_uuid: Uuid) -> Result { + pub async fn fetch_one(data: &Data, user_uuid: Uuid) -> Result { + let mut conn = data.pool.get().await?; + + if let Ok(cache_hit) = data.get_cache_key(user_uuid.to_string()).await { + return Ok(serde_json::from_str(&cache_hit)?); + } + use users::dsl; let user: User = dsl::users .filter(dsl::uuid.eq(user_uuid)) .select(User::as_select()) - .get_result(conn) + .get_result(&mut conn) + .await?; + + data.set_cache_key(user_uuid.to_string(), user.clone(), 1800) .await?; Ok(user) From 21101fecd525e8581ac32290216e68fc1112ec98 Mon Sep 17 00:00:00 2001 From: Radical Date: Thu, 29 May 2025 03:31:02 +0200 Subject: [PATCH 48/92] build: add missing ca-certificates to docker --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 3184195..bc9fc5d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,7 +8,7 @@ RUN cargo build --release FROM debian:12-slim -RUN apt update && apt install libssl3 && rm -rf /var/lib/apt/lists/* /var/cache/apt/* /tmp/* +RUN apt update && apt install libssl3 ca-certificates && rm -rf /var/lib/apt/lists/* /var/cache/apt/* /tmp/* COPY --from=builder /src/target/release/backend /usr/bin/gorb-backend From d0ecf1b375091b1aa8a85e8e8b1dff49e284747d Mon Sep 17 00:00:00 2001 From: Radical Date: Thu, 29 May 2025 03:35:39 +0200 Subject: [PATCH 49/92] build: add missing -y flags this shouldnt have built before but i guess libssl3 is included by default? --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index bc9fc5d..a8e8dc6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,7 +8,7 @@ RUN cargo build --release FROM debian:12-slim -RUN apt update && apt install libssl3 ca-certificates && rm -rf /var/lib/apt/lists/* /var/cache/apt/* /tmp/* +RUN apt update -y && apt install libssl3 ca-certificates -y && rm -rf /var/lib/apt/lists/* /var/cache/apt/* /tmp/* COPY --from=builder /src/target/release/backend /usr/bin/gorb-backend From d102966198cae443c6d28518f8b81e8c3fcaa415 Mon Sep 17 00:00:00 2001 From: Radical Date: Thu, 29 May 2025 16:11:13 +0200 Subject: [PATCH 50/92] fix: fetch messages properly --- src/structs.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/structs.rs b/src/structs.rs index 1efa820..eb89980 100644 --- a/src/structs.rs +++ b/src/structs.rs @@ -274,6 +274,7 @@ impl Channel { dsl::messages .filter(dsl::channel_uuid.eq(self.uuid)) .select(MessageBuilder::as_select()) + .order(dsl::uuid.desc()) .limit(amount) .offset(offset) .load(&mut conn) From 29dbb085a23b320a5237bf8807c05af42b8799fb Mon Sep 17 00:00:00 2001 From: Radical Date: Thu, 29 May 2025 18:31:26 +0200 Subject: [PATCH 51/92] fix: dont require auth to check invite information --- src/api/v1/invites/id.rs | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/src/api/v1/invites/id.rs b/src/api/v1/invites/id.rs index 601d9db..14b444f 100644 --- a/src/api/v1/invites/id.rs +++ b/src/api/v1/invites/id.rs @@ -1,27 +1,16 @@ use actix_web::{HttpRequest, HttpResponse, get, post, web}; use crate::{ - Data, - api::v1::auth::check_access_token, - error::Error, - structs::{Guild, Invite, Member}, - utils::get_auth_header, + api::v1::auth::check_access_token, error::Error, structs::{Guild, Invite, Member}, utils::{get_auth_header, global_checks}, Data }; #[get("{id}")] pub async fn get( - req: HttpRequest, path: web::Path<(String,)>, data: web::Data, ) -> Result { - let headers = req.headers(); - - let auth_header = get_auth_header(headers)?; - let mut conn = data.pool.get().await?; - check_access_token(auth_header, &mut conn).await?; - let invite_id = path.into_inner().0; let invite = Invite::fetch_one(&mut conn, invite_id).await?; From abfbaf8918bcb1942c5dd8c5f0156e36ff2a8747 Mon Sep 17 00:00:00 2001 From: Radical Date: Thu, 29 May 2025 18:35:13 +0200 Subject: [PATCH 52/92] feat: add global email verification check --- src/api/v1/invites/id.rs | 2 ++ src/api/v1/me/mod.rs | 12 +++++--- src/api/v1/me/servers.rs | 4 ++- src/api/v1/servers/mod.rs | 10 +++---- src/api/v1/servers/uuid/channels/mod.rs | 6 +++- .../v1/servers/uuid/channels/uuid/messages.rs | 8 ++---- src/api/v1/servers/uuid/channels/uuid/mod.rs | 10 +++---- .../v1/servers/uuid/channels/uuid/socket.rs | 7 ++--- src/api/v1/servers/uuid/icon.rs | 8 ++---- src/api/v1/servers/uuid/invites/mod.rs | 10 +++---- src/api/v1/servers/uuid/mod.rs | 8 ++---- src/api/v1/servers/uuid/roles/mod.rs | 4 ++- src/api/v1/servers/uuid/roles/uuid.rs | 8 ++---- src/api/v1/users/mod.rs | 10 +++---- src/api/v1/users/uuid.rs | 10 ++++--- src/config.rs | 28 +++++++++++++++---- src/structs.rs | 4 ++- src/utils.rs | 20 +++++++++++++ 18 files changed, 106 insertions(+), 63 deletions(-) diff --git a/src/api/v1/invites/id.rs b/src/api/v1/invites/id.rs index 14b444f..f7e4a08 100644 --- a/src/api/v1/invites/id.rs +++ b/src/api/v1/invites/id.rs @@ -36,6 +36,8 @@ pub async fn join( let uuid = check_access_token(auth_header, &mut conn).await?; + global_checks(&data, uuid).await?; + let invite = Invite::fetch_one(&mut conn, invite_id).await?; let guild = Guild::fetch_one(&mut conn, invite.guild_uuid).await?; diff --git a/src/api/v1/me/mod.rs b/src/api/v1/me/mod.rs index 605eb1c..cd86e43 100644 --- a/src/api/v1/me/mod.rs +++ b/src/api/v1/me/mod.rs @@ -3,7 +3,7 @@ use actix_web::{HttpRequest, HttpResponse, Scope, get, patch, web}; use serde::Deserialize; use crate::{ - Data, api::v1::auth::check_access_token, error::Error, structs::Me, utils::get_auth_header, + api::v1::auth::check_access_token, error::Error, structs::Me, utils::{get_auth_header, global_checks}, Data }; mod servers; @@ -27,7 +27,7 @@ pub async fn get(req: HttpRequest, data: web::Data) -> Result, display_name: Option, @@ -39,7 +39,7 @@ struct NewInfo { struct UploadForm { #[multipart(limit = "100MB")] avatar: Option, - json: Option>, + json: MpJson>, } #[patch("")] @@ -56,6 +56,10 @@ pub async fn update( let uuid = check_access_token(auth_header, &mut conn).await?; + if form.avatar.is_some() || form.json.0.clone().is_some_and(|ni| ni.username.is_some() || ni.display_name.is_some()) { + global_checks(&data, uuid).await?; + } + let mut me = Me::get(&mut conn, uuid).await?; if let Some(avatar) = form.avatar { @@ -72,7 +76,7 @@ pub async fn update( .await?; } - if let Some(new_info) = form.json { + if let Some(new_info) = form.json.0 { if let Some(username) = &new_info.username { me.set_username(&mut conn, username.clone()).await?; } diff --git a/src/api/v1/me/servers.rs b/src/api/v1/me/servers.rs index 390c31e..ae026a7 100644 --- a/src/api/v1/me/servers.rs +++ b/src/api/v1/me/servers.rs @@ -2,7 +2,7 @@ use actix_web::{get, web, HttpRequest, HttpResponse}; -use crate::{api::v1::auth::check_access_token, error::Error, structs::Me, utils::get_auth_header, Data}; +use crate::{api::v1::auth::check_access_token, error::Error, structs::Me, utils::{get_auth_header, global_checks}, Data}; /// `GET /api/v1/me/servers` Returns all guild memberships in a list @@ -37,6 +37,8 @@ pub async fn get(req: HttpRequest, data: web::Data) -> Result Scope { @@ -87,6 +83,8 @@ pub async fn get( let uuid = check_access_token(auth_header, &mut conn).await?; + global_checks(&data, uuid).await?; + Member::fetch_one(&mut conn, uuid, guild_uuid).await?; let guild = Guild::fetch_one(&mut conn, guild_uuid).await?; diff --git a/src/api/v1/servers/uuid/roles/mod.rs b/src/api/v1/servers/uuid/roles/mod.rs index c0ebaa2..3ae9c5b 100644 --- a/src/api/v1/servers/uuid/roles/mod.rs +++ b/src/api/v1/servers/uuid/roles/mod.rs @@ -3,7 +3,7 @@ use actix_web::{HttpRequest, HttpResponse, get, post, web}; use serde::Deserialize; use crate::{ - api::v1::auth::check_access_token, error::Error, structs::{Member, Role}, utils::{get_auth_header, order_by_is_above}, Data + api::v1::auth::check_access_token, error::Error, structs::{Member, Role}, utils::{get_auth_header, global_checks, order_by_is_above}, Data }; pub mod uuid; @@ -64,6 +64,8 @@ pub async fn create( let uuid = check_access_token(auth_header, &mut conn).await?; + global_checks(&data, uuid).await?; + Member::fetch_one(&mut conn, uuid, guild_uuid).await?; // FIXME: Logic to check permissions, should probably be done in utils.rs diff --git a/src/api/v1/servers/uuid/roles/uuid.rs b/src/api/v1/servers/uuid/roles/uuid.rs index 8ca3cc5..21ab748 100644 --- a/src/api/v1/servers/uuid/roles/uuid.rs +++ b/src/api/v1/servers/uuid/roles/uuid.rs @@ -1,9 +1,5 @@ use crate::{ - Data, - api::v1::auth::check_access_token, - error::Error, - structs::{Member, Role}, - utils::get_auth_header, + api::v1::auth::check_access_token, error::Error, structs::{Member, Role}, utils::{get_auth_header, global_checks}, Data }; use ::uuid::Uuid; use actix_web::{HttpRequest, HttpResponse, get, web}; @@ -24,6 +20,8 @@ pub async fn get( let uuid = check_access_token(auth_header, &mut conn).await?; + global_checks(&data, uuid).await?; + Member::fetch_one(&mut conn, uuid, guild_uuid).await?; if let Ok(cache_hit) = data.get_cache_key(format!("{}", role_uuid)).await { diff --git a/src/api/v1/users/mod.rs b/src/api/v1/users/mod.rs index 0b80fd1..b3d853b 100644 --- a/src/api/v1/users/mod.rs +++ b/src/api/v1/users/mod.rs @@ -3,11 +3,7 @@ use actix_web::{HttpRequest, HttpResponse, Scope, get, web}; use crate::{ - Data, - api::v1::auth::check_access_token, - error::Error, - structs::{StartAmountQuery, User}, - utils::get_auth_header, + api::v1::auth::check_access_token, error::Error, structs::{StartAmountQuery, User}, utils::{get_auth_header, global_checks}, Data }; mod uuid; @@ -68,7 +64,9 @@ pub async fn get( let mut conn = data.pool.get().await?; - check_access_token(auth_header, &mut conn).await?; + let uuid = check_access_token(auth_header, &mut conn).await?; + + global_checks(&data, uuid).await?; let users = User::fetch_amount(&mut conn, start, amount).await?; diff --git a/src/api/v1/users/uuid.rs b/src/api/v1/users/uuid.rs index 1e4b9e5..337019b 100644 --- a/src/api/v1/users/uuid.rs +++ b/src/api/v1/users/uuid.rs @@ -4,7 +4,7 @@ use actix_web::{HttpRequest, HttpResponse, get, web}; use uuid::Uuid; use crate::{ - Data, api::v1::auth::check_access_token, error::Error, structs::User, utils::get_auth_header, + api::v1::auth::check_access_token, error::Error, structs::User, utils::{get_auth_header, global_checks}, Data }; /// `GET /api/v1/users/{uuid}` Returns user with the given UUID @@ -31,15 +31,17 @@ pub async fn get( ) -> Result { let headers = req.headers(); - let uuid = path.into_inner().0; + let user_uuid = path.into_inner().0; let auth_header = get_auth_header(headers)?; let mut conn = data.pool.get().await?; - check_access_token(auth_header, &mut conn).await?; + let uuid = check_access_token(auth_header, &mut conn).await?; - let user = User::fetch_one(&data, uuid).await?; + global_checks(&data, uuid).await?; + + let user = User::fetch_one(&data, user_uuid).await?; Ok(HttpResponse::Ok().json(user)) } diff --git a/src/config.rs b/src/config.rs index 9ffd9c2..6db0483 100644 --- a/src/config.rs +++ b/src/config.rs @@ -11,7 +11,7 @@ pub struct ConfigBuilder { database: Database, cache_database: CacheDatabase, web: WebBuilder, - instance: Option, + instance: Option, bunny: BunnyBuilder, mail: Mail, } @@ -42,9 +42,10 @@ struct WebBuilder { _ssl: Option, } -#[derive(Debug, Deserialize, Clone)] -pub struct Instance { - pub registration: bool, +#[derive(Debug, Deserialize)] +struct InstanceBuilder { + registration: Option, + require_email_verification: Option, } #[derive(Debug, Deserialize)] @@ -106,11 +107,22 @@ impl ConfigBuilder { cdn_url: self.bunny.cdn_url, }; + let instance = match self.instance { + Some(instance) => Instance { + registration: instance.registration.unwrap_or(true), + require_email_verification: instance.require_email_verification.unwrap_or(false), + }, + None => Instance { + registration: true, + require_email_verification: false, + }, + }; + Config { database: self.database, cache_database: self.cache_database, web, - instance: self.instance.unwrap_or(Instance { registration: true }), + instance, bunny, mail: self.mail, } @@ -134,6 +146,12 @@ pub struct Web { pub url: Url, } +#[derive(Debug, Clone)] +pub struct Instance { + pub registration: bool, + pub require_email_verification: bool, +} + #[derive(Debug, Clone)] pub struct Bunny { pub api_key: String, diff --git a/src/structs.rs b/src/structs.rs index eb89980..4ece87a 100644 --- a/src/structs.rs +++ b/src/structs.rs @@ -16,7 +16,7 @@ use argon2::{ }; use crate::{ - error::Error, schema::*, utils::{generate_refresh_token, image_check, order_by_is_above, user_uuid_from_identifier, EMAIL_REGEX, PASSWORD_REGEX, USERNAME_REGEX}, Conn, Data + error::Error, schema::*, utils::{generate_refresh_token, global_checks, image_check, order_by_is_above, user_uuid_from_identifier, EMAIL_REGEX, PASSWORD_REGEX, USERNAME_REGEX}, Conn, Data }; pub trait HasUuid { @@ -1070,6 +1070,8 @@ impl PasswordResetToken { let user_uuid = user_uuid_from_identifier(&mut conn, &identifier).await?; + global_checks(&data, user_uuid).await?; + use users::dsl as udsl; let (username, email_address): (String, String) = udsl::users .filter(udsl::uuid.eq(user_uuid)) diff --git a/src/utils.rs b/src/utils.rs index 1d39fdb..d5d4480 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -161,6 +161,26 @@ pub async fn user_uuid_from_identifier(conn: &mut Conn, identifier: &String) -> } } +pub async fn global_checks(data: &Data, user_uuid: Uuid) -> Result<(), Error> { + if data.config.instance.require_email_verification { + let mut conn = data.pool.get().await?; + + use users::dsl; + let email_verified: bool = dsl::users + .filter(dsl::uuid.eq(user_uuid)) + .select(dsl::email_verified) + .get_result(&mut conn) + .await?; + + if !email_verified { + return Err(Error::Forbidden("server requires email verification".to_string())) + } + } + + + Ok(()) +} + pub async fn order_by_is_above(mut items: Vec) -> Result, Error> where T: HasUuid + HasIsAbove, From 8ddcbc49554879052bec94e7149ec66431db93a1 Mon Sep 17 00:00:00 2001 From: Radical Date: Thu, 29 May 2025 18:36:07 +0200 Subject: [PATCH 53/92] feat: add registration_enabled and email_verification_required fields to stats --- src/api/v1/stats.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/api/v1/stats.rs b/src/api/v1/stats.rs index 4e8aa32..bf2bec3 100644 --- a/src/api/v1/stats.rs +++ b/src/api/v1/stats.rs @@ -18,6 +18,8 @@ struct Response { accounts: i64, uptime: u64, version: String, + registration_enabled: bool, + email_verification_required: bool, build_number: String, } @@ -31,6 +33,8 @@ struct Response { /// "accounts": 3, /// "uptime": 50000, /// "version": "0.1.0", +/// "registration_enabled": true, +/// "email_verification_required": true, /// "build_number": "39d01bb" /// }); /// ``` @@ -50,6 +54,8 @@ pub async fn res(data: web::Data) -> Result { .expect("Seriously why dont you have time??") .as_secs(), version: String::from(VERSION.unwrap_or("UNKNOWN")), + registration_enabled: data.config.instance.registration, + email_verification_required: data.config.instance.require_email_verification, // TODO: Get build number from git hash or remove this from the spec build_number: String::from("how do i implement this?"), }; From 461295c14a0f221e6a4e0469d5a4741f39796705 Mon Sep 17 00:00:00 2001 From: Radical Date: Thu, 29 May 2025 18:48:29 +0200 Subject: [PATCH 54/92] feat: add instance name and use it in emails --- src/config.rs | 4 ++++ src/structs.rs | 18 +++++++++--------- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/src/config.rs b/src/config.rs index 6db0483..44f73ed 100644 --- a/src/config.rs +++ b/src/config.rs @@ -44,6 +44,7 @@ struct WebBuilder { #[derive(Debug, Deserialize)] struct InstanceBuilder { + name: Option, registration: Option, require_email_verification: Option, } @@ -109,10 +110,12 @@ impl ConfigBuilder { let instance = match self.instance { Some(instance) => Instance { + name: instance.name.unwrap_or("Gorb".to_string()), registration: instance.registration.unwrap_or(true), require_email_verification: instance.require_email_verification.unwrap_or(false), }, None => Instance { + name: "Gorb".to_string(), registration: true, require_email_verification: false, }, @@ -148,6 +151,7 @@ pub struct Web { #[derive(Debug, Clone)] pub struct Instance { + pub name: String, pub registration: bool, pub require_email_verification: bool, } diff --git a/src/structs.rs b/src/structs.rs index 4ece87a..500396f 100644 --- a/src/structs.rs +++ b/src/structs.rs @@ -1003,10 +1003,10 @@ impl EmailToken { .mail_client .message_builder() .to(me.email.parse()?) - .subject(format!("{} E-mail Verification", data.config.web.url.domain().unwrap())) + .subject(format!("{} E-mail Verification", data.config.instance.name)) .multipart(MultiPart::alternative_plain_html( - format!("Verify your {} account\n\nHello, {}!\nThanks for creating a new account on Gorb.\nThe final step to create your account is to verify your email address by visiting the page, within 24 hours.\n\n{}\n\nIf you didn't ask to verify this address, you can safely ignore this email\n\nThanks, The gorb team.", data.config.web.url.domain().unwrap(), me.username, verify_endpoint), - format!(r#"

Verify your {} Account

Hello, {}!

Thanks for creating a new account on Gorb.

The final step to create your account is to verify your email address by clicking the button below, within 24 hours.

VERIFY ACCOUNT

If you didn't ask to verify this address, you can safely ignore this email.

"#, data.config.web.url.domain().unwrap(), me.username, verify_endpoint) + format!("Verify your {} account\n\nHello, {}!\nThanks for creating a new account on Gorb.\nThe final step to create your account is to verify your email address by visiting the page, within 24 hours.\n\n{}\n\nIf you didn't ask to verify this address, you can safely ignore this email\n\nThanks, The gorb team.", data.config.instance.name, me.username, verify_endpoint), + format!(r#"

Verify your {} Account

Hello, {}!

Thanks for creating a new account on Gorb.

The final step to create your account is to verify your email address by clicking the button below, within 24 hours.

VERIFY ACCOUNT

If you didn't ask to verify this address, you can safely ignore this email.

"#, data.config.instance.name, me.username, verify_endpoint) ))?; data @@ -1093,10 +1093,10 @@ impl PasswordResetToken { .mail_client .message_builder() .to(email_address.parse()?) - .subject(format!("{} Password Reset", data.config.web.url.domain().unwrap())) + .subject(format!("{} Password Reset", data.config.instance.name)) .multipart(MultiPart::alternative_plain_html( - format!("{} Password Reset\n\nHello, {}!\nSomeone requested a password reset for your Gorb account.\nClick the button below within 24 hours to reset your password.\n\n{}\n\nIf you didn't request a password reset, don't worry, your account is safe and you can safely ignore this email.\n\nThanks, The gorb team.", data.config.web.url.domain().unwrap(), username, reset_endpoint), - format!(r#"

{} Password Reset

Hello, {}!

Someone requested a password reset for your Gorb account.

Click the button below within 24 hours to reset your password.

RESET PASSWORD

If you didn't request a password reset, don't worry, your account is safe and you can safely ignore this email.

"#, data.config.web.url.domain().unwrap(), username, reset_endpoint) + format!("{} Password Reset\n\nHello, {}!\nSomeone requested a password reset for your Gorb account.\nClick the button below within 24 hours to reset your password.\n\n{}\n\nIf you didn't request a password reset, don't worry, your account is safe and you can safely ignore this email.\n\nThanks, The gorb team.", data.config.instance.name, username, reset_endpoint), + format!(r#"

{} Password Reset

Hello, {}!

Someone requested a password reset for your Gorb account.

Click the button below within 24 hours to reset your password.

RESET PASSWORD

If you didn't request a password reset, don't worry, your account is safe and you can safely ignore this email.

"#, data.config.instance.name, username, reset_endpoint) ))?; data @@ -1140,10 +1140,10 @@ impl PasswordResetToken { .mail_client .message_builder() .to(email_address.parse()?) - .subject(format!("Your {} Password has been Reset", data.config.web.url.domain().unwrap())) + .subject(format!("Your {} Password has been Reset", data.config.instance.name)) .multipart(MultiPart::alternative_plain_html( - format!("{} Password Reset Confirmation\n\nHello, {}!\nYour password has been successfully reset for your Gorb account.\nIf you did not initiate this change, please click the link below to reset your password immediately.\n\n{}\n\nThanks, The gorb team.", data.config.web.url.domain().unwrap(), username, login_page), - format!(r#"

{} Password Reset Confirmation

Hello, {}!

Your password has been successfully reset for your Gorb account.

If you did not initiate this change, please click the button below to reset your password immediately.

RESET PASSWORD
"#, data.config.web.url.domain().unwrap(), username, login_page) + format!("{} Password Reset Confirmation\n\nHello, {}!\nYour password has been successfully reset for your Gorb account.\nIf you did not initiate this change, please click the link below to reset your password immediately.\n\n{}\n\nThanks, The gorb team.", data.config.instance.name, username, login_page), + format!(r#"

{} Password Reset Confirmation

Hello, {}!

Your password has been successfully reset for your Gorb account.

If you did not initiate this change, please click the button below to reset your password immediately.

RESET PASSWORD
"#, data.config.instance.name, username, login_page) ))?; data From 66c3aef6090087d5a3e64cc322027a29f4c6ad0c Mon Sep 17 00:00:00 2001 From: Radical Date: Thu, 29 May 2025 20:11:50 +0200 Subject: [PATCH 55/92] style: move channels to /channels --- src/api/v1/channels/mod.rs | 11 +++ .../uuid => }/channels/uuid/messages.rs | 19 ++--- src/api/v1/channels/uuid/mod.rs | 60 +++++++++++++++ .../uuid => }/channels/uuid/socket.rs | 24 ++---- src/api/v1/mod.rs | 2 + .../uuid/{channels/mod.rs => channels.rs} | 2 - src/api/v1/servers/uuid/channels/uuid/mod.rs | 77 ------------------- src/api/v1/servers/uuid/mod.rs | 4 - src/structs.rs | 33 ++++++-- 9 files changed, 111 insertions(+), 121 deletions(-) create mode 100644 src/api/v1/channels/mod.rs rename src/api/v1/{servers/uuid => }/channels/uuid/messages.rs (76%) create mode 100644 src/api/v1/channels/uuid/mod.rs rename src/api/v1/{servers/uuid => }/channels/uuid/socket.rs (82%) rename src/api/v1/servers/uuid/{channels/mod.rs => channels.rs} (99%) delete mode 100644 src/api/v1/servers/uuid/channels/uuid/mod.rs diff --git a/src/api/v1/channels/mod.rs b/src/api/v1/channels/mod.rs new file mode 100644 index 0000000..d3d5d23 --- /dev/null +++ b/src/api/v1/channels/mod.rs @@ -0,0 +1,11 @@ +use actix_web::{web, Scope}; + +mod uuid; + +pub fn web() -> Scope { + web::scope("/channels") + .service(uuid::get) + .service(uuid::delete) + .service(uuid::messages::get) + .service(uuid::socket::ws) +} diff --git a/src/api/v1/servers/uuid/channels/uuid/messages.rs b/src/api/v1/channels/uuid/messages.rs similarity index 76% rename from src/api/v1/servers/uuid/channels/uuid/messages.rs rename to src/api/v1/channels/uuid/messages.rs index 27bf0cf..25134d2 100644 --- a/src/api/v1/servers/uuid/channels/uuid/messages.rs +++ b/src/api/v1/channels/uuid/messages.rs @@ -43,10 +43,10 @@ struct MessageRequest { /// }); /// ``` /// -#[get("{uuid}/channels/{channel_uuid}/messages")] +#[get("/{uuid}/messages")] pub async fn get( req: HttpRequest, - path: web::Path<(Uuid, Uuid)>, + path: web::Path<(Uuid,)>, message_request: web::Query, data: web::Data, ) -> Result { @@ -54,7 +54,7 @@ pub async fn get( let auth_header = get_auth_header(headers)?; - let (guild_uuid, channel_uuid) = path.into_inner(); + let channel_uuid = path.into_inner().0; let mut conn = data.pool.get().await?; @@ -62,18 +62,9 @@ pub async fn get( global_checks(&data, uuid).await?; - Member::fetch_one(&mut conn, uuid, guild_uuid).await?; + let channel = Channel::fetch_one(&data, channel_uuid).await?; - let channel: Channel; - - if let Ok(cache_hit) = data.get_cache_key(format!("{}", channel_uuid)).await { - channel = serde_json::from_str(&cache_hit)? - } else { - channel = Channel::fetch_one(&mut conn, channel_uuid).await?; - - data.set_cache_key(format!("{}", channel_uuid), channel.clone(), 60) - .await?; - } + Member::fetch_one(&mut conn, uuid, channel.guild_uuid).await?; let messages = channel .fetch_messages(&data, message_request.amount, message_request.offset) diff --git a/src/api/v1/channels/uuid/mod.rs b/src/api/v1/channels/uuid/mod.rs new file mode 100644 index 0000000..f6c93fe --- /dev/null +++ b/src/api/v1/channels/uuid/mod.rs @@ -0,0 +1,60 @@ +pub mod messages; +pub mod socket; + +use crate::{ + api::v1::auth::check_access_token, error::Error, structs::{Channel, Member}, utils::{get_auth_header, global_checks}, Data +}; +use actix_web::{HttpRequest, HttpResponse, delete, get, web}; +use uuid::Uuid; + +#[get("/{uuid}")] +pub async fn get( + req: HttpRequest, + path: web::Path<(Uuid,)>, + data: web::Data, +) -> Result { + let headers = req.headers(); + + let auth_header = get_auth_header(headers)?; + + let channel_uuid = path.into_inner().0; + + let mut conn = data.pool.get().await?; + + let uuid = check_access_token(auth_header, &mut conn).await?; + + global_checks(&data, uuid).await?; + + let channel = Channel::fetch_one(&data, channel_uuid).await?; + + Member::fetch_one(&mut conn, uuid, channel.guild_uuid).await?; + + Ok(HttpResponse::Ok().json(channel)) +} + +#[delete("/{uuid}")] +pub async fn delete( + req: HttpRequest, + path: web::Path<(Uuid,)>, + data: web::Data, +) -> Result { + let headers = req.headers(); + + let auth_header = get_auth_header(headers)?; + + let channel_uuid = path.into_inner().0; + + let mut conn = data.pool.get().await?; + + let uuid = check_access_token(auth_header, &mut conn).await?; + + global_checks(&data, uuid).await?; + + let channel = Channel::fetch_one(&data, channel_uuid).await?; + + Member::fetch_one(&mut conn, uuid, channel.guild_uuid).await?; + + channel.delete(&data).await?; + + Ok(HttpResponse::Ok().finish()) +} diff --git a/src/api/v1/servers/uuid/channels/uuid/socket.rs b/src/api/v1/channels/uuid/socket.rs similarity index 82% rename from src/api/v1/servers/uuid/channels/uuid/socket.rs rename to src/api/v1/channels/uuid/socket.rs index 3cbdb8f..c7ca1e8 100644 --- a/src/api/v1/servers/uuid/channels/uuid/socket.rs +++ b/src/api/v1/channels/uuid/socket.rs @@ -11,10 +11,10 @@ use crate::{ api::v1::auth::check_access_token, structs::{Channel, Member}, utils::{get_ws_protocol_header, global_checks}, Data }; -#[get("{uuid}/channels/{channel_uuid}/socket")] +#[get("/{uuid}/socket")] pub async fn ws( req: HttpRequest, - path: web::Path<(Uuid, Uuid)>, + path: web::Path<(Uuid,)>, stream: web::Payload, data: web::Data, ) -> Result { @@ -24,8 +24,8 @@ pub async fn ws( // Retrieve auth header let auth_header = get_ws_protocol_header(headers)?; - // Get uuids from path - let (guild_uuid, channel_uuid) = path.into_inner(); + // Get uuid from path + let channel_uuid = path.into_inner().0; let mut conn = data.pool.get().await.map_err(crate::error::Error::from)?; @@ -34,20 +34,10 @@ pub async fn ws( global_checks(&data, uuid).await?; + let channel = Channel::fetch_one(&data, channel_uuid).await?; + // Get server member from psql - Member::fetch_one(&mut conn, uuid, guild_uuid).await?; - - let channel: Channel; - - // Return channel cache or result from psql as `channel` variable - if let Ok(cache_hit) = data.get_cache_key(format!("{}", channel_uuid)).await { - channel = serde_json::from_str(&cache_hit)? - } else { - channel = Channel::fetch_one(&mut conn, channel_uuid).await?; - - data.set_cache_key(format!("{}", channel_uuid), channel.clone(), 60) - .await?; - } + Member::fetch_one(&mut conn, uuid, channel.guild_uuid).await?; let (mut res, mut session_1, stream) = actix_ws::handle(&req, stream)?; diff --git a/src/api/v1/mod.rs b/src/api/v1/mod.rs index f30ad58..c08e1e3 100644 --- a/src/api/v1/mod.rs +++ b/src/api/v1/mod.rs @@ -3,6 +3,7 @@ use actix_web::{Scope, web}; mod auth; +mod channels; mod invites; mod servers; mod stats; @@ -14,6 +15,7 @@ pub fn web() -> Scope { .service(stats::res) .service(auth::web()) .service(users::web()) + .service(channels::web()) .service(servers::web()) .service(invites::web()) .service(me::web()) diff --git a/src/api/v1/servers/uuid/channels/mod.rs b/src/api/v1/servers/uuid/channels.rs similarity index 99% rename from src/api/v1/servers/uuid/channels/mod.rs rename to src/api/v1/servers/uuid/channels.rs index 6327e33..813de13 100644 --- a/src/api/v1/servers/uuid/channels/mod.rs +++ b/src/api/v1/servers/uuid/channels.rs @@ -5,8 +5,6 @@ use ::uuid::Uuid; use actix_web::{HttpRequest, HttpResponse, get, post, web}; use serde::Deserialize; -pub mod uuid; - #[derive(Deserialize)] struct ChannelInfo { name: String, diff --git a/src/api/v1/servers/uuid/channels/uuid/mod.rs b/src/api/v1/servers/uuid/channels/uuid/mod.rs deleted file mode 100644 index 946adf3..0000000 --- a/src/api/v1/servers/uuid/channels/uuid/mod.rs +++ /dev/null @@ -1,77 +0,0 @@ -pub mod messages; -pub mod socket; - -use crate::{ - api::v1::auth::check_access_token, error::Error, structs::{Channel, Member}, utils::{get_auth_header, global_checks}, Data -}; -use actix_web::{HttpRequest, HttpResponse, delete, get, web}; -use uuid::Uuid; - -#[get("{uuid}/channels/{channel_uuid}")] -pub async fn get( - req: HttpRequest, - path: web::Path<(Uuid, Uuid)>, - data: web::Data, -) -> Result { - let headers = req.headers(); - - let auth_header = get_auth_header(headers)?; - - let (guild_uuid, channel_uuid) = path.into_inner(); - - let mut conn = data.pool.get().await?; - - let uuid = check_access_token(auth_header, &mut conn).await?; - - global_checks(&data, uuid).await?; - - Member::fetch_one(&mut conn, uuid, guild_uuid).await?; - - if let Ok(cache_hit) = data.get_cache_key(format!("{}", channel_uuid)).await { - return Ok(HttpResponse::Ok() - .content_type("application/json") - .body(cache_hit)); - } - - let channel = Channel::fetch_one(&mut conn, channel_uuid).await?; - - data.set_cache_key(format!("{}", channel_uuid), channel.clone(), 60) - .await?; - - Ok(HttpResponse::Ok().json(channel)) -} - -#[delete("{uuid}/channels/{channel_uuid}")] -pub async fn delete( - req: HttpRequest, - path: web::Path<(Uuid, Uuid)>, - data: web::Data, -) -> Result { - let headers = req.headers(); - - let auth_header = get_auth_header(headers)?; - - let (guild_uuid, channel_uuid) = path.into_inner(); - - let mut conn = data.pool.get().await?; - - let uuid = check_access_token(auth_header, &mut conn).await?; - - global_checks(&data, uuid).await?; - - Member::fetch_one(&mut conn, uuid, guild_uuid).await?; - - let channel: Channel; - - if let Ok(cache_hit) = data.get_cache_key(format!("{}", channel_uuid)).await { - channel = serde_json::from_str(&cache_hit)?; - - data.del_cache_key(format!("{}", channel_uuid)).await?; - } else { - channel = Channel::fetch_one(&mut conn, channel_uuid).await?; - } - - channel.delete(&mut conn).await?; - - Ok(HttpResponse::Ok().finish()) -} diff --git a/src/api/v1/servers/uuid/mod.rs b/src/api/v1/servers/uuid/mod.rs index 39b2925..f874b91 100644 --- a/src/api/v1/servers/uuid/mod.rs +++ b/src/api/v1/servers/uuid/mod.rs @@ -19,10 +19,6 @@ pub fn web() -> Scope { // Channels .service(channels::get) .service(channels::create) - .service(channels::uuid::get) - .service(channels::uuid::delete) - .service(channels::uuid::messages::get) - .service(channels::uuid::socket::ws) // Roles .service(roles::get) .service(roles::create) diff --git a/src/structs.rs b/src/structs.rs index 500396f..8d1d367 100644 --- a/src/structs.rs +++ b/src/structs.rs @@ -183,15 +183,26 @@ impl Channel { futures::future::try_join_all(channel_futures).await } - pub async fn fetch_one(conn: &mut Conn, channel_uuid: Uuid) -> Result { + pub async fn fetch_one(data: &Data, channel_uuid: Uuid) -> Result { + if let Ok(cache_hit) = data.get_cache_key(channel_uuid.to_string()).await { + return Ok(serde_json::from_str(&cache_hit)?); + } + + let mut conn = data.pool.get().await?; + use channels::dsl; let channel_builder: ChannelBuilder = dsl::channels .filter(dsl::uuid.eq(channel_uuid)) .select(ChannelBuilder::as_select()) - .get_result(conn) + .get_result(&mut conn) .await?; - channel_builder.build(conn).await + let channel = channel_builder.build(&mut conn).await?; + + data.set_cache_key(channel_uuid.to_string(), channel.clone(), 60) + .await?; + + Ok(channel) } pub async fn new( @@ -245,19 +256,27 @@ impl Channel { data.set_cache_key(channel_uuid.to_string(), channel.clone(), 1800) .await?; - data.del_cache_key(format!("{}_channels", guild_uuid)) - .await?; + if let Ok(_) = data.get_cache_key(format!("{}_channels", guild_uuid)).await { + data.del_cache_key(format!("{}_channels", guild_uuid)) + .await?; + } Ok(channel) } - pub async fn delete(self, conn: &mut Conn) -> Result<(), Error> { + pub async fn delete(self, data: &Data) -> Result<(), Error> { + let mut conn = data.pool.get().await?; + use channels::dsl; delete(channels::table) .filter(dsl::uuid.eq(self.uuid)) - .execute(conn) + .execute(&mut conn) .await?; + if let Ok(_) = data.get_cache_key(self.uuid.to_string()).await { + data.del_cache_key(self.uuid.to_string()).await?; + } + Ok(()) } From 1543a2f4857d0977dd6eb274f730e8675bf55ec7 Mon Sep 17 00:00:00 2001 From: Radical Date: Thu, 29 May 2025 20:13:01 +0200 Subject: [PATCH 56/92] docs: change path in comments --- src/api/v1/channels/uuid/messages.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/api/v1/channels/uuid/messages.rs b/src/api/v1/channels/uuid/messages.rs index 25134d2..90055c8 100644 --- a/src/api/v1/channels/uuid/messages.rs +++ b/src/api/v1/channels/uuid/messages.rs @@ -1,4 +1,4 @@ -//! `/api/v1/servers/{uuid}/channels/{uuid}/messages` Endpoints related to channel messages +//! `/api/v1/channels/{uuid}/messages` Endpoints related to channel messages use crate::{ api::v1::auth::check_access_token, error::Error, structs::{Channel, Member}, utils::{get_auth_header, global_checks}, Data @@ -13,7 +13,7 @@ struct MessageRequest { offset: i64, } -/// `GET /api/v1/servers/{uuid}/channels/{uuid}/messages` Returns user with the given UUID +/// `GET /api/v1/channels/{uuid}/messages` Returns user with the given UUID /// /// requires auth: yes /// From e4d9a1b5afe07c8e5d6593acd5c34068c5c43bce Mon Sep 17 00:00:00 2001 From: Radical Date: Thu, 29 May 2025 20:15:27 +0200 Subject: [PATCH 57/92] style: move servers to guilds --- src/api/v1/{servers => guilds}/mod.rs | 2 +- src/api/v1/{servers => guilds}/uuid/channels.rs | 0 src/api/v1/{servers => guilds}/uuid/icon.rs | 0 src/api/v1/{servers => guilds}/uuid/invites/id.rs | 0 src/api/v1/{servers => guilds}/uuid/invites/mod.rs | 0 src/api/v1/{servers => guilds}/uuid/mod.rs | 0 src/api/v1/{servers => guilds}/uuid/roles/mod.rs | 0 src/api/v1/{servers => guilds}/uuid/roles/uuid.rs | 0 src/api/v1/mod.rs | 4 ++-- 9 files changed, 3 insertions(+), 3 deletions(-) rename src/api/v1/{servers => guilds}/mod.rs (99%) rename src/api/v1/{servers => guilds}/uuid/channels.rs (100%) rename src/api/v1/{servers => guilds}/uuid/icon.rs (100%) rename src/api/v1/{servers => guilds}/uuid/invites/id.rs (100%) rename src/api/v1/{servers => guilds}/uuid/invites/mod.rs (100%) rename src/api/v1/{servers => guilds}/uuid/mod.rs (100%) rename src/api/v1/{servers => guilds}/uuid/roles/mod.rs (100%) rename src/api/v1/{servers => guilds}/uuid/roles/uuid.rs (100%) diff --git a/src/api/v1/servers/mod.rs b/src/api/v1/guilds/mod.rs similarity index 99% rename from src/api/v1/servers/mod.rs rename to src/api/v1/guilds/mod.rs index f0d27a8..237abce 100644 --- a/src/api/v1/servers/mod.rs +++ b/src/api/v1/guilds/mod.rs @@ -15,7 +15,7 @@ struct GuildInfo { } pub fn web() -> Scope { - web::scope("/servers") + web::scope("/guilds") .service(post) .service(get) .service(uuid::web()) diff --git a/src/api/v1/servers/uuid/channels.rs b/src/api/v1/guilds/uuid/channels.rs similarity index 100% rename from src/api/v1/servers/uuid/channels.rs rename to src/api/v1/guilds/uuid/channels.rs diff --git a/src/api/v1/servers/uuid/icon.rs b/src/api/v1/guilds/uuid/icon.rs similarity index 100% rename from src/api/v1/servers/uuid/icon.rs rename to src/api/v1/guilds/uuid/icon.rs diff --git a/src/api/v1/servers/uuid/invites/id.rs b/src/api/v1/guilds/uuid/invites/id.rs similarity index 100% rename from src/api/v1/servers/uuid/invites/id.rs rename to src/api/v1/guilds/uuid/invites/id.rs diff --git a/src/api/v1/servers/uuid/invites/mod.rs b/src/api/v1/guilds/uuid/invites/mod.rs similarity index 100% rename from src/api/v1/servers/uuid/invites/mod.rs rename to src/api/v1/guilds/uuid/invites/mod.rs diff --git a/src/api/v1/servers/uuid/mod.rs b/src/api/v1/guilds/uuid/mod.rs similarity index 100% rename from src/api/v1/servers/uuid/mod.rs rename to src/api/v1/guilds/uuid/mod.rs diff --git a/src/api/v1/servers/uuid/roles/mod.rs b/src/api/v1/guilds/uuid/roles/mod.rs similarity index 100% rename from src/api/v1/servers/uuid/roles/mod.rs rename to src/api/v1/guilds/uuid/roles/mod.rs diff --git a/src/api/v1/servers/uuid/roles/uuid.rs b/src/api/v1/guilds/uuid/roles/uuid.rs similarity index 100% rename from src/api/v1/servers/uuid/roles/uuid.rs rename to src/api/v1/guilds/uuid/roles/uuid.rs diff --git a/src/api/v1/mod.rs b/src/api/v1/mod.rs index c08e1e3..421c10d 100644 --- a/src/api/v1/mod.rs +++ b/src/api/v1/mod.rs @@ -5,7 +5,7 @@ use actix_web::{Scope, web}; mod auth; mod channels; mod invites; -mod servers; +mod guilds; mod stats; mod users; mod me; @@ -16,7 +16,7 @@ pub fn web() -> Scope { .service(auth::web()) .service(users::web()) .service(channels::web()) - .service(servers::web()) + .service(guilds::web()) .service(invites::web()) .service(me::web()) } From 556337aa4e13617839e70d24e51ba46fbf9db6b2 Mon Sep 17 00:00:00 2001 From: Radical Date: Thu, 29 May 2025 20:16:29 +0200 Subject: [PATCH 58/92] docs: fix paths in guild comments --- src/api/v1/guilds/mod.rs | 4 ++-- src/api/v1/guilds/uuid/icon.rs | 4 ++-- src/api/v1/guilds/uuid/mod.rs | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/api/v1/guilds/mod.rs b/src/api/v1/guilds/mod.rs index 237abce..4c2dbf1 100644 --- a/src/api/v1/guilds/mod.rs +++ b/src/api/v1/guilds/mod.rs @@ -1,4 +1,4 @@ -//! `/api/v1/servers` Guild related endpoints +//! `/api/v1/guilds` Guild related endpoints use actix_web::{HttpRequest, HttpResponse, Scope, get, post, web}; use serde::Deserialize; @@ -21,7 +21,7 @@ pub fn web() -> Scope { .service(uuid::web()) } -/// `POST /api/v1/servers` Creates a new guild +/// `POST /api/v1/guilds` Creates a new guild /// /// requires auth: yes /// diff --git a/src/api/v1/guilds/uuid/icon.rs b/src/api/v1/guilds/uuid/icon.rs index 876aec4..0ac4470 100644 --- a/src/api/v1/guilds/uuid/icon.rs +++ b/src/api/v1/guilds/uuid/icon.rs @@ -1,4 +1,4 @@ -//! `/api/v1/servers/{uuid}/icon` icon related endpoints, will probably be replaced by a multipart post to above endpoint +//! `/api/v1/guilds/{uuid}/icon` icon related endpoints, will probably be replaced by a multipart post to above endpoint use actix_web::{HttpRequest, HttpResponse, put, web}; use futures_util::StreamExt as _; @@ -8,7 +8,7 @@ use crate::{ api::v1::auth::check_access_token, error::Error, structs::{Guild, Member}, utils::{get_auth_header, global_checks}, Data }; -/// `PUT /api/v1/servers/{uuid}/icon` Icon upload +/// `PUT /api/v1/guilds/{uuid}/icon` Icon upload /// /// requires auth: no /// diff --git a/src/api/v1/guilds/uuid/mod.rs b/src/api/v1/guilds/uuid/mod.rs index f874b91..7ab719d 100644 --- a/src/api/v1/guilds/uuid/mod.rs +++ b/src/api/v1/guilds/uuid/mod.rs @@ -1,4 +1,4 @@ -//! `/api/v1/servers/{uuid}` Specific server endpoints +//! `/api/v1/guilds/{uuid}` Specific server endpoints use actix_web::{HttpRequest, HttpResponse, Scope, get, web}; use uuid::Uuid; @@ -30,7 +30,7 @@ pub fn web() -> Scope { .service(icon::upload) } -/// `GET /api/v1/servers/{uuid}` DESCRIPTION +/// `GET /api/v1/guilds/{uuid}` DESCRIPTION /// /// requires auth: yes /// From 3c5f3fd6544ec418b04dcadec4181b255c1362e9 Mon Sep 17 00:00:00 2001 From: Radical Date: Thu, 29 May 2025 20:29:45 +0200 Subject: [PATCH 59/92] style: rename url to frontend_url --- Dockerfile | 3 ++- compose.dev.yml | 2 +- compose.yml | 2 +- entrypoint.sh | 3 ++- src/config.rs | 6 +++--- src/structs.rs | 6 +++--- 6 files changed, 12 insertions(+), 10 deletions(-) diff --git a/Dockerfile b/Dockerfile index a8e8dc6..d7209ef 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,7 +18,8 @@ RUN useradd --create-home --home-dir /gorb gorb USER gorb -ENV WEB_URL=https://gorb.app/web/ \ +ENV WEB_FRONTEND_URL=https://gorb.app/web/ \ +WEB_BASE_PATH=/api \ DATABASE_USERNAME=gorb \ DATABASE_PASSWORD=gorb \ DATABASE=gorb \ diff --git a/compose.dev.yml b/compose.dev.yml index e80f2a7..93a1a85 100644 --- a/compose.dev.yml +++ b/compose.dev.yml @@ -18,7 +18,7 @@ services: - gorb-backend:/gorb environment: #- RUST_LOG=debug - - WEB_URL=https://gorb.app/web/ + - WEB_FRONTEND_URL=https://gorb.app/web/ - DATABASE_USERNAME=gorb - DATABASE_PASSWORD=gorb - DATABASE=gorb diff --git a/compose.yml b/compose.yml index 2bc7339..b1dc07d 100644 --- a/compose.yml +++ b/compose.yml @@ -16,7 +16,7 @@ services: - gorb-backend:/gorb environment: #- RUST_LOG=debug - - WEB_URL=https://gorb.app/web/ + - WEB_FRONTEND_URL=https://gorb.app/web/ - DATABASE_USERNAME=gorb - DATABASE_PASSWORD=gorb - DATABASE=gorb diff --git a/entrypoint.sh b/entrypoint.sh index 9c7a401..38ba890 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -11,7 +11,8 @@ fi if [ ! -f "/gorb/config/config.toml" ]; then cat > /gorb/config/config.toml <, port: Option, - url: Url, + frontend_url: Url, _ssl: Option, } @@ -85,7 +85,7 @@ impl ConfigBuilder { let web = Web { ip: self.web.ip.unwrap_or(String::from("0.0.0.0")), port: self.web.port.unwrap_or(8080), - url: self.web.url, + frontend_url: self.web.frontend_url, }; let endpoint = match &*self.bunny.endpoint { @@ -146,7 +146,7 @@ pub struct Config { pub struct Web { pub ip: String, pub port: u16, - pub url: Url, + pub frontend_url: Url, } #[derive(Debug, Clone)] diff --git a/src/structs.rs b/src/structs.rs index 8d1d367..50615ff 100644 --- a/src/structs.rs +++ b/src/structs.rs @@ -1014,7 +1014,7 @@ impl EmailToken { .execute(&mut conn) .await?; - let mut verify_endpoint = data.config.web.url.join("verify-email")?; + let mut verify_endpoint = data.config.web.frontend_url.join("verify-email")?; verify_endpoint.set_query(Some(&format!("token={}", token))); @@ -1104,7 +1104,7 @@ impl PasswordResetToken { .execute(&mut conn) .await?; - let mut reset_endpoint = data.config.web.url.join("reset-password")?; + let mut reset_endpoint = data.config.web.frontend_url.join("reset-password")?; reset_endpoint.set_query(Some(&format!("token={}", token))); @@ -1153,7 +1153,7 @@ impl PasswordResetToken { .get_result(&mut conn) .await?; - let login_page = data.config.web.url.join("login")?; + let login_page = data.config.web.frontend_url.join("login")?; let email = data .mail_client From 94c4428bb0d0eca3e3da1a9ac29148017df6c3ba Mon Sep 17 00:00:00 2001 From: Radical Date: Thu, 29 May 2025 20:41:50 +0200 Subject: [PATCH 60/92] feat: add base_path to api Lets you replace /api with whatever you want! --- src/api/mod.rs | 4 ++-- src/config.rs | 3 +++ src/main.rs | 2 +- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/api/mod.rs b/src/api/mod.rs index 87c1c14..4cad2fa 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -6,6 +6,6 @@ use actix_web::web; mod v1; mod versions; -pub fn web() -> Scope { - web::scope("/api").service(v1::web()).service(versions::get) +pub fn web(path: &str) -> Scope { + web::scope(path.trim_end_matches('/')).service(v1::web()).service(versions::get) } diff --git a/src/config.rs b/src/config.rs index 8a274b4..464c98d 100644 --- a/src/config.rs +++ b/src/config.rs @@ -38,6 +38,7 @@ pub struct CacheDatabase { struct WebBuilder { ip: Option, port: Option, + base_path: Option, frontend_url: Url, _ssl: Option, } @@ -85,6 +86,7 @@ impl ConfigBuilder { let web = Web { ip: self.web.ip.unwrap_or(String::from("0.0.0.0")), port: self.web.port.unwrap_or(8080), + base_path: self.web.base_path.unwrap_or("".to_string()), frontend_url: self.web.frontend_url, }; @@ -146,6 +148,7 @@ pub struct Config { pub struct Web { pub ip: String, pub port: u16, + pub base_path: String, pub frontend_url: Url, } diff --git a/src/main.rs b/src/main.rs index 0f94be8..5670e7b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -150,7 +150,7 @@ async fn main() -> Result<(), Error> { App::new() .app_data(web::Data::new(data.clone())) .wrap(cors) - .service(api::web()) + .service(api::web(&data.config.web.base_path)) }) .bind((web.ip, web.port))? .run() From 55e343507e20e2ca5d88915e164249df324e51d1 Mon Sep 17 00:00:00 2001 From: Radical Date: Fri, 30 May 2025 08:37:45 +0000 Subject: [PATCH 61/92] style: move /me/servers to /me/guilds --- src/api/v1/me/{servers.rs => guilds.rs} | 6 +++--- src/api/v1/me/mod.rs | 7 +++++-- 2 files changed, 8 insertions(+), 5 deletions(-) rename src/api/v1/me/{servers.rs => guilds.rs} (89%) diff --git a/src/api/v1/me/servers.rs b/src/api/v1/me/guilds.rs similarity index 89% rename from src/api/v1/me/servers.rs rename to src/api/v1/me/guilds.rs index ae026a7..1e92a34 100644 --- a/src/api/v1/me/servers.rs +++ b/src/api/v1/me/guilds.rs @@ -1,11 +1,11 @@ -//! `/api/v1/me/servers` Contains endpoint related to guild memberships +//! `/api/v1/me/guilds` Contains endpoint related to guild memberships use actix_web::{get, web, HttpRequest, HttpResponse}; use crate::{api::v1::auth::check_access_token, error::Error, structs::Me, utils::{get_auth_header, global_checks}, Data}; -/// `GET /api/v1/me/servers` Returns all guild memberships in a list +/// `GET /api/v1/me/guilds` Returns all guild memberships in a list /// /// requires auth: yes /// @@ -27,7 +27,7 @@ use crate::{api::v1::auth::check_access_token, error::Error, structs::Me, utils: /// ]); /// ``` /// NOTE: UUIDs in this response are made using `uuidgen`, UUIDs made by the actual backend will be UUIDv7 and have extractable timestamps -#[get("/servers")] +#[get("/guilds")] pub async fn get(req: HttpRequest, data: web::Data) -> Result { let headers = req.headers(); diff --git a/src/api/v1/me/mod.rs b/src/api/v1/me/mod.rs index cd86e43..ac24342 100644 --- a/src/api/v1/me/mod.rs +++ b/src/api/v1/me/mod.rs @@ -6,10 +6,13 @@ use crate::{ api::v1::auth::check_access_token, error::Error, structs::Me, utils::{get_auth_header, global_checks}, Data }; -mod servers; +mod guilds; pub fn web() -> Scope { - web::scope("/me").service(get).service(update) + web::scope("/me") + .service(get) + .service(update) + .service(guilds::get) } #[get("")] From 746285e0fb09b2b906a5a9f9dc5c009d62e25f6c Mon Sep 17 00:00:00 2001 From: Radical Date: Fri, 30 May 2025 21:11:13 +0200 Subject: [PATCH 62/92] fix: make build number display! --- build.rs | 13 +++++++++++++ src/api/v1/stats.rs | 3 ++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/build.rs b/build.rs index 3a8149e..2bf0bd2 100644 --- a/build.rs +++ b/build.rs @@ -1,3 +1,16 @@ +use std::process::Command; + fn main() { println!("cargo:rerun-if-changed=migrations"); + + let git_short_hash = Command::new("git") + .args(["rev-parse", "--short", "HEAD"]) + .output() + .ok() + .and_then(|o| String::from_utf8(o.stdout).ok()) + .map(|s| s.trim().to_string()) // Trim newline + .unwrap_or_else(|| "UNKNOWN".to_string()); + + // Tell Cargo to set `GIT_SHORT_HASH` for the main compilation + println!("cargo:rustc-env=GIT_SHORT_HASH={}", git_short_hash); } diff --git a/src/api/v1/stats.rs b/src/api/v1/stats.rs index bf2bec3..30888aa 100644 --- a/src/api/v1/stats.rs +++ b/src/api/v1/stats.rs @@ -12,6 +12,7 @@ use crate::error::Error; use crate::schema::users::dsl::{users, uuid}; const VERSION: Option<&'static str> = option_env!("CARGO_PKG_VERSION"); +const GIT_SHORT_HASH: &str = env!("GIT_SHORT_HASH"); #[derive(Serialize)] struct Response { @@ -57,7 +58,7 @@ pub async fn res(data: web::Data) -> Result { registration_enabled: data.config.instance.registration, email_verification_required: data.config.instance.require_email_verification, // TODO: Get build number from git hash or remove this from the spec - build_number: String::from("how do i implement this?"), + build_number: String::from(GIT_SHORT_HASH), }; Ok(HttpResponse::Ok().json(response)) From c9a3e8c6c41b43524c934bdf43bd0489b1939a51 Mon Sep 17 00:00:00 2001 From: Radical Date: Fri, 30 May 2025 21:12:07 +0200 Subject: [PATCH 63/92] feat: add /guilds/{uuid}members Also makes it return user object with the query --- src/api/v1/channels/uuid/messages.rs | 2 +- src/api/v1/channels/uuid/mod.rs | 4 +- src/api/v1/channels/uuid/socket.rs | 3 +- src/api/v1/guilds/uuid/channels.rs | 4 +- src/api/v1/guilds/uuid/icon.rs | 2 +- src/api/v1/guilds/uuid/invites/mod.rs | 6 +- src/api/v1/guilds/uuid/members.rs | 30 +++++++ src/api/v1/guilds/uuid/mod.rs | 5 +- src/api/v1/guilds/uuid/roles/mod.rs | 4 +- src/api/v1/guilds/uuid/roles/uuid.rs | 2 +- src/api/v1/invites/id.rs | 2 +- src/api/v1/me/guilds.rs | 2 +- src/structs.rs | 117 ++++++++++++++++++++------ 13 files changed, 141 insertions(+), 42 deletions(-) create mode 100644 src/api/v1/guilds/uuid/members.rs diff --git a/src/api/v1/channels/uuid/messages.rs b/src/api/v1/channels/uuid/messages.rs index 90055c8..ea5377e 100644 --- a/src/api/v1/channels/uuid/messages.rs +++ b/src/api/v1/channels/uuid/messages.rs @@ -64,7 +64,7 @@ pub async fn get( let channel = Channel::fetch_one(&data, channel_uuid).await?; - Member::fetch_one(&mut conn, uuid, channel.guild_uuid).await?; + Member::check_membership(&mut conn, uuid, channel.guild_uuid).await?; let messages = channel .fetch_messages(&data, message_request.amount, message_request.offset) diff --git a/src/api/v1/channels/uuid/mod.rs b/src/api/v1/channels/uuid/mod.rs index f6c93fe..4e81142 100644 --- a/src/api/v1/channels/uuid/mod.rs +++ b/src/api/v1/channels/uuid/mod.rs @@ -27,7 +27,7 @@ pub async fn get( let channel = Channel::fetch_one(&data, channel_uuid).await?; - Member::fetch_one(&mut conn, uuid, channel.guild_uuid).await?; + Member::check_membership(&mut conn, uuid, channel.guild_uuid).await?; Ok(HttpResponse::Ok().json(channel)) } @@ -52,7 +52,7 @@ pub async fn delete( let channel = Channel::fetch_one(&data, channel_uuid).await?; - Member::fetch_one(&mut conn, uuid, channel.guild_uuid).await?; + Member::check_membership(&mut conn, uuid, channel.guild_uuid).await?; channel.delete(&data).await?; diff --git a/src/api/v1/channels/uuid/socket.rs b/src/api/v1/channels/uuid/socket.rs index c7ca1e8..34e0fdc 100644 --- a/src/api/v1/channels/uuid/socket.rs +++ b/src/api/v1/channels/uuid/socket.rs @@ -36,8 +36,7 @@ pub async fn ws( let channel = Channel::fetch_one(&data, channel_uuid).await?; - // Get server member from psql - Member::fetch_one(&mut conn, uuid, channel.guild_uuid).await?; + Member::check_membership(&mut conn, uuid, channel.guild_uuid).await?; let (mut res, mut session_1, stream) = actix_ws::handle(&req, stream)?; diff --git a/src/api/v1/guilds/uuid/channels.rs b/src/api/v1/guilds/uuid/channels.rs index 813de13..2d01e2b 100644 --- a/src/api/v1/guilds/uuid/channels.rs +++ b/src/api/v1/guilds/uuid/channels.rs @@ -29,7 +29,7 @@ pub async fn get( global_checks(&data, uuid).await?; - Member::fetch_one(&mut conn, uuid, guild_uuid).await?; + Member::check_membership(&mut conn, uuid, guild_uuid).await?; if let Ok(cache_hit) = data.get_cache_key(format!("{}_channels", guild_uuid)).await { return Ok(HttpResponse::Ok() @@ -70,7 +70,7 @@ pub async fn create( global_checks(&data, uuid).await?; - Member::fetch_one(&mut conn, uuid, guild_uuid).await?; + Member::check_membership(&mut conn, uuid, guild_uuid).await?; // FIXME: Logic to check permissions, should probably be done in utils.rs diff --git a/src/api/v1/guilds/uuid/icon.rs b/src/api/v1/guilds/uuid/icon.rs index 0ac4470..ae71321 100644 --- a/src/api/v1/guilds/uuid/icon.rs +++ b/src/api/v1/guilds/uuid/icon.rs @@ -32,7 +32,7 @@ pub async fn upload( global_checks(&data, uuid).await?; - Member::fetch_one(&mut conn, uuid, guild_uuid).await?; + Member::check_membership(&mut conn, uuid, guild_uuid).await?; let mut guild = Guild::fetch_one(&mut conn, guild_uuid).await?; diff --git a/src/api/v1/guilds/uuid/invites/mod.rs b/src/api/v1/guilds/uuid/invites/mod.rs index 83ea08d..e985625 100644 --- a/src/api/v1/guilds/uuid/invites/mod.rs +++ b/src/api/v1/guilds/uuid/invites/mod.rs @@ -29,7 +29,7 @@ pub async fn get( global_checks(&data, uuid).await?; - Member::fetch_one(&mut conn, uuid, guild_uuid).await?; + Member::check_membership(&mut conn, uuid, guild_uuid).await?; let guild = Guild::fetch_one(&mut conn, guild_uuid).await?; @@ -57,13 +57,13 @@ pub async fn create( global_checks(&data, uuid).await?; - let member = Member::fetch_one(&mut conn, uuid, guild_uuid).await?; + Member::check_membership(&mut conn, uuid, guild_uuid).await?; let guild = Guild::fetch_one(&mut conn, guild_uuid).await?; let custom_id = invite_request.as_ref().map(|ir| ir.custom_id.clone()); - let invite = guild.create_invite(&mut conn, &member, custom_id).await?; + let invite = guild.create_invite(&mut conn, uuid, custom_id).await?; Ok(HttpResponse::Ok().json(invite)) } diff --git a/src/api/v1/guilds/uuid/members.rs b/src/api/v1/guilds/uuid/members.rs new file mode 100644 index 0000000..2cc416a --- /dev/null +++ b/src/api/v1/guilds/uuid/members.rs @@ -0,0 +1,30 @@ +use crate::{ + api::v1::auth::check_access_token, error::Error, structs::Member, utils::{get_auth_header, global_checks}, Data +}; +use ::uuid::Uuid; +use actix_web::{HttpRequest, HttpResponse, get, web}; + +#[get("{uuid}/members")] +pub async fn get( + req: HttpRequest, + path: web::Path<(Uuid,)>, + data: web::Data, +) -> Result { + let headers = req.headers(); + + let auth_header = get_auth_header(headers)?; + + let guild_uuid = path.into_inner().0; + + let mut conn = data.pool.get().await?; + + let uuid = check_access_token(auth_header, &mut conn).await?; + + global_checks(&data, uuid).await?; + + Member::check_membership(&mut conn, uuid, guild_uuid).await?; + + let members = Member::fetch_all(&data, guild_uuid).await?; + + Ok(HttpResponse::Ok().json(members)) +} diff --git a/src/api/v1/guilds/uuid/mod.rs b/src/api/v1/guilds/uuid/mod.rs index 7ab719d..69c3f31 100644 --- a/src/api/v1/guilds/uuid/mod.rs +++ b/src/api/v1/guilds/uuid/mod.rs @@ -7,6 +7,7 @@ mod channels; mod icon; mod invites; mod roles; +mod members; use crate::{ api::v1::auth::check_access_token, error::Error, structs::{Guild, Member}, utils::{get_auth_header, global_checks}, Data @@ -28,6 +29,8 @@ pub fn web() -> Scope { .service(invites::create) // Icon .service(icon::upload) + // Members + .service(members::get) } /// `GET /api/v1/guilds/{uuid}` DESCRIPTION @@ -81,7 +84,7 @@ pub async fn get( global_checks(&data, uuid).await?; - Member::fetch_one(&mut conn, uuid, guild_uuid).await?; + Member::check_membership(&mut conn, uuid, guild_uuid).await?; let guild = Guild::fetch_one(&mut conn, guild_uuid).await?; diff --git a/src/api/v1/guilds/uuid/roles/mod.rs b/src/api/v1/guilds/uuid/roles/mod.rs index 3ae9c5b..9eb6349 100644 --- a/src/api/v1/guilds/uuid/roles/mod.rs +++ b/src/api/v1/guilds/uuid/roles/mod.rs @@ -29,7 +29,7 @@ pub async fn get( let uuid = check_access_token(auth_header, &mut conn).await?; - Member::fetch_one(&mut conn, uuid, guild_uuid).await?; + Member::check_membership(&mut conn, uuid, guild_uuid).await?; if let Ok(cache_hit) = data.get_cache_key(format!("{}_roles", guild_uuid)).await { return Ok(HttpResponse::Ok() @@ -66,7 +66,7 @@ pub async fn create( global_checks(&data, uuid).await?; - Member::fetch_one(&mut conn, uuid, guild_uuid).await?; + Member::check_membership(&mut conn, uuid, guild_uuid).await?; // FIXME: Logic to check permissions, should probably be done in utils.rs diff --git a/src/api/v1/guilds/uuid/roles/uuid.rs b/src/api/v1/guilds/uuid/roles/uuid.rs index 21ab748..9e7853f 100644 --- a/src/api/v1/guilds/uuid/roles/uuid.rs +++ b/src/api/v1/guilds/uuid/roles/uuid.rs @@ -22,7 +22,7 @@ pub async fn get( global_checks(&data, uuid).await?; - Member::fetch_one(&mut conn, uuid, guild_uuid).await?; + Member::check_membership(&mut conn, uuid, guild_uuid).await?; if let Ok(cache_hit) = data.get_cache_key(format!("{}", role_uuid)).await { return Ok(HttpResponse::Ok() diff --git a/src/api/v1/invites/id.rs b/src/api/v1/invites/id.rs index f7e4a08..6ddc53f 100644 --- a/src/api/v1/invites/id.rs +++ b/src/api/v1/invites/id.rs @@ -42,7 +42,7 @@ pub async fn join( let guild = Guild::fetch_one(&mut conn, invite.guild_uuid).await?; - Member::new(&mut conn, uuid, guild.uuid).await?; + Member::new(&data, uuid, guild.uuid).await?; Ok(HttpResponse::Ok().json(guild)) } diff --git a/src/api/v1/me/guilds.rs b/src/api/v1/me/guilds.rs index 1e92a34..516fc35 100644 --- a/src/api/v1/me/guilds.rs +++ b/src/api/v1/me/guilds.rs @@ -41,7 +41,7 @@ pub async fn get(req: HttpRequest, data: web::Data) -> Result, ) -> Result { let invite_id; @@ -530,7 +530,7 @@ impl Guild { let invite = Invite { id: invite_id, - user_uuid: member.user_uuid, + user_uuid, guild_uuid: self.uuid, }; @@ -666,11 +666,34 @@ impl Role { #[derive(Serialize, Queryable, Selectable, Insertable)] #[diesel(table_name = guild_members)] #[diesel(check_for_backend(diesel::pg::Pg))] +pub struct MemberBuilder { + pub uuid: Uuid, + pub nickname: Option, + pub user_uuid: Uuid, + pub guild_uuid: Uuid, +} + +impl MemberBuilder { + async fn build(&self, data: &Data) -> Result { + let user = User::fetch_one(data, self.user_uuid).await?; + + Ok(Member { + uuid: self.uuid, + nickname: self.nickname.clone(), + user_uuid: self.user_uuid, + guild_uuid: self.guild_uuid, + user, + }) + } +} + +#[derive(Serialize, Deserialize)] pub struct Member { pub uuid: Uuid, pub nickname: Option, pub user_uuid: Uuid, pub guild_uuid: Uuid, + user: User, } impl Member { @@ -685,26 +708,61 @@ impl Member { Ok(count) } - pub async fn fetch_one( - conn: &mut Conn, - user_uuid: Uuid, - guild_uuid: Uuid, - ) -> Result { + pub async fn check_membership(conn: &mut Conn, user_uuid: Uuid, guild_uuid: Uuid) -> Result<(), Error> { use guild_members::dsl; - let member: Member = dsl::guild_members + dsl::guild_members .filter(dsl::user_uuid.eq(user_uuid)) .filter(dsl::guild_uuid.eq(guild_uuid)) - .select(Member::as_select()) + .select(MemberBuilder::as_select()) .get_result(conn) .await?; - Ok(member) + Ok(()) } - pub async fn new(conn: &mut Conn, user_uuid: Uuid, guild_uuid: Uuid) -> Result { + pub async fn fetch_one( + data: &Data, + user_uuid: Uuid, + guild_uuid: Uuid, + ) -> Result { + let mut conn = data.pool.get().await?; + + use guild_members::dsl; + let member: MemberBuilder = dsl::guild_members + .filter(dsl::user_uuid.eq(user_uuid)) + .filter(dsl::guild_uuid.eq(guild_uuid)) + .select(MemberBuilder::as_select()) + .get_result(&mut conn) + .await?; + + member.build(data).await + } + + pub async fn fetch_all(data: &Data, guild_uuid: Uuid) -> Result, Error> { + let mut conn = data.pool.get().await?; + + use guild_members::dsl; + let member_builders: Vec = load_or_empty( + dsl::guild_members + .filter(dsl::guild_uuid.eq(guild_uuid)) + .select(MemberBuilder::as_select()) + .load(&mut conn) + .await + )?; + + let member_futures = member_builders.iter().map(async move |m| { + m.build(data).await + }); + + futures::future::try_join_all(member_futures).await + } + + pub async fn new(data: &Data, user_uuid: Uuid, guild_uuid: Uuid) -> Result { + let mut conn = data.pool.get().await?; + let member_uuid = Uuid::now_v7(); - let member = Member { + let member = MemberBuilder { uuid: member_uuid, guild_uuid, user_uuid, @@ -712,16 +770,11 @@ impl Member { }; insert_into(guild_members::table) - .values(member) - .execute(conn) + .values(&member) + .execute(&mut conn) .await?; - Ok(Self { - uuid: member_uuid, - nickname: None, - user_uuid, - guild_uuid, - }) + member.build(data).await } } @@ -856,14 +909,28 @@ impl Me { Ok(me) } - pub async fn fetch_memberships(&self, conn: &mut Conn) -> Result, Error> { + pub async fn fetch_memberships(&self, data: &Data) -> Result, Error> { + let mut conn = data.pool.get().await?; + use guild_members::dsl; - let memberships: Vec = dsl::guild_members + let member_builders: Vec = dsl::guild_members .filter(dsl::user_uuid.eq(self.uuid)) - .select(Member::as_select()) - .load(conn) + .select(MemberBuilder::as_select()) + .load(&mut conn) .await?; + let user = User::fetch_one(data, self.uuid).await?; + + let memberships = member_builders.iter().map(|m| { + Member { + uuid: m.uuid, + nickname: m.nickname.clone(), + user_uuid: m.user_uuid, + guild_uuid: m.guild_uuid, + user: user.clone(), + } + }).collect(); + Ok(memberships) } From d615f1392ea72832e5656580c58a3a29dbd13465 Mon Sep 17 00:00:00 2001 From: Radical Date: Fri, 30 May 2025 21:17:30 +0200 Subject: [PATCH 64/92] style: cargo clippy && cargo fmt --- build.rs | 2 +- src/api/mod.rs | 4 +- src/api/v1/auth/login.rs | 4 +- src/api/v1/auth/mod.rs | 2 +- src/api/v1/auth/refresh.rs | 2 - src/api/v1/auth/reset_password.rs | 40 ++++---- src/api/v1/auth/verify_email.rs | 31 +++--- src/api/v1/channels/mod.rs | 2 +- src/api/v1/channels/uuid/messages.rs | 16 ++-- src/api/v1/channels/uuid/mod.rs | 6 +- src/api/v1/channels/uuid/socket.rs | 9 +- src/api/v1/guilds/mod.rs | 25 +++-- src/api/v1/guilds/uuid/channels.rs | 6 +- src/api/v1/guilds/uuid/icon.rs | 10 +- src/api/v1/guilds/uuid/invites/mod.rs | 6 +- src/api/v1/guilds/uuid/members.rs | 6 +- src/api/v1/guilds/uuid/mod.rs | 14 ++- src/api/v1/guilds/uuid/roles/mod.rs | 6 +- src/api/v1/guilds/uuid/roles/uuid.rs | 6 +- src/api/v1/invites/id.rs | 11 ++- src/api/v1/me/guilds.rs | 17 ++-- src/api/v1/me/mod.rs | 14 ++- src/api/v1/mod.rs | 4 +- src/api/v1/stats.rs | 4 +- src/api/v1/users/mod.rs | 16 ++-- src/api/v1/users/uuid.rs | 12 ++- src/api/versions.rs | 4 +- src/error.rs | 4 +- src/main.rs | 9 +- src/structs.rs | 130 ++++++++++++++++---------- src/utils.rs | 47 ++++++---- 31 files changed, 288 insertions(+), 181 deletions(-) diff --git a/build.rs b/build.rs index 2bf0bd2..45de5ff 100644 --- a/build.rs +++ b/build.rs @@ -8,7 +8,7 @@ fn main() { .output() .ok() .and_then(|o| String::from_utf8(o.stdout).ok()) - .map(|s| s.trim().to_string()) // Trim newline + .map(|s| s.trim().to_string()) // Trim newline .unwrap_or_else(|| "UNKNOWN".to_string()); // Tell Cargo to set `GIT_SHORT_HASH` for the main compilation diff --git a/src/api/mod.rs b/src/api/mod.rs index 4cad2fa..6d83e02 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -7,5 +7,7 @@ mod v1; mod versions; pub fn web(path: &str) -> Scope { - web::scope(path.trim_end_matches('/')).service(v1::web()).service(versions::get) + web::scope(path.trim_end_matches('/')) + .service(v1::web()) + .service(versions::get) } diff --git a/src/api/v1/auth/login.rs b/src/api/v1/auth/login.rs index 254b913..5229425 100644 --- a/src/api/v1/auth/login.rs +++ b/src/api/v1/auth/login.rs @@ -11,8 +11,8 @@ use crate::{ error::Error, schema::*, utils::{ - PASSWORD_REGEX, generate_access_token, generate_refresh_token, - refresh_token_cookie, user_uuid_from_identifier + PASSWORD_REGEX, generate_access_token, generate_refresh_token, refresh_token_cookie, + user_uuid_from_identifier, }, }; diff --git a/src/api/v1/auth/mod.rs b/src/api/v1/auth/mod.rs index cabe114..d627a59 100644 --- a/src/api/v1/auth/mod.rs +++ b/src/api/v1/auth/mod.rs @@ -11,9 +11,9 @@ use crate::{Conn, error::Error, schema::access_tokens::dsl}; mod login; mod refresh; mod register; +mod reset_password; mod revoke; mod verify_email; -mod reset_password; #[derive(Serialize)] struct Response { diff --git a/src/api/v1/auth/refresh.rs b/src/api/v1/auth/refresh.rs index fceabf5..b64b10e 100644 --- a/src/api/v1/auth/refresh.rs +++ b/src/api/v1/auth/refresh.rs @@ -61,8 +61,6 @@ pub async fn res(req: HttpRequest, data: web::Data) -> Result 1987200 { let new_refresh_token = generate_refresh_token()?; - let new_refresh_token = new_refresh_token; - match update(refresh_tokens::table) .filter(rdsl::token.eq(&refresh_token)) .set(( diff --git a/src/api/v1/auth/reset_password.rs b/src/api/v1/auth/reset_password.rs index 6c6dee7..8240fbd 100644 --- a/src/api/v1/auth/reset_password.rs +++ b/src/api/v1/auth/reset_password.rs @@ -4,9 +4,7 @@ use actix_web::{HttpResponse, get, post, web}; use chrono::{Duration, Utc}; use serde::Deserialize; -use crate::{ - error::Error, structs::PasswordResetToken, Data -}; +use crate::{Data, error::Error, structs::PasswordResetToken}; #[derive(Deserialize)] struct Query { @@ -14,30 +12,31 @@ struct Query { } /// `GET /api/v1/auth/reset-password` Sends password reset email to user -/// +/// /// requires auth? no -/// +/// /// ### Query Parameters /// identifier: Email or username -/// +/// /// ### Responses /// 200 Email sent /// 429 Too Many Requests /// 404 Not found /// 400 Bad request -/// +/// #[get("/reset-password")] -pub async fn get( - query: web::Query, - data: web::Data, -) -> Result { +pub async fn get(query: web::Query, data: web::Data) -> Result { let mut conn = data.pool.get().await?; - if let Ok(password_reset_token) = PasswordResetToken::get_with_identifier(&mut conn, query.identifier.clone()).await { + if let Ok(password_reset_token) = + PasswordResetToken::get_with_identifier(&mut conn, query.identifier.clone()).await + { if Utc::now().signed_duration_since(password_reset_token.created_at) > Duration::hours(1) { password_reset_token.delete(&mut conn).await?; } else { - return Err(Error::TooManyRequests("Please allow 1 hour before sending a new email".to_string())) + return Err(Error::TooManyRequests( + "Please allow 1 hour before sending a new email".to_string(), + )); } } @@ -53,9 +52,9 @@ struct ResetPassword { } /// `POST /api/v1/auth/reset-password` Resets user password -/// +/// /// requires auth? no -/// +/// /// ### Request Example: /// ``` /// json!({ @@ -63,13 +62,13 @@ struct ResetPassword { /// "token": "a3f7e29c1b8d0456e2c9f83b7a1d6e4f5028c3b9a7e1f2d5c6b8a0d3e7f4a2b" /// }); /// ``` -/// +/// /// ### Responses /// 200 Success /// 410 Token Expired /// 404 Not Found /// 400 Bad Request -/// +/// #[post("/reset-password")] pub async fn post( reset_password: web::Json, @@ -77,14 +76,17 @@ pub async fn post( ) -> Result { let mut conn = data.pool.get().await?; - let password_reset_token = PasswordResetToken::get(&mut conn, reset_password.token.clone()).await?; + let password_reset_token = + PasswordResetToken::get(&mut conn, reset_password.token.clone()).await?; if Utc::now().signed_duration_since(password_reset_token.created_at) > Duration::hours(24) { password_reset_token.delete(&mut conn).await?; return Ok(HttpResponse::Gone().finish()); } - password_reset_token.set_password(&data, reset_password.password.clone()).await?; + password_reset_token + .set_password(&data, reset_password.password.clone()) + .await?; Ok(HttpResponse::Ok().finish()) } diff --git a/src/api/v1/auth/verify_email.rs b/src/api/v1/auth/verify_email.rs index d8df8c3..c5c9097 100644 --- a/src/api/v1/auth/verify_email.rs +++ b/src/api/v1/auth/verify_email.rs @@ -5,7 +5,11 @@ use chrono::{Duration, Utc}; use serde::Deserialize; use crate::{ - api::v1::auth::check_access_token, error::Error, structs::{EmailToken, Me}, utils::get_auth_header, Data + Data, + api::v1::auth::check_access_token, + error::Error, + structs::{EmailToken, Me}, + utils::get_auth_header, }; #[derive(Deserialize)] @@ -14,18 +18,18 @@ struct Query { } /// `GET /api/v1/auth/verify-email` Verifies user email address -/// +/// /// requires auth? yes -/// +/// /// ### Query Parameters /// token -/// +/// /// ### Responses /// 200 Success /// 410 Token Expired /// 404 Not Found /// 401 Unauthorized -/// +/// #[get("/verify-email")] pub async fn get( req: HttpRequest, @@ -61,20 +65,17 @@ pub async fn get( } /// `POST /api/v1/auth/verify-email` Sends user verification email -/// +/// /// requires auth? yes -/// +/// /// ### Responses /// 200 Email sent /// 204 Already verified /// 429 Too Many Requests /// 401 Unauthorized -/// +/// #[post("/verify-email")] -pub async fn post( - req: HttpRequest, - data: web::Data, -) -> Result { +pub async fn post(req: HttpRequest, data: web::Data) -> Result { let headers = req.headers(); let auth_header = get_auth_header(headers)?; @@ -86,14 +87,16 @@ pub async fn post( let me = Me::get(&mut conn, uuid).await?; if me.email_verified { - return Ok(HttpResponse::NoContent().finish()) + return Ok(HttpResponse::NoContent().finish()); } if let Ok(email_token) = EmailToken::get(&mut conn, me.uuid).await { if Utc::now().signed_duration_since(email_token.created_at) > Duration::hours(1) { email_token.delete(&mut conn).await?; } else { - return Err(Error::TooManyRequests("Please allow 1 hour before sending a new email".to_string())) + return Err(Error::TooManyRequests( + "Please allow 1 hour before sending a new email".to_string(), + )); } } diff --git a/src/api/v1/channels/mod.rs b/src/api/v1/channels/mod.rs index d3d5d23..999bb23 100644 --- a/src/api/v1/channels/mod.rs +++ b/src/api/v1/channels/mod.rs @@ -1,4 +1,4 @@ -use actix_web::{web, Scope}; +use actix_web::{Scope, web}; mod uuid; diff --git a/src/api/v1/channels/uuid/messages.rs b/src/api/v1/channels/uuid/messages.rs index ea5377e..ddcc800 100644 --- a/src/api/v1/channels/uuid/messages.rs +++ b/src/api/v1/channels/uuid/messages.rs @@ -1,7 +1,11 @@ //! `/api/v1/channels/{uuid}/messages` Endpoints related to channel messages use crate::{ - api::v1::auth::check_access_token, error::Error, structs::{Channel, Member}, utils::{get_auth_header, global_checks}, Data + Data, + api::v1::auth::check_access_token, + error::Error, + structs::{Channel, Member}, + utils::{get_auth_header, global_checks}, }; use ::uuid::Uuid; use actix_web::{HttpRequest, HttpResponse, get, web}; @@ -14,11 +18,11 @@ struct MessageRequest { } /// `GET /api/v1/channels/{uuid}/messages` Returns user with the given UUID -/// +/// /// requires auth: yes -/// +/// /// requires relation: yes -/// +/// /// ### Request Example /// ``` /// json!({ @@ -26,7 +30,7 @@ struct MessageRequest { /// "offset": 0 /// }) /// ``` -/// +/// /// ### Response Example /// ``` /// json!({ @@ -42,7 +46,7 @@ struct MessageRequest { /// } /// }); /// ``` -/// +/// #[get("/{uuid}/messages")] pub async fn get( req: HttpRequest, diff --git a/src/api/v1/channels/uuid/mod.rs b/src/api/v1/channels/uuid/mod.rs index 4e81142..f429159 100644 --- a/src/api/v1/channels/uuid/mod.rs +++ b/src/api/v1/channels/uuid/mod.rs @@ -2,7 +2,11 @@ pub mod messages; pub mod socket; use crate::{ - api::v1::auth::check_access_token, error::Error, structs::{Channel, Member}, utils::{get_auth_header, global_checks}, Data + Data, + api::v1::auth::check_access_token, + error::Error, + structs::{Channel, Member}, + utils::{get_auth_header, global_checks}, }; use actix_web::{HttpRequest, HttpResponse, delete, get, web}; use uuid::Uuid; diff --git a/src/api/v1/channels/uuid/socket.rs b/src/api/v1/channels/uuid/socket.rs index 34e0fdc..556dca3 100644 --- a/src/api/v1/channels/uuid/socket.rs +++ b/src/api/v1/channels/uuid/socket.rs @@ -8,7 +8,10 @@ use futures_util::StreamExt as _; use uuid::Uuid; use crate::{ - api::v1::auth::check_access_token, structs::{Channel, Member}, utils::{get_ws_protocol_header, global_checks}, Data + Data, + api::v1::auth::check_access_token, + structs::{Channel, Member}, + utils::{get_ws_protocol_header, global_checks}, }; #[get("/{uuid}/socket")] @@ -71,9 +74,7 @@ pub async fn ws( Ok(AggregatedMessage::Text(text)) => { let mut conn = data.cache_pool.get_multiplexed_tokio_connection().await?; - let message = channel - .new_message(&data, uuid, text.to_string()) - .await?; + let message = channel.new_message(&data, uuid, text.to_string()).await?; redis::cmd("PUBLISH") .arg(&[channel_uuid.to_string(), serde_json::to_string(&message)?]) diff --git a/src/api/v1/guilds/mod.rs b/src/api/v1/guilds/mod.rs index 4c2dbf1..b7a7a7c 100644 --- a/src/api/v1/guilds/mod.rs +++ b/src/api/v1/guilds/mod.rs @@ -6,7 +6,11 @@ use serde::Deserialize; mod uuid; use crate::{ - api::v1::auth::check_access_token, error::Error, structs::{Guild, StartAmountQuery}, utils::{get_auth_header, global_checks}, Data + Data, + api::v1::auth::check_access_token, + error::Error, + structs::{Guild, StartAmountQuery}, + utils::{get_auth_header, global_checks}, }; #[derive(Deserialize)] @@ -22,16 +26,16 @@ pub fn web() -> Scope { } /// `POST /api/v1/guilds` Creates a new guild -/// +/// /// requires auth: yes -/// +/// /// ### Request Example /// ``` /// json!({ /// "name": "My new server!" /// }); /// ``` -/// +/// /// ### Response Example /// ``` /// json!({ @@ -59,22 +63,17 @@ pub async fn post( let uuid = check_access_token(auth_header, &mut conn).await?; - let guild = Guild::new( - &mut conn, - guild_info.name.clone(), - uuid, - ) - .await?; + let guild = Guild::new(&mut conn, guild_info.name.clone(), uuid).await?; Ok(HttpResponse::Ok().json(guild)) } /// `GET /api/v1/servers` Fetches all guilds -/// +/// /// requires auth: yes -/// +/// /// requires admin: yes -/// +/// /// ### Response Example /// ``` /// json!([ diff --git a/src/api/v1/guilds/uuid/channels.rs b/src/api/v1/guilds/uuid/channels.rs index 2d01e2b..0dd4566 100644 --- a/src/api/v1/guilds/uuid/channels.rs +++ b/src/api/v1/guilds/uuid/channels.rs @@ -1,5 +1,9 @@ use crate::{ - api::v1::auth::check_access_token, error::Error, structs::{Channel, Member}, utils::{get_auth_header, global_checks, order_by_is_above}, Data + Data, + api::v1::auth::check_access_token, + error::Error, + structs::{Channel, Member}, + utils::{get_auth_header, global_checks, order_by_is_above}, }; use ::uuid::Uuid; use actix_web::{HttpRequest, HttpResponse, get, post, web}; diff --git a/src/api/v1/guilds/uuid/icon.rs b/src/api/v1/guilds/uuid/icon.rs index ae71321..f2e15b6 100644 --- a/src/api/v1/guilds/uuid/icon.rs +++ b/src/api/v1/guilds/uuid/icon.rs @@ -5,13 +5,17 @@ use futures_util::StreamExt as _; use uuid::Uuid; use crate::{ - api::v1::auth::check_access_token, error::Error, structs::{Guild, Member}, utils::{get_auth_header, global_checks}, Data + Data, + api::v1::auth::check_access_token, + error::Error, + structs::{Guild, Member}, + utils::{get_auth_header, global_checks}, }; /// `PUT /api/v1/guilds/{uuid}/icon` Icon upload -/// +/// /// requires auth: no -/// +/// /// put request expects a file and nothing else #[put("{uuid}/icon")] pub async fn upload( diff --git a/src/api/v1/guilds/uuid/invites/mod.rs b/src/api/v1/guilds/uuid/invites/mod.rs index e985625..ea04529 100644 --- a/src/api/v1/guilds/uuid/invites/mod.rs +++ b/src/api/v1/guilds/uuid/invites/mod.rs @@ -3,7 +3,11 @@ use serde::Deserialize; use uuid::Uuid; use crate::{ - api::v1::auth::check_access_token, error::Error, structs::{Guild, Member}, utils::{get_auth_header, global_checks}, Data + Data, + api::v1::auth::check_access_token, + error::Error, + structs::{Guild, Member}, + utils::{get_auth_header, global_checks}, }; #[derive(Deserialize)] diff --git a/src/api/v1/guilds/uuid/members.rs b/src/api/v1/guilds/uuid/members.rs index 2cc416a..d7ed0a5 100644 --- a/src/api/v1/guilds/uuid/members.rs +++ b/src/api/v1/guilds/uuid/members.rs @@ -1,5 +1,9 @@ use crate::{ - api::v1::auth::check_access_token, error::Error, structs::Member, utils::{get_auth_header, global_checks}, Data + Data, + api::v1::auth::check_access_token, + error::Error, + structs::Member, + utils::{get_auth_header, global_checks}, }; use ::uuid::Uuid; use actix_web::{HttpRequest, HttpResponse, get, web}; diff --git a/src/api/v1/guilds/uuid/mod.rs b/src/api/v1/guilds/uuid/mod.rs index 69c3f31..c24e957 100644 --- a/src/api/v1/guilds/uuid/mod.rs +++ b/src/api/v1/guilds/uuid/mod.rs @@ -6,11 +6,15 @@ use uuid::Uuid; mod channels; mod icon; mod invites; -mod roles; mod members; +mod roles; use crate::{ - api::v1::auth::check_access_token, error::Error, structs::{Guild, Member}, utils::{get_auth_header, global_checks}, Data + Data, + api::v1::auth::check_access_token, + error::Error, + structs::{Guild, Member}, + utils::{get_auth_header, global_checks}, }; pub fn web() -> Scope { @@ -34,9 +38,9 @@ pub fn web() -> Scope { } /// `GET /api/v1/guilds/{uuid}` DESCRIPTION -/// +/// /// requires auth: yes -/// +/// /// ### Response Example /// ``` /// json!({ @@ -65,7 +69,7 @@ pub fn web() -> Scope { /// ], /// "member_count": 20 /// }); -/// ``` +/// ``` #[get("/{uuid}")] pub async fn get( req: HttpRequest, diff --git a/src/api/v1/guilds/uuid/roles/mod.rs b/src/api/v1/guilds/uuid/roles/mod.rs index 9eb6349..8015384 100644 --- a/src/api/v1/guilds/uuid/roles/mod.rs +++ b/src/api/v1/guilds/uuid/roles/mod.rs @@ -3,7 +3,11 @@ use actix_web::{HttpRequest, HttpResponse, get, post, web}; use serde::Deserialize; use crate::{ - api::v1::auth::check_access_token, error::Error, structs::{Member, Role}, utils::{get_auth_header, global_checks, order_by_is_above}, Data + Data, + api::v1::auth::check_access_token, + error::Error, + structs::{Member, Role}, + utils::{get_auth_header, global_checks, order_by_is_above}, }; pub mod uuid; diff --git a/src/api/v1/guilds/uuid/roles/uuid.rs b/src/api/v1/guilds/uuid/roles/uuid.rs index 9e7853f..0e7f306 100644 --- a/src/api/v1/guilds/uuid/roles/uuid.rs +++ b/src/api/v1/guilds/uuid/roles/uuid.rs @@ -1,5 +1,9 @@ use crate::{ - api::v1::auth::check_access_token, error::Error, structs::{Member, Role}, utils::{get_auth_header, global_checks}, Data + Data, + api::v1::auth::check_access_token, + error::Error, + structs::{Member, Role}, + utils::{get_auth_header, global_checks}, }; use ::uuid::Uuid; use actix_web::{HttpRequest, HttpResponse, get, web}; diff --git a/src/api/v1/invites/id.rs b/src/api/v1/invites/id.rs index 6ddc53f..687b825 100644 --- a/src/api/v1/invites/id.rs +++ b/src/api/v1/invites/id.rs @@ -1,14 +1,15 @@ use actix_web::{HttpRequest, HttpResponse, get, post, web}; use crate::{ - api::v1::auth::check_access_token, error::Error, structs::{Guild, Invite, Member}, utils::{get_auth_header, global_checks}, Data + Data, + api::v1::auth::check_access_token, + error::Error, + structs::{Guild, Invite, Member}, + utils::{get_auth_header, global_checks}, }; #[get("{id}")] -pub async fn get( - path: web::Path<(String,)>, - data: web::Data, -) -> Result { +pub async fn get(path: web::Path<(String,)>, data: web::Data) -> Result { let mut conn = data.pool.get().await?; let invite_id = path.into_inner().0; diff --git a/src/api/v1/me/guilds.rs b/src/api/v1/me/guilds.rs index 516fc35..06c3328 100644 --- a/src/api/v1/me/guilds.rs +++ b/src/api/v1/me/guilds.rs @@ -1,14 +1,19 @@ //! `/api/v1/me/guilds` Contains endpoint related to guild memberships -use actix_web::{get, web, HttpRequest, HttpResponse}; - -use crate::{api::v1::auth::check_access_token, error::Error, structs::Me, utils::{get_auth_header, global_checks}, Data}; +use actix_web::{HttpRequest, HttpResponse, get, web}; +use crate::{ + Data, + api::v1::auth::check_access_token, + error::Error, + structs::Me, + utils::{get_auth_header, global_checks}, +}; /// `GET /api/v1/me/guilds` Returns all guild memberships in a list -/// +/// /// requires auth: yes -/// +/// /// ### Example Response /// ``` /// json!([ @@ -44,4 +49,4 @@ pub async fn get(req: HttpRequest, data: web::Data) -> Result Scope { web::scope("/v1") diff --git a/src/api/v1/stats.rs b/src/api/v1/stats.rs index 30888aa..5877c00 100644 --- a/src/api/v1/stats.rs +++ b/src/api/v1/stats.rs @@ -25,9 +25,9 @@ struct Response { } /// `GET /api/v1/` Returns stats about the server -/// +/// /// requires auth: no -/// +/// /// ### Response Example /// ``` /// json!({ diff --git a/src/api/v1/users/mod.rs b/src/api/v1/users/mod.rs index b3d853b..fd3980d 100644 --- a/src/api/v1/users/mod.rs +++ b/src/api/v1/users/mod.rs @@ -3,23 +3,25 @@ use actix_web::{HttpRequest, HttpResponse, Scope, get, web}; use crate::{ - api::v1::auth::check_access_token, error::Error, structs::{StartAmountQuery, User}, utils::{get_auth_header, global_checks}, Data + Data, + api::v1::auth::check_access_token, + error::Error, + structs::{StartAmountQuery, User}, + utils::{get_auth_header, global_checks}, }; mod uuid; pub fn web() -> Scope { - web::scope("/users") - .service(get) - .service(uuid::get) + web::scope("/users").service(get).service(uuid::get) } /// `GET /api/v1/users` Returns all users on this instance -/// +/// /// requires auth: yes -/// +/// /// requires admin: yes -/// +/// /// ### Response Example /// ``` /// json!([ diff --git a/src/api/v1/users/uuid.rs b/src/api/v1/users/uuid.rs index 337019b..213afe5 100644 --- a/src/api/v1/users/uuid.rs +++ b/src/api/v1/users/uuid.rs @@ -4,15 +4,19 @@ use actix_web::{HttpRequest, HttpResponse, get, web}; use uuid::Uuid; use crate::{ - api::v1::auth::check_access_token, error::Error, structs::User, utils::{get_auth_header, global_checks}, Data + Data, + api::v1::auth::check_access_token, + error::Error, + structs::User, + utils::{get_auth_header, global_checks}, }; /// `GET /api/v1/users/{uuid}` Returns user with the given UUID -/// +/// /// requires auth: yes -/// +/// /// requires relation: yes -/// +/// /// ### Response Example /// ``` /// json!({ diff --git a/src/api/versions.rs b/src/api/versions.rs index 809d6ed..0c3e106 100644 --- a/src/api/versions.rs +++ b/src/api/versions.rs @@ -12,9 +12,9 @@ struct Response { struct UnstableFeatures; /// `GET /api/versions` Returns info about api versions. -/// +/// /// requires auth: no -/// +/// /// ### Response Example /// ``` /// json!({ diff --git a/src/error.rs b/src/error.rs index 984f57e..1b1bfba 100644 --- a/src/error.rs +++ b/src/error.rs @@ -12,6 +12,9 @@ use bunny_api_tokio::error::Error as BunnyError; use deadpool::managed::{BuildError, PoolError}; use diesel::{ConnectionError, result::Error as DieselError}; use diesel_async::pooled_connection::PoolError as DieselPoolError; +use lettre::{ + address::AddressError, error::Error as EmailError, transport::smtp::Error as SmtpError, +}; use log::{debug, error}; use redis::RedisError; use serde::Serialize; @@ -19,7 +22,6 @@ use serde_json::Error as JsonError; use thiserror::Error; use tokio::task::JoinError; use toml::de::Error as TomlError; -use lettre::{error::Error as EmailError, address::AddressError, transport::smtp::Error as SmtpError}; #[derive(Debug, Error)] pub enum Error { diff --git a/src/main.rs b/src/main.rs index 5670e7b..892c79d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,8 +6,8 @@ use diesel_async::pooled_connection::AsyncDieselConnectionManager; use diesel_async::pooled_connection::deadpool::Pool; use error::Error; use simple_logger::SimpleLogger; -use structs::MailClient; use std::time::SystemTime; +use structs::MailClient; mod config; use config::{Config, ConfigBuilder}; use diesel_migrations::{EmbeddedMigrations, MigrationHarness, embed_migrations}; @@ -76,7 +76,12 @@ async fn main() -> Result<(), Error> { let mail = config.mail.clone(); - let mail_client = MailClient::new(mail.smtp.credentials(), mail.smtp.server, mail.address, mail.tls)?; + let mail_client = MailClient::new( + mail.smtp.credentials(), + mail.smtp.server, + mail.address, + mail.tls, + )?; let database_url = config.database.url(); diff --git a/src/structs.rs b/src/structs.rs index bf49a30..e76aa2a 100644 --- a/src/structs.rs +++ b/src/structs.rs @@ -1,22 +1,36 @@ use actix_web::web::BytesMut; +use argon2::{ + PasswordHasher, + password_hash::{SaltString, rand_core::OsRng}, +}; use chrono::Utc; use diesel::{ - delete, dsl::now, insert_into, prelude::{Insertable, Queryable}, update, ExpressionMethods, QueryDsl, Selectable, SelectableHelper + ExpressionMethods, QueryDsl, Selectable, SelectableHelper, delete, + dsl::now, + insert_into, + prelude::{Insertable, Queryable}, + update, }; use diesel_async::{RunQueryDsl, pooled_connection::AsyncDieselConnectionManager}; -use lettre::{message::{Mailbox, MessageBuilder as EmailBuilder, MultiPart}, transport::smtp::authentication::Credentials, AsyncSmtpTransport, AsyncTransport, Message as Email, Tokio1Executor}; +use lettre::{ + AsyncSmtpTransport, AsyncTransport, Message as Email, Tokio1Executor, + message::{Mailbox, MessageBuilder as EmailBuilder, MultiPart}, + transport::smtp::authentication::Credentials, +}; use log::debug; use serde::{Deserialize, Serialize}; use tokio::task; use url::Url; use uuid::Uuid; -use argon2::{ - PasswordHasher, - password_hash::{SaltString, rand_core::OsRng}, -}; use crate::{ - error::Error, schema::*, utils::{generate_refresh_token, global_checks, image_check, order_by_is_above, user_uuid_from_identifier, EMAIL_REGEX, PASSWORD_REGEX, USERNAME_REGEX}, Conn, Data + Conn, Data, + error::Error, + schema::*, + utils::{ + EMAIL_REGEX, PASSWORD_REGEX, USERNAME_REGEX, generate_refresh_token, global_checks, + image_check, order_by_is_above, user_uuid_from_identifier, + }, }; pub trait HasUuid { @@ -61,7 +75,12 @@ pub struct MailClient { } impl MailClient { - pub fn new>(creds: Credentials, smtp_server: String, mbox: String, tls: T) -> Result { + pub fn new>( + creds: Credentials, + smtp_server: String, + mbox: String, + tls: T, + ) -> Result { Ok(Self { creds, smtp_server, @@ -71,15 +90,16 @@ impl MailClient { } pub fn message_builder(&self) -> EmailBuilder { - Email::builder() - .from(self.mbox.clone()) + Email::builder().from(self.mbox.clone()) } pub async fn send_mail(&self, email: Email) -> Result<(), Error> { let mailer: AsyncSmtpTransport = match self.tls { - MailTls::StartTls => AsyncSmtpTransport::::starttls_relay(&self.smtp_server)? - .credentials(self.creds.clone()) - .build(), + MailTls::StartTls => { + AsyncSmtpTransport::::starttls_relay(&self.smtp_server)? + .credentials(self.creds.clone()) + .build() + } MailTls::Tls => AsyncSmtpTransport::::relay(&self.smtp_server)? .credentials(self.creds.clone()) .build(), @@ -256,7 +276,11 @@ impl Channel { data.set_cache_key(channel_uuid.to_string(), channel.clone(), 1800) .await?; - if let Ok(_) = data.get_cache_key(format!("{}_channels", guild_uuid)).await { + if data + .get_cache_key(format!("{}_channels", guild_uuid)) + .await + .is_ok() + { data.del_cache_key(format!("{}_channels", guild_uuid)) .await?; } @@ -273,7 +297,7 @@ impl Channel { .execute(&mut conn) .await?; - if let Ok(_) = data.get_cache_key(self.uuid.to_string()).await { + if data.get_cache_key(self.uuid.to_string()).await.is_ok() { data.del_cache_key(self.uuid.to_string()).await?; } @@ -300,9 +324,7 @@ impl Channel { .await, )?; - let message_futures = messages.iter().map(async move |b| { - b.build(data).await - }); + let message_futures = messages.iter().map(async move |b| b.build(data).await); futures::future::try_join_all(message_futures).await } @@ -708,7 +730,11 @@ impl Member { Ok(count) } - pub async fn check_membership(conn: &mut Conn, user_uuid: Uuid, guild_uuid: Uuid) -> Result<(), Error> { + pub async fn check_membership( + conn: &mut Conn, + user_uuid: Uuid, + guild_uuid: Uuid, + ) -> Result<(), Error> { use guild_members::dsl; dsl::guild_members .filter(dsl::user_uuid.eq(user_uuid)) @@ -720,11 +746,7 @@ impl Member { Ok(()) } - pub async fn fetch_one( - data: &Data, - user_uuid: Uuid, - guild_uuid: Uuid, - ) -> Result { + pub async fn fetch_one(data: &Data, user_uuid: Uuid, guild_uuid: Uuid) -> Result { let mut conn = data.pool.get().await?; use guild_members::dsl; @@ -747,12 +769,12 @@ impl Member { .filter(dsl::guild_uuid.eq(guild_uuid)) .select(MemberBuilder::as_select()) .load(&mut conn) - .await + .await, )?; - let member_futures = member_builders.iter().map(async move |m| { - m.build(data).await - }); + let member_futures = member_builders + .iter() + .map(async move |m| m.build(data).await); futures::future::try_join_all(member_futures).await } @@ -921,15 +943,16 @@ impl Me { let user = User::fetch_one(data, self.uuid).await?; - let memberships = member_builders.iter().map(|m| { - Member { + let memberships = member_builders + .iter() + .map(|m| Member { uuid: m.uuid, nickname: m.nickname.clone(), user_uuid: m.user_uuid, guild_uuid: m.guild_uuid, user: user.clone(), - } - }).collect(); + }) + .collect(); Ok(memberships) } @@ -1070,6 +1093,7 @@ impl EmailToken { Ok(email_token) } + #[allow(clippy::new_ret_no_self)] pub async fn new(data: &Data, me: Me) -> Result<(), Error> { let token = generate_refresh_token()?; @@ -1077,7 +1101,11 @@ impl EmailToken { use email_tokens::dsl; insert_into(email_tokens::table) - .values((dsl::user_uuid.eq(me.uuid), dsl::token.eq(&token), dsl::created_at.eq(now))) + .values(( + dsl::user_uuid.eq(me.uuid), + dsl::token.eq(&token), + dsl::created_at.eq(now), + )) .execute(&mut conn) .await?; @@ -1095,10 +1123,7 @@ impl EmailToken { format!(r#"

Verify your {} Account

Hello, {}!

Thanks for creating a new account on Gorb.

The final step to create your account is to verify your email address by clicking the button below, within 24 hours.

VERIFY ACCOUNT

If you didn't ask to verify this address, you can safely ignore this email.

"#, data.config.instance.name, me.username, verify_endpoint) ))?; - data - .mail_client - .send_mail(email) - .await?; + data.mail_client.send_mail(email).await?; Ok(()) } @@ -1136,7 +1161,10 @@ impl PasswordResetToken { Ok(password_reset_token) } - pub async fn get_with_identifier(conn: &mut Conn, identifier: String) -> Result { + pub async fn get_with_identifier( + conn: &mut Conn, + identifier: String, + ) -> Result { let user_uuid = user_uuid_from_identifier(conn, &identifier).await?; use password_reset_tokens::dsl; @@ -1149,6 +1177,7 @@ impl PasswordResetToken { Ok(password_reset_token) } + #[allow(clippy::new_ret_no_self)] pub async fn new(data: &Data, identifier: String) -> Result<(), Error> { let token = generate_refresh_token()?; @@ -1156,7 +1185,7 @@ impl PasswordResetToken { let user_uuid = user_uuid_from_identifier(&mut conn, &identifier).await?; - global_checks(&data, user_uuid).await?; + global_checks(data, user_uuid).await?; use users::dsl as udsl; let (username, email_address): (String, String) = udsl::users @@ -1167,7 +1196,11 @@ impl PasswordResetToken { use password_reset_tokens::dsl; insert_into(password_reset_tokens::table) - .values((dsl::user_uuid.eq(user_uuid), dsl::token.eq(&token), dsl::created_at.eq(now))) + .values(( + dsl::user_uuid.eq(user_uuid), + dsl::token.eq(&token), + dsl::created_at.eq(now), + )) .execute(&mut conn) .await?; @@ -1185,17 +1218,16 @@ impl PasswordResetToken { format!(r#"

{} Password Reset

Hello, {}!

Someone requested a password reset for your Gorb account.

Click the button below within 24 hours to reset your password.

RESET PASSWORD

If you didn't request a password reset, don't worry, your account is safe and you can safely ignore this email.

"#, data.config.instance.name, username, reset_endpoint) ))?; - data - .mail_client - .send_mail(email) - .await?; + data.mail_client.send_mail(email).await?; Ok(()) } pub async fn set_password(&self, data: &Data, password: String) -> Result<(), Error> { if !PASSWORD_REGEX.is_match(&password) { - return Err(Error::BadRequest("Please provide a valid password".to_string())) + return Err(Error::BadRequest( + "Please provide a valid password".to_string(), + )); } let salt = SaltString::generate(&mut OsRng); @@ -1204,7 +1236,7 @@ impl PasswordResetToken { .argon2 .hash_password(password.as_bytes(), &salt) .map_err(|e| Error::PasswordHashError(e.to_string()))?; - + let mut conn = data.pool.get().await?; use users::dsl; @@ -1232,10 +1264,7 @@ impl PasswordResetToken { format!(r#"

{} Password Reset Confirmation

Hello, {}!

Your password has been successfully reset for your Gorb account.

If you did not initiate this change, please click the button below to reset your password immediately.

RESET PASSWORD
"#, data.config.instance.name, username, login_page) ))?; - data - .mail_client - .send_mail(email) - .await?; + data.mail_client.send_mail(email).await?; self.delete(&mut conn).await } @@ -1251,4 +1280,3 @@ impl PasswordResetToken { Ok(()) } } - diff --git a/src/utils.rs b/src/utils.rs index d5d4480..5ef2187 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -16,7 +16,10 @@ use serde::Serialize; use uuid::Uuid; use crate::{ - error::Error, schema::users, structs::{HasIsAbove, HasUuid}, Conn, Data + Conn, Data, + error::Error, + schema::users, + structs::{HasIsAbove, HasUuid}, }; pub static EMAIL_REGEX: LazyLock = LazyLock::new(|| { @@ -137,27 +140,32 @@ pub fn image_check(icon: BytesMut) -> Result { )) } -pub async fn user_uuid_from_identifier(conn: &mut Conn, identifier: &String) -> Result { +pub async fn user_uuid_from_identifier( + conn: &mut Conn, + identifier: &String, +) -> Result { if EMAIL_REGEX.is_match(identifier) { - use users::dsl; - let user_uuid = dsl::users - .filter(dsl::email.eq(identifier)) - .select(dsl::uuid) - .get_result(conn) - .await?; + use users::dsl; + let user_uuid = dsl::users + .filter(dsl::email.eq(identifier)) + .select(dsl::uuid) + .get_result(conn) + .await?; - Ok(user_uuid) + Ok(user_uuid) } else if USERNAME_REGEX.is_match(identifier) { - use users::dsl; - let user_uuid = dsl::users - .filter(dsl::username.eq(identifier)) - .select(dsl::uuid) - .get_result(conn) - .await?; + use users::dsl; + let user_uuid = dsl::users + .filter(dsl::username.eq(identifier)) + .select(dsl::uuid) + .get_result(conn) + .await?; - Ok(user_uuid) + Ok(user_uuid) } else { - Err(Error::BadRequest("Please provide a valid username or email".to_string())) + Err(Error::BadRequest( + "Please provide a valid username or email".to_string(), + )) } } @@ -173,11 +181,12 @@ pub async fn global_checks(data: &Data, user_uuid: Uuid) -> Result<(), Error> { .await?; if !email_verified { - return Err(Error::Forbidden("server requires email verification".to_string())) + return Err(Error::Forbidden( + "server requires email verification".to_string(), + )); } } - Ok(()) } From 38aab46534e823a9d33b33b23924405e2d5f58ca Mon Sep 17 00:00:00 2001 From: Radical Date: Sat, 31 May 2025 14:41:29 +0200 Subject: [PATCH 65/92] style: rename refresh_token_cookie() to new_refresh_token_cookie() and fix error message when no refresh_token is found on refresh --- src/api/v1/auth/login.rs | 4 ++-- src/api/v1/auth/refresh.rs | 16 ++++------------ src/api/v1/auth/register.rs | 4 ++-- src/utils.rs | 2 +- 4 files changed, 9 insertions(+), 17 deletions(-) diff --git a/src/api/v1/auth/login.rs b/src/api/v1/auth/login.rs index 5229425..04d6b4f 100644 --- a/src/api/v1/auth/login.rs +++ b/src/api/v1/auth/login.rs @@ -11,7 +11,7 @@ use crate::{ error::Error, schema::*, utils::{ - PASSWORD_REGEX, generate_access_token, generate_refresh_token, refresh_token_cookie, + PASSWORD_REGEX, generate_access_token, generate_refresh_token, new_refresh_token_cookie, user_uuid_from_identifier, }, }; @@ -89,6 +89,6 @@ pub async fn response( .await?; Ok(HttpResponse::Ok() - .cookie(refresh_token_cookie(refresh_token)) + .cookie(new_refresh_token_cookie(refresh_token)) .json(Response { access_token })) } diff --git a/src/api/v1/auth/refresh.rs b/src/api/v1/auth/refresh.rs index b64b10e..cc3bbe9 100644 --- a/src/api/v1/auth/refresh.rs +++ b/src/api/v1/auth/refresh.rs @@ -11,20 +11,16 @@ use crate::{ access_tokens::{self, dsl}, refresh_tokens::{self, dsl as rdsl}, }, - utils::{generate_access_token, generate_refresh_token, refresh_token_cookie}, + utils::{generate_access_token, generate_refresh_token, new_refresh_token_cookie}, }; use super::Response; #[post("/refresh")] pub async fn res(req: HttpRequest, data: web::Data) -> Result { - let recv_refresh_token_cookie = req.cookie("refresh_token"); + let mut refresh_token_cookie = req.cookie("refresh_token").ok_or(Error::Unauthorized("request has no refresh token".to_string()))?; - if recv_refresh_token_cookie.is_none() { - return Ok(HttpResponse::Unauthorized().finish()); - } - - let mut refresh_token = String::from(recv_refresh_token_cookie.unwrap().value()); + let mut refresh_token = String::from(refresh_token_cookie.value()); let current_time = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs() as i64; @@ -47,8 +43,6 @@ pub async fn res(req: HttpRequest, data: web::Data) -> Result) -> Result Result<&str, Error> { Ok(auth_value.unwrap()) } -pub fn refresh_token_cookie(refresh_token: String) -> Cookie<'static> { +pub fn new_refresh_token_cookie(refresh_token: String) -> Cookie<'static> { Cookie::build("refresh_token", refresh_token) .http_only(true) .secure(true) From 60f0219e85a74e9a41c834179983d41689a3a9e3 Mon Sep 17 00:00:00 2001 From: Radical Date: Sat, 31 May 2025 14:43:48 +0200 Subject: [PATCH 66/92] feat: add logout endpoint --- src/api/v1/auth/logout.rs | 31 +++++++++++++++++++++++++++++++ src/api/v1/auth/mod.rs | 2 ++ 2 files changed, 33 insertions(+) create mode 100644 src/api/v1/auth/logout.rs diff --git a/src/api/v1/auth/logout.rs b/src/api/v1/auth/logout.rs new file mode 100644 index 0000000..79b5c36 --- /dev/null +++ b/src/api/v1/auth/logout.rs @@ -0,0 +1,31 @@ +use actix_web::{HttpRequest, HttpResponse, post, web}; +use diesel::{ExpressionMethods, delete}; +use diesel_async::RunQueryDsl; + +use crate::{ + Data, + error::Error, + schema::refresh_tokens::{self, dsl}, +}; + +// TODO: Should maybe be a delete request? +#[post("/logout")] +pub async fn res( + req: HttpRequest, + data: web::Data, +) -> Result { + let mut refresh_token_cookie = req.cookie("refresh_token").ok_or(Error::Unauthorized("request has no refresh token".to_string()))?; + + let refresh_token = String::from(refresh_token_cookie.value()); + + let mut conn = data.pool.get().await?; + + delete(refresh_tokens::table) + .filter(dsl::token.eq(refresh_token)) + .execute(&mut conn) + .await?; + + refresh_token_cookie.make_removal(); + + Ok(HttpResponse::Ok().cookie(refresh_token_cookie).finish()) +} diff --git a/src/api/v1/auth/mod.rs b/src/api/v1/auth/mod.rs index d627a59..75a6b0b 100644 --- a/src/api/v1/auth/mod.rs +++ b/src/api/v1/auth/mod.rs @@ -9,6 +9,7 @@ use uuid::Uuid; use crate::{Conn, error::Error, schema::access_tokens::dsl}; mod login; +mod logout; mod refresh; mod register; mod reset_password; @@ -24,6 +25,7 @@ pub fn web() -> Scope { web::scope("/auth") .service(register::res) .service(login::response) + .service(logout::res) .service(refresh::res) .service(revoke::res) .service(verify_email::get) From 1e993026a0270eb49a2c9a90ba7ade0e8895fc61 Mon Sep 17 00:00:00 2001 From: Radical Date: Sat, 31 May 2025 14:52:42 +0200 Subject: [PATCH 67/92] fix: add missing /stats to docs --- src/api/v1/stats.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/api/v1/stats.rs b/src/api/v1/stats.rs index 5877c00..760ec71 100644 --- a/src/api/v1/stats.rs +++ b/src/api/v1/stats.rs @@ -24,7 +24,7 @@ struct Response { build_number: String, } -/// `GET /api/v1/` Returns stats about the server +/// `GET /api/v1/stats` Returns stats about the server /// /// requires auth: no /// From 4fce2625518df3da139219cdf940a4eb449ae05e Mon Sep 17 00:00:00 2001 From: Radical Date: Sat, 31 May 2025 14:52:57 +0200 Subject: [PATCH 68/92] docs: add documentation to logout endpoint --- src/api/v1/auth/logout.rs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/api/v1/auth/logout.rs b/src/api/v1/auth/logout.rs index 79b5c36..d801ad7 100644 --- a/src/api/v1/auth/logout.rs +++ b/src/api/v1/auth/logout.rs @@ -8,7 +8,15 @@ use crate::{ schema::refresh_tokens::{self, dsl}, }; -// TODO: Should maybe be a delete request? +/// `GET /api/v1/logout` +/// +/// requires auth: kinda, needs refresh token set but no access token is technically required +/// +/// ### Responses +/// 200 Logged out +/// 404 Refresh token is invalid +/// 401 Unauthorized (no refresh token found) +/// #[post("/logout")] pub async fn res( req: HttpRequest, From 6783bd22a73cd86b905ac8690c83a8c4ef19a48e Mon Sep 17 00:00:00 2001 From: Radical Date: Sat, 31 May 2025 17:11:14 +0200 Subject: [PATCH 69/92] feat: add backend_url config option Required for refresh_token cookie to work properly --- src/api/v1/auth/login.rs | 2 +- src/api/v1/auth/refresh.rs | 2 +- src/api/v1/auth/register.rs | 2 +- src/config.rs | 8 ++++---- src/main.rs | 2 +- src/utils.rs | 10 ++++------ 6 files changed, 12 insertions(+), 14 deletions(-) diff --git a/src/api/v1/auth/login.rs b/src/api/v1/auth/login.rs index 04d6b4f..e190c2f 100644 --- a/src/api/v1/auth/login.rs +++ b/src/api/v1/auth/login.rs @@ -89,6 +89,6 @@ pub async fn response( .await?; Ok(HttpResponse::Ok() - .cookie(new_refresh_token_cookie(refresh_token)) + .cookie(new_refresh_token_cookie(&data.config, refresh_token)) .json(Response { access_token })) } diff --git a/src/api/v1/auth/refresh.rs b/src/api/v1/auth/refresh.rs index cc3bbe9..69bf248 100644 --- a/src/api/v1/auth/refresh.rs +++ b/src/api/v1/auth/refresh.rs @@ -85,7 +85,7 @@ pub async fn res(req: HttpRequest, data: web::Data) -> Result, port: Option, - base_path: Option, frontend_url: Url, + backend_url: Option, _ssl: Option, } @@ -86,8 +86,8 @@ impl ConfigBuilder { let web = Web { ip: self.web.ip.unwrap_or(String::from("0.0.0.0")), port: self.web.port.unwrap_or(8080), - base_path: self.web.base_path.unwrap_or("".to_string()), - frontend_url: self.web.frontend_url, + frontend_url: self.web.frontend_url.clone(), + backend_url: self.web.backend_url.or_else(|| self.web.frontend_url.join("/api").ok()).unwrap(), }; let endpoint = match &*self.bunny.endpoint { @@ -148,8 +148,8 @@ pub struct Config { pub struct Web { pub ip: String, pub port: u16, - pub base_path: String, pub frontend_url: Url, + pub backend_url: Url, } #[derive(Debug, Clone)] diff --git a/src/main.rs b/src/main.rs index 892c79d..d026f55 100644 --- a/src/main.rs +++ b/src/main.rs @@ -155,7 +155,7 @@ async fn main() -> Result<(), Error> { App::new() .app_data(web::Data::new(data.clone())) .wrap(cors) - .service(api::web(&data.config.web.base_path)) + .service(api::web(&data.config.web.backend_url.path())) }) .bind((web.ip, web.port))? .run() diff --git a/src/utils.rs b/src/utils.rs index 3241dee..f393bf5 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -16,10 +16,7 @@ use serde::Serialize; use uuid::Uuid; use crate::{ - Conn, Data, - error::Error, - schema::users, - structs::{HasIsAbove, HasUuid}, + config::Config, error::Error, schema::users, structs::{HasIsAbove, HasUuid}, Conn, Data }; pub static EMAIL_REGEX: LazyLock = LazyLock::new(|| { @@ -100,12 +97,13 @@ pub fn get_ws_protocol_header(headers: &HeaderMap) -> Result<&str, Error> { Ok(auth_value.unwrap()) } -pub fn new_refresh_token_cookie(refresh_token: String) -> Cookie<'static> { +pub fn new_refresh_token_cookie(config: &Config, refresh_token: String) -> Cookie<'static> { Cookie::build("refresh_token", refresh_token) .http_only(true) .secure(true) .same_site(SameSite::None) - .path("/api") + .domain(config.web.backend_url.domain().unwrap().to_string()) + .path(config.web.backend_url.path().to_string()) .max_age(Duration::days(30)) .finish() } From 8163d0d9c02e43d5cbb10dffd1a1a4515279cf61 Mon Sep 17 00:00:00 2001 From: Radical Date: Sat, 31 May 2025 17:51:04 +0200 Subject: [PATCH 70/92] style: clippy & fmt --- src/api/v1/auth/logout.rs | 11 +++++------ src/api/v1/auth/refresh.rs | 4 +++- src/config.rs | 6 +++++- src/utils.rs | 6 +++++- 4 files changed, 18 insertions(+), 9 deletions(-) diff --git a/src/api/v1/auth/logout.rs b/src/api/v1/auth/logout.rs index d801ad7..0f265bb 100644 --- a/src/api/v1/auth/logout.rs +++ b/src/api/v1/auth/logout.rs @@ -8,7 +8,7 @@ use crate::{ schema::refresh_tokens::{self, dsl}, }; -/// `GET /api/v1/logout` +/// `GET /api/v1/logout` /// /// requires auth: kinda, needs refresh token set but no access token is technically required /// @@ -18,11 +18,10 @@ use crate::{ /// 401 Unauthorized (no refresh token found) /// #[post("/logout")] -pub async fn res( - req: HttpRequest, - data: web::Data, -) -> Result { - let mut refresh_token_cookie = req.cookie("refresh_token").ok_or(Error::Unauthorized("request has no refresh token".to_string()))?; +pub async fn res(req: HttpRequest, data: web::Data) -> Result { + let mut refresh_token_cookie = req.cookie("refresh_token").ok_or(Error::Unauthorized( + "request has no refresh token".to_string(), + ))?; let refresh_token = String::from(refresh_token_cookie.value()); diff --git a/src/api/v1/auth/refresh.rs b/src/api/v1/auth/refresh.rs index 69bf248..63e150e 100644 --- a/src/api/v1/auth/refresh.rs +++ b/src/api/v1/auth/refresh.rs @@ -18,7 +18,9 @@ use super::Response; #[post("/refresh")] pub async fn res(req: HttpRequest, data: web::Data) -> Result { - let mut refresh_token_cookie = req.cookie("refresh_token").ok_or(Error::Unauthorized("request has no refresh token".to_string()))?; + let mut refresh_token_cookie = req.cookie("refresh_token").ok_or(Error::Unauthorized( + "request has no refresh token".to_string(), + ))?; let mut refresh_token = String::from(refresh_token_cookie.value()); diff --git a/src/config.rs b/src/config.rs index 6b1d077..cbcc8c5 100644 --- a/src/config.rs +++ b/src/config.rs @@ -87,7 +87,11 @@ impl ConfigBuilder { ip: self.web.ip.unwrap_or(String::from("0.0.0.0")), port: self.web.port.unwrap_or(8080), frontend_url: self.web.frontend_url.clone(), - backend_url: self.web.backend_url.or_else(|| self.web.frontend_url.join("/api").ok()).unwrap(), + backend_url: self + .web + .backend_url + .or_else(|| self.web.frontend_url.join("/api").ok()) + .unwrap(), }; let endpoint = match &*self.bunny.endpoint { diff --git a/src/utils.rs b/src/utils.rs index f393bf5..1621ddf 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -16,7 +16,11 @@ use serde::Serialize; use uuid::Uuid; use crate::{ - config::Config, error::Error, schema::users, structs::{HasIsAbove, HasUuid}, Conn, Data + Conn, Data, + config::Config, + error::Error, + schema::users, + structs::{HasIsAbove, HasUuid}, }; pub static EMAIL_REGEX: LazyLock = LazyLock::new(|| { From 042aae66f2730991d0f4719bcb877e50847ed814 Mon Sep 17 00:00:00 2001 From: Radical Date: Sat, 31 May 2025 17:52:40 +0200 Subject: [PATCH 71/92] fix: make /me/guilds return guilds instead of member objects --- src/api/v1/me/guilds.rs | 41 ++++++++++++++++++++++++++++++++--------- src/structs.rs | 34 +++++++++++++++++----------------- 2 files changed, 49 insertions(+), 26 deletions(-) diff --git a/src/api/v1/me/guilds.rs b/src/api/v1/me/guilds.rs index 06c3328..7fe02bd 100644 --- a/src/api/v1/me/guilds.rs +++ b/src/api/v1/me/guilds.rs @@ -18,16 +18,39 @@ use crate::{ /// ``` /// json!([ /// { -/// "uuid": "22006503-fb01-46e6-8e0e-70336dac6c63", -/// "nickname": "This field is nullable", -/// "user_uuid": "522bca17-de63-4706-9d18-0971867ad1e0", -/// "guild_uuid": "0911e468-3e9e-47bf-8381-59b30e8b68a8" +/// "uuid": "383d2afa-082f-4dd3-9050-ca6ed91487b6", +/// "name": "My new server!", +/// "description": null, +/// "icon": null, +/// "owner_uuid": "155d2291-fb23-46bd-a656-ae7c5d8218e6", +/// "roles": [], +/// "member_count": 1 /// }, /// { -/// "uuid": "bf95361e-3b64-4704-969c-3c5a80d10514", -/// "nickname": null, -/// "user_uuid": "522bca17-de63-4706-9d18-0971867ad1e0", -/// "guild_uuid": "69ec2ce5-3d8b-4451-b644-c2d969905458" +/// "uuid": "5ba61ec7-5f97-43e1-89a5-d4693c155612", +/// "name": "My first server!", +/// "description": "This is a cool and nullable description!", +/// "icon": "https://nullable-url/path/to/icon.png", +/// "owner_uuid": "155d2291-fb23-46bd-a656-ae7c5d8218e6", +/// "roles": [ +/// { +/// "uuid": "be0e4da4-cf73-4f45-98f8-bb1c73d1ab8b", +/// "guild_uuid": "5ba61ec7-5f97-43e1-89a5-d4693c155612", +/// "name": "Cool people", +/// "color": 15650773, +/// "is_above": c7432f1c-f4ad-4ad3-8216-51388b6abb5b, +/// "permissions": 0 +/// } +/// { +/// "uuid": "c7432f1c-f4ad-4ad3-8216-51388b6abb5b", +/// "guild_uuid": "5ba61ec7-5f97-43e1-89a5-d4693c155612", +/// "name": "Equally cool people", +/// "color": 16777215, +/// "is_above": null, +/// "permissions": 0 +/// } +/// ], +/// "member_count": 20 /// } /// ]); /// ``` @@ -46,7 +69,7 @@ pub async fn get(req: HttpRequest, data: web::Data) -> Result Result, Error> { - let mut conn = data.pool.get().await?; - + pub async fn fetch_memberships(&self, conn: &mut Conn) -> Result, Error> { use guild_members::dsl; - let member_builders: Vec = dsl::guild_members + let memberships: Vec = dsl::guild_members .filter(dsl::user_uuid.eq(self.uuid)) .select(MemberBuilder::as_select()) - .load(&mut conn) + .load(conn) .await?; - let user = User::fetch_one(data, self.uuid).await?; + let mut guilds: Vec = vec![]; - let memberships = member_builders - .iter() - .map(|m| Member { - uuid: m.uuid, - nickname: m.nickname.clone(), - user_uuid: m.user_uuid, - guild_uuid: m.guild_uuid, - user: user.clone(), - }) - .collect(); + for membership in memberships { + use guilds::dsl; + guilds.push( + dsl::guilds + .filter(dsl::uuid.eq(membership.guild_uuid)) + .select(GuildBuilder::as_select()) + .get_result(conn) + .await? + .build(conn) + .await?, + ) + } - Ok(memberships) + Ok(guilds) } pub async fn set_avatar( From 6bc2cdc3c7eac869983f6f7824a9b92327eb861e Mon Sep 17 00:00:00 2001 From: Radical Date: Sat, 31 May 2025 23:07:09 +0200 Subject: [PATCH 72/92] revert: add domain to refresh_token_cookie --- src/utils.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils.rs b/src/utils.rs index 1621ddf..c9f3cb2 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -106,7 +106,7 @@ pub fn new_refresh_token_cookie(config: &Config, refresh_token: String) -> Cooki .http_only(true) .secure(true) .same_site(SameSite::None) - .domain(config.web.backend_url.domain().unwrap().to_string()) + //.domain(config.web.backend_url.domain().unwrap().to_string()) .path(config.web.backend_url.path().to_string()) .max_age(Duration::days(30)) .finish() From cade49d9c63f62ce7146390860170e29a197b455 Mon Sep 17 00:00:00 2001 From: Radical Date: Sun, 1 Jun 2025 01:47:30 +0200 Subject: [PATCH 73/92] fix: return empty vector instead of 404 error --- src/structs.rs | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/structs.rs b/src/structs.rs index 38f2982..0f7afdc 100644 --- a/src/structs.rs +++ b/src/structs.rs @@ -933,11 +933,13 @@ impl Me { pub async fn fetch_memberships(&self, conn: &mut Conn) -> Result, Error> { use guild_members::dsl; - let memberships: Vec = dsl::guild_members - .filter(dsl::user_uuid.eq(self.uuid)) - .select(MemberBuilder::as_select()) - .load(conn) - .await?; + let memberships: Vec = load_or_empty( + dsl::guild_members + .filter(dsl::user_uuid.eq(self.uuid)) + .select(MemberBuilder::as_select()) + .load(conn) + .await + )?; let mut guilds: Vec = vec![]; From 57f52d96df40e9e878dd8112c678f5a920817117 Mon Sep 17 00:00:00 2001 From: Radical Date: Sun, 1 Jun 2025 14:09:38 +0200 Subject: [PATCH 74/92] feat: expire cache when updating user --- src/api/v1/me/mod.rs | 9 ++++----- src/structs.rs | 46 +++++++++++++++++++++++++++++++++----------- 2 files changed, 39 insertions(+), 16 deletions(-) diff --git a/src/api/v1/me/mod.rs b/src/api/v1/me/mod.rs index 00a1ec3..4a4c1e8 100644 --- a/src/api/v1/me/mod.rs +++ b/src/api/v1/me/mod.rs @@ -81,8 +81,7 @@ pub async fn update( let byte_slice: &[u8] = &bytes; me.set_avatar( - &data.bunny_cdn, - &mut conn, + &data, data.config.bunny.cdn_url.clone(), byte_slice.into(), ) @@ -91,15 +90,15 @@ pub async fn update( if let Some(new_info) = form.json.0 { if let Some(username) = &new_info.username { - me.set_username(&mut conn, username.clone()).await?; + me.set_username(&data, username.clone()).await?; } if let Some(display_name) = &new_info.display_name { - me.set_display_name(&mut conn, display_name.clone()).await?; + me.set_display_name(&data, display_name.clone()).await?; } if let Some(email) = &new_info.email { - me.set_email(&mut conn, email.to_string()).await?; + me.set_email(&data, email.to_string()).await?; } } diff --git a/src/structs.rs b/src/structs.rs index 0f7afdc..0dada1a 100644 --- a/src/structs.rs +++ b/src/structs.rs @@ -961,25 +961,27 @@ impl Me { pub async fn set_avatar( &mut self, - bunny_cdn: &bunny_api_tokio::Client, - conn: &mut Conn, + data: &Data, cdn_url: Url, avatar: BytesMut, ) -> Result<(), Error> { let avatar_clone = avatar.clone(); let image_type = task::spawn_blocking(move || image_check(avatar_clone)).await??; + let mut conn = data.pool.get().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?; + data.bunny_cdn.storage.delete(relative_url).await?; } let path = format!("avatar/{}/avatar.{}", self.uuid, image_type); - bunny_cdn + data. + bunny_cdn .storage .upload(path.clone(), avatar.into()) .await?; @@ -990,9 +992,13 @@ impl Me { update(users::table) .filter(dsl::uuid.eq(self.uuid)) .set(dsl::avatar.eq(avatar_url.as_str())) - .execute(conn) + .execute(&mut conn) .await?; + if data.get_cache_key(self.uuid.to_string()).await.is_ok() { + data.del_cache_key(self.uuid.to_string()).await? + } + self.avatar = Some(avatar_url.to_string()); Ok(()) @@ -1011,20 +1017,26 @@ impl Me { pub async fn set_username( &mut self, - conn: &mut Conn, + data: &Data, new_username: String, ) -> Result<(), Error> { if !USERNAME_REGEX.is_match(&new_username) { return Err(Error::BadRequest("Invalid username".to_string())); } + let mut conn = data.pool.get().await?; + use users::dsl; update(users::table) .filter(dsl::uuid.eq(self.uuid)) .set(dsl::username.eq(new_username.as_str())) - .execute(conn) + .execute(&mut conn) .await?; + if data.get_cache_key(self.uuid.to_string()).await.is_ok() { + data.del_cache_key(self.uuid.to_string()).await? + } + self.username = new_username; Ok(()) @@ -1032,26 +1044,34 @@ impl Me { pub async fn set_display_name( &mut self, - conn: &mut Conn, + data: &Data, new_display_name: String, ) -> Result<(), Error> { + let mut conn = data.pool.get().await?; + use users::dsl; update(users::table) .filter(dsl::uuid.eq(self.uuid)) .set(dsl::display_name.eq(new_display_name.as_str())) - .execute(conn) + .execute(&mut conn) .await?; + if data.get_cache_key(self.uuid.to_string()).await.is_ok() { + data.del_cache_key(self.uuid.to_string()).await? + } + self.display_name = Some(new_display_name); Ok(()) } - pub async fn set_email(&mut self, conn: &mut Conn, new_email: String) -> Result<(), Error> { + pub async fn set_email(&mut self, data: &Data, new_email: String) -> Result<(), Error> { if !EMAIL_REGEX.is_match(&new_email) { return Err(Error::BadRequest("Invalid username".to_string())); } + let mut conn = data.pool.get().await?; + use users::dsl; update(users::table) .filter(dsl::uuid.eq(self.uuid)) @@ -1059,9 +1079,13 @@ impl Me { dsl::email.eq(new_email.as_str()), dsl::email_verified.eq(false), )) - .execute(conn) + .execute(&mut conn) .await?; + if data.get_cache_key(self.uuid.to_string()).await.is_ok() { + data.del_cache_key(self.uuid.to_string()).await? + } + self.email = new_email; Ok(()) From 2f7fac8db5cbc87e4d2d10c0ba0266d2e0c9d27e Mon Sep 17 00:00:00 2001 From: Radical Date: Sun, 1 Jun 2025 14:22:52 +0200 Subject: [PATCH 75/92] fix: dont use option in MpJson --- src/api/v1/me/mod.rs | 27 +++++++++++---------------- 1 file changed, 11 insertions(+), 16 deletions(-) diff --git a/src/api/v1/me/mod.rs b/src/api/v1/me/mod.rs index 4a4c1e8..e7c8167 100644 --- a/src/api/v1/me/mod.rs +++ b/src/api/v1/me/mod.rs @@ -46,7 +46,7 @@ struct NewInfo { struct UploadForm { #[multipart(limit = "100MB")] avatar: Option, - json: MpJson>, + json: MpJson, } #[patch("")] @@ -64,11 +64,8 @@ pub async fn update( let uuid = check_access_token(auth_header, &mut conn).await?; if form.avatar.is_some() - || form - .json - .0 - .clone() - .is_some_and(|ni| ni.username.is_some() || ni.display_name.is_some()) + || form.json.username.is_some() + || form.json.display_name.is_some() { global_checks(&data, uuid).await?; } @@ -88,18 +85,16 @@ pub async fn update( .await?; } - if let Some(new_info) = form.json.0 { - if let Some(username) = &new_info.username { - me.set_username(&data, username.clone()).await?; - } + if let Some(username) = &form.json.username { + me.set_username(&data, username.clone()).await?; + } - if let Some(display_name) = &new_info.display_name { - me.set_display_name(&data, display_name.clone()).await?; - } + if let Some(display_name) = &form.json.display_name { + me.set_display_name(&data, display_name.clone()).await?; + } - if let Some(email) = &new_info.email { - me.set_email(&data, email.to_string()).await?; - } + if let Some(email) = &form.json.email { + me.set_email(&data, email.clone()).await?; } Ok(HttpResponse::Ok().finish()) From ee8211a3216a5f0f2aeb8f2154eec78ba9027aa3 Mon Sep 17 00:00:00 2001 From: Radical Date: Sun, 1 Jun 2025 15:58:07 +0200 Subject: [PATCH 76/92] feat: add pronouns to users --- .../down.sql | 2 ++ .../up.sql | 2 ++ src/api/v1/me/mod.rs | 5 +++++ src/schema.rs | 2 ++ src/structs.rs | 21 +++++++++++++++++++ 5 files changed, 32 insertions(+) create mode 100644 migrations/2025-06-01-134036_add_pronouns_to_users/down.sql create mode 100644 migrations/2025-06-01-134036_add_pronouns_to_users/up.sql diff --git a/migrations/2025-06-01-134036_add_pronouns_to_users/down.sql b/migrations/2025-06-01-134036_add_pronouns_to_users/down.sql new file mode 100644 index 0000000..32d891f --- /dev/null +++ b/migrations/2025-06-01-134036_add_pronouns_to_users/down.sql @@ -0,0 +1,2 @@ +-- This file should undo anything in `up.sql` +ALTER TABLE users DROP COLUMN pronouns; diff --git a/migrations/2025-06-01-134036_add_pronouns_to_users/up.sql b/migrations/2025-06-01-134036_add_pronouns_to_users/up.sql new file mode 100644 index 0000000..90807bb --- /dev/null +++ b/migrations/2025-06-01-134036_add_pronouns_to_users/up.sql @@ -0,0 +1,2 @@ +-- Your SQL goes here +ALTER TABLE users ADD COLUMN pronouns VARCHAR(32) DEFAULT NULL; diff --git a/src/api/v1/me/mod.rs b/src/api/v1/me/mod.rs index e7c8167..fc9e61b 100644 --- a/src/api/v1/me/mod.rs +++ b/src/api/v1/me/mod.rs @@ -40,6 +40,7 @@ struct NewInfo { display_name: Option, //password: Option, will probably be handled through a reset password link email: Option, + pronouns: Option, } #[derive(Debug, MultipartForm)] @@ -97,5 +98,9 @@ pub async fn update( me.set_email(&data, email.clone()).await?; } + if let Some(pronouns) = &form.json.pronouns { + me.set_pronouns(&data, pronouns.clone()).await?; + } + Ok(HttpResponse::Ok().finish()) } diff --git a/src/schema.rs b/src/schema.rs index 1b34400..3be885a 100644 --- a/src/schema.rs +++ b/src/schema.rs @@ -144,6 +144,8 @@ diesel::table! { deleted_at -> Nullable, #[max_length = 100] avatar -> Nullable, + #[max_length = 32] + pronouns -> Nullable, } } diff --git a/src/structs.rs b/src/structs.rs index 0dada1a..5e19dad 100644 --- a/src/structs.rs +++ b/src/structs.rs @@ -865,6 +865,7 @@ pub struct User { username: String, display_name: Option, avatar: Option, + pronouns: Option, } impl User { @@ -915,6 +916,7 @@ pub struct Me { username: String, display_name: Option, avatar: Option, + pronouns: Option, email: String, pub email_verified: bool, } @@ -1090,6 +1092,25 @@ impl Me { Ok(()) } + + pub async fn set_pronouns(&mut self, data: &Data, new_pronouns: String) -> Result<(), Error> { + let mut conn = data.pool.get().await?; + + use users::dsl; + update(users::table) + .filter(dsl::uuid.eq(self.uuid)) + .set(( + dsl::pronouns.eq(new_pronouns.as_str()), + )) + .execute(&mut conn) + .await?; + + if data.get_cache_key(self.uuid.to_string()).await.is_ok() { + data.del_cache_key(self.uuid.to_string()).await? + } + + Ok(()) + } } #[derive(Deserialize)] From 643f94b5805f616dcf79cb663f76236c99ef685a Mon Sep 17 00:00:00 2001 From: Radical Date: Sun, 1 Jun 2025 21:56:47 +0200 Subject: [PATCH 77/92] ci: add proper cross compiling! --- .woodpecker/build-and-publish.yml | 15 +++++++++++++++ Dockerfile | 11 ++++++----- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/.woodpecker/build-and-publish.yml b/.woodpecker/build-and-publish.yml index c0f367a..57f2761 100644 --- a/.woodpecker/build-and-publish.yml +++ b/.woodpecker/build-and-publish.yml @@ -3,6 +3,21 @@ when: branch: main steps: + - name: build-x86_64 + image: rust:bookworm + commands: + - cargo build --release + - name: build-arm64 + image: rust:bookworm + commands: + - dpkg --add-architecture arm64 + - apt-get update -y && apt-get install -y crossbuild-essential-arm64 libssl-dev:arm64 + - rustup target add aarch64-unknown-linux-gnu + - cargo build --target aarch64-unknown-linux-gnu --release + environment: + CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: aarch64-linux-gnu-gcc + PKG_CONFIG_ALLOW_CROSS: 1 + PKG_CONFIG_PATH: /usr/aarch64-linux-gnu/lib/pkgconfig - name: container-build-and-publish image: docker commands: diff --git a/Dockerfile b/Dockerfile index d7209ef..25795a9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,16 +1,17 @@ -FROM rust:bookworm AS builder +FROM --platform=linux/amd64 debian:12-slim AS prep WORKDIR /src -COPY . . - -RUN cargo build --release +COPY target/release/backend backend-amd64 +COPY target/aarch64-unknown-linux-gnu/release/backend backend-arm64 FROM debian:12-slim +ARG TARGETARCH + RUN apt update -y && apt install libssl3 ca-certificates -y && rm -rf /var/lib/apt/lists/* /var/cache/apt/* /tmp/* -COPY --from=builder /src/target/release/backend /usr/bin/gorb-backend +COPY --from=prep /src/backend-${TARGETARCH} /usr/bin/gorb-backend COPY entrypoint.sh /usr/bin/entrypoint.sh From 15eb1027845e1a322f9f6d08387389226d6a3c3c Mon Sep 17 00:00:00 2001 From: Radical Date: Sun, 1 Jun 2025 22:10:23 +0200 Subject: [PATCH 78/92] build: try to make dev bearable --- Cargo.toml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 492a284..30b5827 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,12 @@ strip = true lto = true codegen-units = 1 +# Speed up compilation to make dev bearable +[profile.dev] +debug = 0 +strip = "debuginfo" +codegen-units = 512 + [dependencies] actix-cors = "0.7.1" actix-web = "4.11" @@ -32,7 +38,7 @@ futures-util = "0.3.31" bunny-api-tokio = "0.3.0" bindet = "0.3.2" deadpool = "0.12" -diesel = { version = "2.2", features = ["uuid", "chrono"] } +diesel = { version = "2.2", features = ["uuid", "chrono"], default-features = false } diesel-async = { version = "0.5", features = ["deadpool", "postgres", "async-connection-wrapper"] } diesel_migrations = { version = "2.2.0", features = ["postgres"] } thiserror = "2.0.12" From 41defc4a252bf577b96d4d125f7a4335dc9d4b17 Mon Sep 17 00:00:00 2001 From: Radical Date: Sun, 1 Jun 2025 22:10:37 +0200 Subject: [PATCH 79/92] feat: add patch request to channels! --- src/api/v1/channels/uuid/mod.rs | 82 +++++++++++++++++++++++++- src/structs.rs | 101 +++++++++++++++++++++++++++++--- src/utils.rs | 3 + 3 files changed, 178 insertions(+), 8 deletions(-) diff --git a/src/api/v1/channels/uuid/mod.rs b/src/api/v1/channels/uuid/mod.rs index f429159..1874dfc 100644 --- a/src/api/v1/channels/uuid/mod.rs +++ b/src/api/v1/channels/uuid/mod.rs @@ -1,3 +1,5 @@ +//! `/api/v1/channels/{uuid}` Channel specific endpoints + pub mod messages; pub mod socket; @@ -8,8 +10,9 @@ use crate::{ structs::{Channel, Member}, utils::{get_auth_header, global_checks}, }; -use actix_web::{HttpRequest, HttpResponse, delete, get, web}; +use actix_web::{delete, get, patch, web, HttpRequest, HttpResponse}; use uuid::Uuid; +use serde::Deserialize; #[get("/{uuid}")] pub async fn get( @@ -62,3 +65,80 @@ pub async fn delete( Ok(HttpResponse::Ok().finish()) } + +#[derive(Deserialize)] +struct NewInfo { + name: Option, + description: Option, + is_above: Option, +} + +/// `PATCH /api/v1/channels/{uuid}` Returns user with the given UUID +/// +/// requires auth: yes +/// +/// requires relation: yes +/// +/// ### Request Example +/// All fields are optional and can be nulled/dropped if only changing 1 value +/// ``` +/// json!({ +/// "name": "gaming-chat", +/// "description": "Gaming related topics.", +/// "is_above": "398f6d7b-752c-4348-9771-fe6024adbfb1" +/// }); +/// ``` +/// +/// ### Response Example +/// ``` +/// json!({ +/// uuid: "cdcac171-5add-4f88-9559-3a247c8bba2c", +/// guild_uuid: "383d2afa-082f-4dd3-9050-ca6ed91487b6", +/// name: "gaming-chat", +/// description: "Gaming related topics.", +/// is_above: "398f6d7b-752c-4348-9771-fe6024adbfb1", +/// permissions: { +/// role_uuid: "79cc0806-0f37-4a06-a468-6639c4311a2d", +/// permissions: 0 +/// } +/// }); +/// ``` +/// NOTE: UUIDs in this response are made using `uuidgen`, UUIDs made by the actual backend will be UUIDv7 and have extractable timestamps +#[patch("/{uuid}")] +pub async fn patch( + req: HttpRequest, + path: web::Path<(Uuid,)>, + new_info: web::Json, + data: web::Data, +) -> Result { + let headers = req.headers(); + + let auth_header = get_auth_header(headers)?; + + let channel_uuid = path.into_inner().0; + + let mut conn = data.pool.get().await?; + + let uuid = check_access_token(auth_header, &mut conn).await?; + + global_checks(&data, uuid).await?; + + let mut channel = Channel::fetch_one(&data, channel_uuid).await?; + + Member::check_membership(&mut conn, uuid, channel.guild_uuid).await?; + + if let Some(new_name) = &new_info.name { + channel.set_name(&data, new_name.to_string()).await?; + } + + if let Some(new_description) = &new_info.description { + channel.set_description(&data, new_description.to_string()).await?; + } + + if let Some(new_is_above) = &new_info.is_above { + channel.set_description(&data, new_is_above.to_string()).await?; + } + + Ok(HttpResponse::Ok().json(channel)) +} + diff --git a/src/structs.rs b/src/structs.rs index 5e19dad..b955133 100644 --- a/src/structs.rs +++ b/src/structs.rs @@ -24,13 +24,9 @@ use url::Url; use uuid::Uuid; use crate::{ - Conn, Data, - error::Error, - schema::*, - utils::{ - EMAIL_REGEX, PASSWORD_REGEX, USERNAME_REGEX, generate_refresh_token, global_checks, - image_check, order_by_is_above, user_uuid_from_identifier, - }, + error::Error, schema::*, utils::{ + generate_refresh_token, global_checks, image_check, order_by_is_above, user_uuid_from_identifier, CHANNEL_REGEX, EMAIL_REGEX, PASSWORD_REGEX, USERNAME_REGEX + }, Conn, Data }; pub trait HasUuid { @@ -231,6 +227,10 @@ impl Channel { name: String, description: Option, ) -> Result { + if !CHANNEL_REGEX.is_match(&name) { + return Err(Error::BadRequest("Channel name is invalid".to_string())) + } + let mut conn = data.pool.get().await?; let channel_uuid = Uuid::now_v7(); @@ -353,6 +353,93 @@ impl Channel { message.build(data).await } + + pub async fn set_name(&mut self, data: &Data, new_name: String) -> Result<(), Error> { + if !CHANNEL_REGEX.is_match(&new_name) { + return Err(Error::BadRequest("Channel name is invalid".to_string())) + } + + let mut conn = data.pool.get().await?; + + use channels::dsl; + update(channels::table) + .filter(dsl::uuid.eq(self.uuid)) + .set(dsl::name.eq(&new_name)) + .execute(&mut conn) + .await?; + + self.name = new_name; + + Ok(()) + } + + pub async fn set_description(&mut self, data: &Data, new_description: String) -> Result<(), Error> { + let mut conn = data.pool.get().await?; + + use channels::dsl; + update(channels::table) + .filter(dsl::uuid.eq(self.uuid)) + .set(dsl::description.eq(&new_description)) + .execute(&mut conn) + .await?; + + self.description = Some(new_description); + + Ok(()) + } + + pub async fn move_channel(&mut self, data: &Data, new_is_above: Uuid) -> Result<(), Error> { + let mut conn = data.pool.get().await?; + + use channels::dsl; + let old_above_uuid: Option = match dsl::channels + .filter(dsl::is_above.eq(self.uuid)) + .select(dsl::uuid) + .get_result(&mut conn) + .await + { + Ok(r) => Ok(Some(r)), + Err(e) if e == diesel::result::Error::NotFound => Ok(None), + Err(e) => Err(e), + }?; + + if let Some(uuid) = old_above_uuid { + update(channels::table) + .filter(dsl::uuid.eq(uuid)) + .set(dsl::is_above.eq(None::)) + .execute(&mut conn) + .await?; + } + + match update(channels::table) + .filter(dsl::is_above.eq(new_is_above)) + .set(dsl::is_above.eq(self.uuid)) + .execute(&mut conn) + .await + { + Ok(r) => Ok(r), + Err(e) if e == diesel::result::Error::NotFound => Ok(0), + Err(e) => Err(e), + }?; + + update(channels::table) + .filter(dsl::uuid.eq(self.uuid)) + .set(dsl::is_above.eq(new_is_above)) + .execute(&mut conn) + .await?; + + if let Some(uuid) = old_above_uuid { + update(channels::table) + .filter(dsl::uuid.eq(uuid)) + .set(dsl::is_above.eq(self.is_above)) + .execute(&mut conn) + .await?; + } + + self.is_above = Some(new_is_above); + + Ok(()) + } } #[derive(Clone, Copy)] diff --git a/src/utils.rs b/src/utils.rs index c9f3cb2..3bf7332 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -30,6 +30,9 @@ pub static EMAIL_REGEX: LazyLock = LazyLock::new(|| { pub static USERNAME_REGEX: LazyLock = LazyLock::new(|| Regex::new(r"^[a-z0-9_.-]+$").unwrap()); +pub static CHANNEL_REGEX: LazyLock = + LazyLock::new(|| Regex::new(r"^[a-z0-9_.-]+$").unwrap()); + // Password is expected to be hashed using SHA3-384 pub static PASSWORD_REGEX: LazyLock = LazyLock::new(|| Regex::new(r"[0-9a-f]{96}").unwrap()); From c4fc23ec85f5da1422688279de9848b1e710abf8 Mon Sep 17 00:00:00 2001 From: Radical Date: Sun, 1 Jun 2025 22:20:29 +0200 Subject: [PATCH 80/92] feat: add about to users --- .../down.sql | 2 ++ .../up.sql | 2 ++ src/api/v1/me/mod.rs | 5 +++++ src/schema.rs | 2 ++ src/structs.rs | 21 +++++++++++++++++++ 5 files changed, 32 insertions(+) create mode 100644 migrations/2025-06-01-143713_add_about_to_users/down.sql create mode 100644 migrations/2025-06-01-143713_add_about_to_users/up.sql diff --git a/migrations/2025-06-01-143713_add_about_to_users/down.sql b/migrations/2025-06-01-143713_add_about_to_users/down.sql new file mode 100644 index 0000000..de48d07 --- /dev/null +++ b/migrations/2025-06-01-143713_add_about_to_users/down.sql @@ -0,0 +1,2 @@ +-- This file should undo anything in `up.sql` +ALTER TABLE users DROP COLUMN about; \ No newline at end of file diff --git a/migrations/2025-06-01-143713_add_about_to_users/up.sql b/migrations/2025-06-01-143713_add_about_to_users/up.sql new file mode 100644 index 0000000..54b5449 --- /dev/null +++ b/migrations/2025-06-01-143713_add_about_to_users/up.sql @@ -0,0 +1,2 @@ +-- Your SQL goes here +ALTER TABLE users ADD COLUMN about VARCHAR(200) DEFAULT NULL; diff --git a/src/api/v1/me/mod.rs b/src/api/v1/me/mod.rs index fc9e61b..ac35140 100644 --- a/src/api/v1/me/mod.rs +++ b/src/api/v1/me/mod.rs @@ -41,6 +41,7 @@ struct NewInfo { //password: Option, will probably be handled through a reset password link email: Option, pronouns: Option, + about: Option, } #[derive(Debug, MultipartForm)] @@ -102,5 +103,9 @@ pub async fn update( me.set_pronouns(&data, pronouns.clone()).await?; } + if let Some(about) = &form.json.about { + me.set_about(&data, about.clone()).await?; + } + Ok(HttpResponse::Ok().finish()) } diff --git a/src/schema.rs b/src/schema.rs index 3be885a..09ea7a3 100644 --- a/src/schema.rs +++ b/src/schema.rs @@ -146,6 +146,8 @@ diesel::table! { avatar -> Nullable, #[max_length = 32] pronouns -> Nullable, + #[max_length = 200] + about -> Nullable, } } diff --git a/src/structs.rs b/src/structs.rs index b955133..b68aaf5 100644 --- a/src/structs.rs +++ b/src/structs.rs @@ -953,6 +953,7 @@ pub struct User { display_name: Option, avatar: Option, pronouns: Option, + about: Option, } impl User { @@ -1004,6 +1005,7 @@ pub struct Me { display_name: Option, avatar: Option, pronouns: Option, + about: Option, email: String, pub email_verified: bool, } @@ -1198,6 +1200,25 @@ impl Me { Ok(()) } + + pub async fn set_about(&mut self, data: &Data, new_about: String) -> Result<(), Error> { + let mut conn = data.pool.get().await?; + + use users::dsl; + update(users::table) + .filter(dsl::uuid.eq(self.uuid)) + .set(( + dsl::about.eq(new_about.as_str()), + )) + .execute(&mut conn) + .await?; + + if data.get_cache_key(self.uuid.to_string()).await.is_ok() { + data.del_cache_key(self.uuid.to_string()).await? + } + + Ok(()) + } } #[derive(Deserialize)] From 08cb70ce18b27966b118b11a02bda654d2c79947 Mon Sep 17 00:00:00 2001 From: Radical Date: Sun, 1 Jun 2025 23:43:14 +0200 Subject: [PATCH 81/92] fix: add patch request as a service in actix whoops forgot to add /channels/{uuid} patch request into actix --- src/api/v1/channels/mod.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/api/v1/channels/mod.rs b/src/api/v1/channels/mod.rs index 999bb23..e9558c9 100644 --- a/src/api/v1/channels/mod.rs +++ b/src/api/v1/channels/mod.rs @@ -6,6 +6,7 @@ pub fn web() -> Scope { web::scope("/channels") .service(uuid::get) .service(uuid::delete) + .service(uuid::patch) .service(uuid::messages::get) .service(uuid::socket::ws) } From 7021c80f0200931c247192953bb0f265688bf1d3 Mon Sep 17 00:00:00 2001 From: Radical Date: Mon, 2 Jun 2025 00:28:48 +0200 Subject: [PATCH 82/92] style: move structs to objects and split into several files for readability --- src/api/v1/auth/reset_password.rs | 2 +- src/api/v1/auth/verify_email.rs | 2 +- src/api/v1/channels/uuid/messages.rs | 2 +- src/api/v1/channels/uuid/mod.rs | 17 +- src/api/v1/channels/uuid/socket.rs | 2 +- src/api/v1/guilds/mod.rs | 2 +- src/api/v1/guilds/uuid/channels.rs | 2 +- src/api/v1/guilds/uuid/icon.rs | 2 +- src/api/v1/guilds/uuid/invites/mod.rs | 2 +- src/api/v1/guilds/uuid/members.rs | 2 +- src/api/v1/guilds/uuid/mod.rs | 2 +- src/api/v1/guilds/uuid/roles/mod.rs | 2 +- src/api/v1/guilds/uuid/roles/uuid.rs | 2 +- src/api/v1/invites/id.rs | 2 +- src/api/v1/me/guilds.rs | 2 +- src/api/v1/me/mod.rs | 15 +- src/api/v1/users/mod.rs | 2 +- src/api/v1/users/uuid.rs | 2 +- src/main.rs | 4 +- src/objects/channel.rs | 353 ++++++ src/objects/email_token.rs | 80 ++ src/objects/guild.rs | 226 ++++ src/objects/invite.rs | 30 + src/objects/me.rs | 232 ++++ src/objects/member.rs | 125 +++ src/objects/message.rs | 40 + src/objects/mod.rs | 156 +++ src/objects/password_reset_token.rs | 160 +++ src/objects/role.rs | 96 ++ src/objects/user.rs | 60 ++ src/structs.rs | 1437 ------------------------- src/utils.rs | 2 +- 32 files changed, 1591 insertions(+), 1474 deletions(-) create mode 100644 src/objects/channel.rs create mode 100644 src/objects/email_token.rs create mode 100644 src/objects/guild.rs create mode 100644 src/objects/invite.rs create mode 100644 src/objects/me.rs create mode 100644 src/objects/member.rs create mode 100644 src/objects/message.rs create mode 100644 src/objects/mod.rs create mode 100644 src/objects/password_reset_token.rs create mode 100644 src/objects/role.rs create mode 100644 src/objects/user.rs delete mode 100644 src/structs.rs diff --git a/src/api/v1/auth/reset_password.rs b/src/api/v1/auth/reset_password.rs index 8240fbd..4373a82 100644 --- a/src/api/v1/auth/reset_password.rs +++ b/src/api/v1/auth/reset_password.rs @@ -4,7 +4,7 @@ use actix_web::{HttpResponse, get, post, web}; use chrono::{Duration, Utc}; use serde::Deserialize; -use crate::{Data, error::Error, structs::PasswordResetToken}; +use crate::{Data, error::Error, objects::PasswordResetToken}; #[derive(Deserialize)] struct Query { diff --git a/src/api/v1/auth/verify_email.rs b/src/api/v1/auth/verify_email.rs index c5c9097..0f23649 100644 --- a/src/api/v1/auth/verify_email.rs +++ b/src/api/v1/auth/verify_email.rs @@ -8,7 +8,7 @@ use crate::{ Data, api::v1::auth::check_access_token, error::Error, - structs::{EmailToken, Me}, + objects::{EmailToken, Me}, utils::get_auth_header, }; diff --git a/src/api/v1/channels/uuid/messages.rs b/src/api/v1/channels/uuid/messages.rs index ddcc800..9fdea0b 100644 --- a/src/api/v1/channels/uuid/messages.rs +++ b/src/api/v1/channels/uuid/messages.rs @@ -4,7 +4,7 @@ use crate::{ Data, api::v1::auth::check_access_token, error::Error, - structs::{Channel, Member}, + objects::{Channel, Member}, utils::{get_auth_header, global_checks}, }; use ::uuid::Uuid; diff --git a/src/api/v1/channels/uuid/mod.rs b/src/api/v1/channels/uuid/mod.rs index 1874dfc..1cb20c7 100644 --- a/src/api/v1/channels/uuid/mod.rs +++ b/src/api/v1/channels/uuid/mod.rs @@ -7,12 +7,12 @@ use crate::{ Data, api::v1::auth::check_access_token, error::Error, - structs::{Channel, Member}, + objects::{Channel, Member}, utils::{get_auth_header, global_checks}, }; -use actix_web::{delete, get, patch, web, HttpRequest, HttpResponse}; -use uuid::Uuid; +use actix_web::{HttpRequest, HttpResponse, delete, get, patch, web}; use serde::Deserialize; +use uuid::Uuid; #[get("/{uuid}")] pub async fn get( @@ -88,7 +88,7 @@ struct NewInfo { /// "is_above": "398f6d7b-752c-4348-9771-fe6024adbfb1" /// }); /// ``` -/// +/// /// ### Response Example /// ``` /// json!({ @@ -132,13 +132,16 @@ pub async fn patch( } if let Some(new_description) = &new_info.description { - channel.set_description(&data, new_description.to_string()).await?; + channel + .set_description(&data, new_description.to_string()) + .await?; } if let Some(new_is_above) = &new_info.is_above { - channel.set_description(&data, new_is_above.to_string()).await?; + channel + .set_description(&data, new_is_above.to_string()) + .await?; } Ok(HttpResponse::Ok().json(channel)) } - diff --git a/src/api/v1/channels/uuid/socket.rs b/src/api/v1/channels/uuid/socket.rs index 556dca3..b346e8e 100644 --- a/src/api/v1/channels/uuid/socket.rs +++ b/src/api/v1/channels/uuid/socket.rs @@ -10,7 +10,7 @@ use uuid::Uuid; use crate::{ Data, api::v1::auth::check_access_token, - structs::{Channel, Member}, + objects::{Channel, Member}, utils::{get_ws_protocol_header, global_checks}, }; diff --git a/src/api/v1/guilds/mod.rs b/src/api/v1/guilds/mod.rs index b7a7a7c..ada5dc8 100644 --- a/src/api/v1/guilds/mod.rs +++ b/src/api/v1/guilds/mod.rs @@ -9,7 +9,7 @@ use crate::{ Data, api::v1::auth::check_access_token, error::Error, - structs::{Guild, StartAmountQuery}, + objects::{Guild, StartAmountQuery}, utils::{get_auth_header, global_checks}, }; diff --git a/src/api/v1/guilds/uuid/channels.rs b/src/api/v1/guilds/uuid/channels.rs index 0dd4566..083553a 100644 --- a/src/api/v1/guilds/uuid/channels.rs +++ b/src/api/v1/guilds/uuid/channels.rs @@ -2,7 +2,7 @@ use crate::{ Data, api::v1::auth::check_access_token, error::Error, - structs::{Channel, Member}, + objects::{Channel, Member}, utils::{get_auth_header, global_checks, order_by_is_above}, }; use ::uuid::Uuid; diff --git a/src/api/v1/guilds/uuid/icon.rs b/src/api/v1/guilds/uuid/icon.rs index f2e15b6..5025416 100644 --- a/src/api/v1/guilds/uuid/icon.rs +++ b/src/api/v1/guilds/uuid/icon.rs @@ -8,7 +8,7 @@ use crate::{ Data, api::v1::auth::check_access_token, error::Error, - structs::{Guild, Member}, + objects::{Guild, Member}, utils::{get_auth_header, global_checks}, }; diff --git a/src/api/v1/guilds/uuid/invites/mod.rs b/src/api/v1/guilds/uuid/invites/mod.rs index ea04529..f4f06bc 100644 --- a/src/api/v1/guilds/uuid/invites/mod.rs +++ b/src/api/v1/guilds/uuid/invites/mod.rs @@ -6,7 +6,7 @@ use crate::{ Data, api::v1::auth::check_access_token, error::Error, - structs::{Guild, Member}, + objects::{Guild, Member}, utils::{get_auth_header, global_checks}, }; diff --git a/src/api/v1/guilds/uuid/members.rs b/src/api/v1/guilds/uuid/members.rs index d7ed0a5..972d862 100644 --- a/src/api/v1/guilds/uuid/members.rs +++ b/src/api/v1/guilds/uuid/members.rs @@ -2,7 +2,7 @@ use crate::{ Data, api::v1::auth::check_access_token, error::Error, - structs::Member, + objects::Member, utils::{get_auth_header, global_checks}, }; use ::uuid::Uuid; diff --git a/src/api/v1/guilds/uuid/mod.rs b/src/api/v1/guilds/uuid/mod.rs index c24e957..4c88d7a 100644 --- a/src/api/v1/guilds/uuid/mod.rs +++ b/src/api/v1/guilds/uuid/mod.rs @@ -13,7 +13,7 @@ use crate::{ Data, api::v1::auth::check_access_token, error::Error, - structs::{Guild, Member}, + objects::{Guild, Member}, utils::{get_auth_header, global_checks}, }; diff --git a/src/api/v1/guilds/uuid/roles/mod.rs b/src/api/v1/guilds/uuid/roles/mod.rs index 8015384..717b30b 100644 --- a/src/api/v1/guilds/uuid/roles/mod.rs +++ b/src/api/v1/guilds/uuid/roles/mod.rs @@ -6,7 +6,7 @@ use crate::{ Data, api::v1::auth::check_access_token, error::Error, - structs::{Member, Role}, + objects::{Member, Role}, utils::{get_auth_header, global_checks, order_by_is_above}, }; diff --git a/src/api/v1/guilds/uuid/roles/uuid.rs b/src/api/v1/guilds/uuid/roles/uuid.rs index 0e7f306..f1a3206 100644 --- a/src/api/v1/guilds/uuid/roles/uuid.rs +++ b/src/api/v1/guilds/uuid/roles/uuid.rs @@ -2,7 +2,7 @@ use crate::{ Data, api::v1::auth::check_access_token, error::Error, - structs::{Member, Role}, + objects::{Member, Role}, utils::{get_auth_header, global_checks}, }; use ::uuid::Uuid; diff --git a/src/api/v1/invites/id.rs b/src/api/v1/invites/id.rs index 687b825..22e2868 100644 --- a/src/api/v1/invites/id.rs +++ b/src/api/v1/invites/id.rs @@ -4,7 +4,7 @@ use crate::{ Data, api::v1::auth::check_access_token, error::Error, - structs::{Guild, Invite, Member}, + objects::{Guild, Invite, Member}, utils::{get_auth_header, global_checks}, }; diff --git a/src/api/v1/me/guilds.rs b/src/api/v1/me/guilds.rs index 7fe02bd..71cfca4 100644 --- a/src/api/v1/me/guilds.rs +++ b/src/api/v1/me/guilds.rs @@ -6,7 +6,7 @@ use crate::{ Data, api::v1::auth::check_access_token, error::Error, - structs::Me, + objects::Me, utils::{get_auth_header, global_checks}, }; diff --git a/src/api/v1/me/mod.rs b/src/api/v1/me/mod.rs index ac35140..da5c929 100644 --- a/src/api/v1/me/mod.rs +++ b/src/api/v1/me/mod.rs @@ -6,7 +6,7 @@ use crate::{ Data, api::v1::auth::check_access_token, error::Error, - structs::Me, + objects::Me, utils::{get_auth_header, global_checks}, }; @@ -65,10 +65,7 @@ pub async fn update( let uuid = check_access_token(auth_header, &mut conn).await?; - if form.avatar.is_some() - || form.json.username.is_some() - || form.json.display_name.is_some() - { + if form.avatar.is_some() || form.json.username.is_some() || form.json.display_name.is_some() { global_checks(&data, uuid).await?; } @@ -79,12 +76,8 @@ pub async fn update( let byte_slice: &[u8] = &bytes; - me.set_avatar( - &data, - data.config.bunny.cdn_url.clone(), - byte_slice.into(), - ) - .await?; + me.set_avatar(&data, data.config.bunny.cdn_url.clone(), byte_slice.into()) + .await?; } if let Some(username) = &form.json.username { diff --git a/src/api/v1/users/mod.rs b/src/api/v1/users/mod.rs index fd3980d..334fd5f 100644 --- a/src/api/v1/users/mod.rs +++ b/src/api/v1/users/mod.rs @@ -6,7 +6,7 @@ use crate::{ Data, api::v1::auth::check_access_token, error::Error, - structs::{StartAmountQuery, User}, + objects::{StartAmountQuery, User}, utils::{get_auth_header, global_checks}, }; diff --git a/src/api/v1/users/uuid.rs b/src/api/v1/users/uuid.rs index 213afe5..9e602a0 100644 --- a/src/api/v1/users/uuid.rs +++ b/src/api/v1/users/uuid.rs @@ -7,7 +7,7 @@ use crate::{ Data, api::v1::auth::check_access_token, error::Error, - structs::User, + objects::User, utils::{get_auth_header, global_checks}, }; diff --git a/src/main.rs b/src/main.rs index d026f55..9a24d3d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,9 +5,9 @@ use clap::Parser; use diesel_async::pooled_connection::AsyncDieselConnectionManager; use diesel_async::pooled_connection::deadpool::Pool; use error::Error; +use objects::MailClient; use simple_logger::SimpleLogger; use std::time::SystemTime; -use structs::MailClient; mod config; use config::{Config, ConfigBuilder}; use diesel_migrations::{EmbeddedMigrations, MigrationHarness, embed_migrations}; @@ -19,8 +19,8 @@ type Conn = mod api; pub mod error; +pub mod objects; pub mod schema; -pub mod structs; pub mod utils; #[derive(Parser, Debug)] diff --git a/src/objects/channel.rs b/src/objects/channel.rs new file mode 100644 index 0000000..c9b5f1f --- /dev/null +++ b/src/objects/channel.rs @@ -0,0 +1,353 @@ +use diesel::{ + ExpressionMethods, Insertable, QueryDsl, Queryable, Selectable, SelectableHelper, delete, + insert_into, update, +}; +use diesel_async::{RunQueryDsl, pooled_connection::AsyncDieselConnectionManager}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::{ + Conn, Data, + error::Error, + schema::{channel_permissions, channels, messages}, + utils::{CHANNEL_REGEX, order_by_is_above}, +}; + +use super::{HasIsAbove, HasUuid, Message, load_or_empty, message::MessageBuilder}; + +#[derive(Queryable, Selectable, Insertable, Clone, Debug)] +#[diesel(table_name = channels)] +#[diesel(check_for_backend(diesel::pg::Pg))] +struct ChannelBuilder { + uuid: Uuid, + guild_uuid: Uuid, + name: String, + description: Option, + is_above: Option, +} + +impl ChannelBuilder { + async fn build(self, conn: &mut Conn) -> Result { + use self::channel_permissions::dsl::*; + let channel_permission: Vec = load_or_empty( + channel_permissions + .filter(channel_uuid.eq(self.uuid)) + .select(ChannelPermission::as_select()) + .load(conn) + .await, + )?; + + Ok(Channel { + uuid: self.uuid, + guild_uuid: self.guild_uuid, + name: self.name, + description: self.description, + is_above: self.is_above, + permissions: channel_permission, + }) + } +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct Channel { + pub uuid: Uuid, + pub guild_uuid: Uuid, + name: String, + description: Option, + pub is_above: Option, + pub permissions: Vec, +} + +#[derive(Serialize, Deserialize, Clone, Queryable, Selectable, Debug)] +#[diesel(table_name = channel_permissions)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct ChannelPermission { + pub role_uuid: Uuid, + pub permissions: i64, +} + +impl HasUuid for Channel { + fn uuid(&self) -> &Uuid { + self.uuid.as_ref() + } +} + +impl HasIsAbove for Channel { + fn is_above(&self) -> Option<&Uuid> { + self.is_above.as_ref() + } +} + +impl Channel { + pub async fn fetch_all( + pool: &deadpool::managed::Pool< + AsyncDieselConnectionManager, + Conn, + >, + guild_uuid: Uuid, + ) -> Result, Error> { + let mut conn = pool.get().await?; + + use channels::dsl; + let channel_builders: Vec = load_or_empty( + dsl::channels + .filter(dsl::guild_uuid.eq(guild_uuid)) + .select(ChannelBuilder::as_select()) + .load(&mut conn) + .await, + )?; + + let channel_futures = channel_builders.iter().map(async move |c| { + let mut conn = pool.get().await?; + c.clone().build(&mut conn).await + }); + + futures::future::try_join_all(channel_futures).await + } + + pub async fn fetch_one(data: &Data, channel_uuid: Uuid) -> Result { + if let Ok(cache_hit) = data.get_cache_key(channel_uuid.to_string()).await { + return Ok(serde_json::from_str(&cache_hit)?); + } + + let mut conn = data.pool.get().await?; + + use channels::dsl; + let channel_builder: ChannelBuilder = dsl::channels + .filter(dsl::uuid.eq(channel_uuid)) + .select(ChannelBuilder::as_select()) + .get_result(&mut conn) + .await?; + + let channel = channel_builder.build(&mut conn).await?; + + data.set_cache_key(channel_uuid.to_string(), channel.clone(), 60) + .await?; + + Ok(channel) + } + + pub async fn new( + data: actix_web::web::Data, + guild_uuid: Uuid, + name: String, + description: Option, + ) -> Result { + if !CHANNEL_REGEX.is_match(&name) { + return Err(Error::BadRequest("Channel name is invalid".to_string())); + } + + let mut conn = data.pool.get().await?; + + let channel_uuid = Uuid::now_v7(); + + let channels = Self::fetch_all(&data.pool, guild_uuid).await?; + + let channels_ordered = order_by_is_above(channels).await?; + + let last_channel = channels_ordered.last(); + + let new_channel = ChannelBuilder { + uuid: channel_uuid, + guild_uuid, + name: name.clone(), + description: description.clone(), + is_above: None, + }; + + insert_into(channels::table) + .values(new_channel.clone()) + .execute(&mut conn) + .await?; + + if let Some(old_last_channel) = last_channel { + use channels::dsl; + update(channels::table) + .filter(dsl::uuid.eq(old_last_channel.uuid)) + .set(dsl::is_above.eq(new_channel.uuid)) + .execute(&mut conn) + .await?; + } + + // returns different object because there's no reason to build the channelbuilder (wastes 1 database request) + let channel = Self { + uuid: channel_uuid, + guild_uuid, + name, + description, + is_above: None, + permissions: vec![], + }; + + data.set_cache_key(channel_uuid.to_string(), channel.clone(), 1800) + .await?; + + if data + .get_cache_key(format!("{}_channels", guild_uuid)) + .await + .is_ok() + { + data.del_cache_key(format!("{}_channels", guild_uuid)) + .await?; + } + + Ok(channel) + } + + pub async fn delete(self, data: &Data) -> Result<(), Error> { + let mut conn = data.pool.get().await?; + + use channels::dsl; + delete(channels::table) + .filter(dsl::uuid.eq(self.uuid)) + .execute(&mut conn) + .await?; + + if data.get_cache_key(self.uuid.to_string()).await.is_ok() { + data.del_cache_key(self.uuid.to_string()).await?; + } + + Ok(()) + } + + pub async fn fetch_messages( + &self, + data: &Data, + amount: i64, + offset: i64, + ) -> Result, Error> { + let mut conn = data.pool.get().await?; + + use messages::dsl; + let messages: Vec = load_or_empty( + dsl::messages + .filter(dsl::channel_uuid.eq(self.uuid)) + .select(MessageBuilder::as_select()) + .order(dsl::uuid.desc()) + .limit(amount) + .offset(offset) + .load(&mut conn) + .await, + )?; + + let message_futures = messages.iter().map(async move |b| b.build(data).await); + + futures::future::try_join_all(message_futures).await + } + + pub async fn new_message( + &self, + data: &Data, + user_uuid: Uuid, + message: String, + ) -> Result { + let message_uuid = Uuid::now_v7(); + + let message = MessageBuilder { + uuid: message_uuid, + channel_uuid: self.uuid, + user_uuid, + message, + }; + + let mut conn = data.pool.get().await?; + + insert_into(messages::table) + .values(message.clone()) + .execute(&mut conn) + .await?; + + message.build(data).await + } + + pub async fn set_name(&mut self, data: &Data, new_name: String) -> Result<(), Error> { + if !CHANNEL_REGEX.is_match(&new_name) { + return Err(Error::BadRequest("Channel name is invalid".to_string())); + } + + let mut conn = data.pool.get().await?; + + use channels::dsl; + update(channels::table) + .filter(dsl::uuid.eq(self.uuid)) + .set(dsl::name.eq(&new_name)) + .execute(&mut conn) + .await?; + + self.name = new_name; + + Ok(()) + } + + pub async fn set_description( + &mut self, + data: &Data, + new_description: String, + ) -> Result<(), Error> { + let mut conn = data.pool.get().await?; + + use channels::dsl; + update(channels::table) + .filter(dsl::uuid.eq(self.uuid)) + .set(dsl::description.eq(&new_description)) + .execute(&mut conn) + .await?; + + self.description = Some(new_description); + + Ok(()) + } + + pub async fn move_channel(&mut self, data: &Data, new_is_above: Uuid) -> Result<(), Error> { + let mut conn = data.pool.get().await?; + + use channels::dsl; + let old_above_uuid: Option = match dsl::channels + .filter(dsl::is_above.eq(self.uuid)) + .select(dsl::uuid) + .get_result(&mut conn) + .await + { + Ok(r) => Ok(Some(r)), + Err(e) if e == diesel::result::Error::NotFound => Ok(None), + Err(e) => Err(e), + }?; + + if let Some(uuid) = old_above_uuid { + update(channels::table) + .filter(dsl::uuid.eq(uuid)) + .set(dsl::is_above.eq(None::)) + .execute(&mut conn) + .await?; + } + + match update(channels::table) + .filter(dsl::is_above.eq(new_is_above)) + .set(dsl::is_above.eq(self.uuid)) + .execute(&mut conn) + .await + { + Ok(r) => Ok(r), + Err(e) if e == diesel::result::Error::NotFound => Ok(0), + Err(e) => Err(e), + }?; + + update(channels::table) + .filter(dsl::uuid.eq(self.uuid)) + .set(dsl::is_above.eq(new_is_above)) + .execute(&mut conn) + .await?; + + if let Some(uuid) = old_above_uuid { + update(channels::table) + .filter(dsl::uuid.eq(uuid)) + .set(dsl::is_above.eq(self.is_above)) + .execute(&mut conn) + .await?; + } + + self.is_above = Some(new_is_above); + + Ok(()) + } +} diff --git a/src/objects/email_token.rs b/src/objects/email_token.rs new file mode 100644 index 0000000..e458cf7 --- /dev/null +++ b/src/objects/email_token.rs @@ -0,0 +1,80 @@ +use chrono::Utc; +use diesel::{ + ExpressionMethods, QueryDsl, Queryable, Selectable, SelectableHelper, delete, dsl::now, + insert_into, +}; +use diesel_async::RunQueryDsl; +use lettre::message::MultiPart; +use uuid::Uuid; + +use crate::{Conn, Data, error::Error, schema::email_tokens, utils::generate_refresh_token}; + +use super::Me; + +#[derive(Selectable, Queryable)] +#[diesel(table_name = email_tokens)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct EmailToken { + user_uuid: Uuid, + pub token: String, + pub created_at: chrono::DateTime, +} + +impl EmailToken { + pub async fn get(conn: &mut Conn, user_uuid: Uuid) -> Result { + use email_tokens::dsl; + let email_token = dsl::email_tokens + .filter(dsl::user_uuid.eq(user_uuid)) + .select(EmailToken::as_select()) + .get_result(conn) + .await?; + + Ok(email_token) + } + + #[allow(clippy::new_ret_no_self)] + pub async fn new(data: &Data, me: Me) -> Result<(), Error> { + let token = generate_refresh_token()?; + + let mut conn = data.pool.get().await?; + + use email_tokens::dsl; + insert_into(email_tokens::table) + .values(( + dsl::user_uuid.eq(me.uuid), + dsl::token.eq(&token), + dsl::created_at.eq(now), + )) + .execute(&mut conn) + .await?; + + let mut verify_endpoint = data.config.web.frontend_url.join("verify-email")?; + + verify_endpoint.set_query(Some(&format!("token={}", token))); + + let email = data + .mail_client + .message_builder() + .to(me.email.parse()?) + .subject(format!("{} E-mail Verification", data.config.instance.name)) + .multipart(MultiPart::alternative_plain_html( + format!("Verify your {} account\n\nHello, {}!\nThanks for creating a new account on Gorb.\nThe final step to create your account is to verify your email address by visiting the page, within 24 hours.\n\n{}\n\nIf you didn't ask to verify this address, you can safely ignore this email\n\nThanks, The gorb team.", data.config.instance.name, me.username, verify_endpoint), + format!(r#"

Verify your {} Account

Hello, {}!

Thanks for creating a new account on Gorb.

The final step to create your account is to verify your email address by clicking the button below, within 24 hours.

VERIFY ACCOUNT

If you didn't ask to verify this address, you can safely ignore this email.

"#, data.config.instance.name, me.username, verify_endpoint) + ))?; + + data.mail_client.send_mail(email).await?; + + Ok(()) + } + + pub async fn delete(&self, conn: &mut Conn) -> Result<(), Error> { + use email_tokens::dsl; + delete(email_tokens::table) + .filter(dsl::user_uuid.eq(self.user_uuid)) + .filter(dsl::token.eq(&self.token)) + .execute(conn) + .await?; + + Ok(()) + } +} diff --git a/src/objects/guild.rs b/src/objects/guild.rs new file mode 100644 index 0000000..f5e973d --- /dev/null +++ b/src/objects/guild.rs @@ -0,0 +1,226 @@ +use actix_web::web::BytesMut; +use diesel::{ + ExpressionMethods, Insertable, QueryDsl, Queryable, Selectable, SelectableHelper, insert_into, + update, +}; +use diesel_async::{RunQueryDsl, pooled_connection::AsyncDieselConnectionManager}; +use serde::Serialize; +use tokio::task; +use url::Url; +use uuid::Uuid; + +use crate::{ + Conn, + error::Error, + schema::{guild_members, guilds, invites}, + utils::image_check, +}; + +use super::{Invite, Member, Role, load_or_empty, member::MemberBuilder}; + +#[derive(Serialize, Queryable, Selectable, Insertable, Clone)] +#[diesel(table_name = guilds)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct GuildBuilder { + uuid: Uuid, + name: String, + description: Option, + icon: Option, + owner_uuid: Uuid, +} + +impl GuildBuilder { + pub async fn build(self, conn: &mut Conn) -> Result { + let member_count = Member::count(conn, self.uuid).await?; + + let roles = Role::fetch_all(conn, self.uuid).await?; + + Ok(Guild { + uuid: self.uuid, + name: self.name, + description: self.description, + icon: self.icon.and_then(|i| i.parse().ok()), + owner_uuid: self.owner_uuid, + roles, + member_count, + }) + } +} + +#[derive(Serialize)] +pub struct Guild { + pub uuid: Uuid, + name: String, + description: Option, + icon: Option, + owner_uuid: Uuid, + pub roles: Vec, + member_count: i64, +} + +impl Guild { + pub async fn fetch_one(conn: &mut Conn, guild_uuid: Uuid) -> Result { + use guilds::dsl; + let guild_builder: GuildBuilder = dsl::guilds + .filter(dsl::uuid.eq(guild_uuid)) + .select(GuildBuilder::as_select()) + .get_result(conn) + .await?; + + guild_builder.build(conn).await + } + + pub async fn fetch_amount( + pool: &deadpool::managed::Pool< + AsyncDieselConnectionManager, + Conn, + >, + offset: i64, + amount: i64, + ) -> Result, Error> { + // Fetch guild data from database + let mut conn = pool.get().await?; + + use guilds::dsl; + let guild_builders: Vec = load_or_empty( + dsl::guilds + .select(GuildBuilder::as_select()) + .order_by(dsl::uuid) + .offset(offset) + .limit(amount) + .load(&mut conn) + .await, + )?; + + // Process each guild concurrently + let guild_futures = guild_builders.iter().map(async move |g| { + let mut conn = pool.get().await?; + g.clone().build(&mut conn).await + }); + + // Execute all futures concurrently and collect results + futures::future::try_join_all(guild_futures).await + } + + pub async fn new(conn: &mut Conn, name: String, owner_uuid: Uuid) -> Result { + let guild_uuid = Uuid::now_v7(); + + let guild_builder = GuildBuilder { + uuid: guild_uuid, + name: name.clone(), + description: None, + icon: None, + owner_uuid, + }; + + insert_into(guilds::table) + .values(guild_builder) + .execute(conn) + .await?; + + let member_uuid = Uuid::now_v7(); + + let member = MemberBuilder { + uuid: member_uuid, + nickname: None, + user_uuid: owner_uuid, + guild_uuid, + }; + + insert_into(guild_members::table) + .values(member) + .execute(conn) + .await?; + + Ok(Guild { + uuid: guild_uuid, + name, + description: None, + icon: None, + owner_uuid, + roles: vec![], + member_count: 1, + }) + } + + pub async fn get_invites(&self, conn: &mut Conn) -> Result, Error> { + use invites::dsl; + let invites = load_or_empty( + dsl::invites + .filter(dsl::guild_uuid.eq(self.uuid)) + .select(Invite::as_select()) + .load(conn) + .await, + )?; + + Ok(invites) + } + + pub async fn create_invite( + &self, + conn: &mut Conn, + user_uuid: Uuid, + custom_id: Option, + ) -> Result { + let invite_id; + + if let Some(id) = custom_id { + invite_id = id; + if invite_id.len() > 32 { + return Err(Error::BadRequest("MAX LENGTH".to_string())); + } + } else { + let charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + + invite_id = random_string::generate(8, charset); + } + + let invite = Invite { + id: invite_id, + user_uuid, + guild_uuid: self.uuid, + }; + + insert_into(invites::table) + .values(invite.clone()) + .execute(conn) + .await?; + + Ok(invite) + } + + // 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 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.path().trim_start_matches('/'); + + bunny_cdn.storage.delete(relative_url).await?; + } + + let path = format!("icons/{}/icon.{}", self.uuid, image_type); + + bunny_cdn.storage.upload(path.clone(), icon.into()).await?; + + let icon_url = cdn_url.join(&path)?; + + use guilds::dsl; + update(guilds::table) + .filter(dsl::uuid.eq(self.uuid)) + .set(dsl::icon.eq(icon_url.as_str())) + .execute(conn) + .await?; + + self.icon = Some(icon_url); + + Ok(()) + } +} diff --git a/src/objects/invite.rs b/src/objects/invite.rs new file mode 100644 index 0000000..5e0827e --- /dev/null +++ b/src/objects/invite.rs @@ -0,0 +1,30 @@ +use diesel::{ExpressionMethods, Insertable, QueryDsl, Queryable, Selectable, SelectableHelper}; +use diesel_async::RunQueryDsl; +use serde::Serialize; +use uuid::Uuid; + +use crate::{Conn, error::Error, schema::invites}; + +/// Server invite struct +#[derive(Clone, Serialize, Queryable, Selectable, Insertable)] +pub struct Invite { + /// case-sensitive alphanumeric string with a fixed length of 8 characters, can be up to 32 characters for custom invites + pub id: String, + /// User that created the invite + pub user_uuid: Uuid, + /// UUID of the guild that the invite belongs to + pub guild_uuid: Uuid, +} + +impl Invite { + pub async fn fetch_one(conn: &mut Conn, invite_id: String) -> Result { + use invites::dsl; + let invite: Invite = dsl::invites + .filter(dsl::id.eq(invite_id)) + .select(Invite::as_select()) + .get_result(conn) + .await?; + + Ok(invite) + } +} diff --git a/src/objects/me.rs b/src/objects/me.rs new file mode 100644 index 0000000..6af5bce --- /dev/null +++ b/src/objects/me.rs @@ -0,0 +1,232 @@ +use actix_web::web::BytesMut; +use diesel::{ExpressionMethods, QueryDsl, Queryable, Selectable, SelectableHelper, update}; +use diesel_async::RunQueryDsl; +use serde::Serialize; +use tokio::task; +use url::Url; +use uuid::Uuid; + +use crate::{ + Conn, Data, + error::Error, + schema::{guild_members, guilds, users}, + utils::{EMAIL_REGEX, USERNAME_REGEX, image_check}, +}; + +use super::{Guild, guild::GuildBuilder, load_or_empty, member::MemberBuilder}; + +#[derive(Serialize, Queryable, Selectable)] +#[diesel(table_name = users)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct Me { + pub uuid: Uuid, + pub username: String, + pub display_name: Option, + avatar: Option, + pronouns: Option, + about: Option, + pub email: String, + pub email_verified: bool, +} + +impl Me { + pub async fn get(conn: &mut Conn, user_uuid: Uuid) -> Result { + use users::dsl; + let me: Me = dsl::users + .filter(dsl::uuid.eq(user_uuid)) + .select(Me::as_select()) + .get_result(conn) + .await?; + + Ok(me) + } + + pub async fn fetch_memberships(&self, conn: &mut Conn) -> Result, Error> { + use guild_members::dsl; + let memberships: Vec = load_or_empty( + dsl::guild_members + .filter(dsl::user_uuid.eq(self.uuid)) + .select(MemberBuilder::as_select()) + .load(conn) + .await, + )?; + + let mut guilds: Vec = vec![]; + + for membership in memberships { + use guilds::dsl; + guilds.push( + dsl::guilds + .filter(dsl::uuid.eq(membership.guild_uuid)) + .select(GuildBuilder::as_select()) + .get_result(conn) + .await? + .build(conn) + .await?, + ) + } + + Ok(guilds) + } + + pub async fn set_avatar( + &mut self, + data: &Data, + cdn_url: Url, + avatar: BytesMut, + ) -> Result<(), Error> { + let avatar_clone = avatar.clone(); + let image_type = task::spawn_blocking(move || image_check(avatar_clone)).await??; + + let mut conn = data.pool.get().await?; + + if let Some(avatar) = &self.avatar { + let avatar_url: Url = avatar.parse()?; + + let relative_url = avatar_url.path().trim_start_matches('/'); + + data.bunny_cdn.storage.delete(relative_url).await?; + } + + let path = format!("avatar/{}/avatar.{}", self.uuid, image_type); + + data.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(&mut conn) + .await?; + + if data.get_cache_key(self.uuid.to_string()).await.is_ok() { + data.del_cache_key(self.uuid.to_string()).await? + } + + self.avatar = Some(avatar_url.to_string()); + + Ok(()) + } + + pub async fn verify_email(&self, conn: &mut Conn) -> Result<(), Error> { + use users::dsl; + update(users::table) + .filter(dsl::uuid.eq(self.uuid)) + .set(dsl::email_verified.eq(true)) + .execute(conn) + .await?; + + Ok(()) + } + + pub async fn set_username(&mut self, data: &Data, new_username: String) -> Result<(), Error> { + if !USERNAME_REGEX.is_match(&new_username) { + return Err(Error::BadRequest("Invalid username".to_string())); + } + + let mut conn = data.pool.get().await?; + + use users::dsl; + update(users::table) + .filter(dsl::uuid.eq(self.uuid)) + .set(dsl::username.eq(new_username.as_str())) + .execute(&mut conn) + .await?; + + if data.get_cache_key(self.uuid.to_string()).await.is_ok() { + data.del_cache_key(self.uuid.to_string()).await? + } + + self.username = new_username; + + Ok(()) + } + + pub async fn set_display_name( + &mut self, + data: &Data, + new_display_name: String, + ) -> Result<(), Error> { + let mut conn = data.pool.get().await?; + + use users::dsl; + update(users::table) + .filter(dsl::uuid.eq(self.uuid)) + .set(dsl::display_name.eq(new_display_name.as_str())) + .execute(&mut conn) + .await?; + + if data.get_cache_key(self.uuid.to_string()).await.is_ok() { + data.del_cache_key(self.uuid.to_string()).await? + } + + self.display_name = Some(new_display_name); + + Ok(()) + } + + pub async fn set_email(&mut self, data: &Data, new_email: String) -> Result<(), Error> { + if !EMAIL_REGEX.is_match(&new_email) { + return Err(Error::BadRequest("Invalid username".to_string())); + } + + let mut conn = data.pool.get().await?; + + use users::dsl; + update(users::table) + .filter(dsl::uuid.eq(self.uuid)) + .set(( + dsl::email.eq(new_email.as_str()), + dsl::email_verified.eq(false), + )) + .execute(&mut conn) + .await?; + + if data.get_cache_key(self.uuid.to_string()).await.is_ok() { + data.del_cache_key(self.uuid.to_string()).await? + } + + self.email = new_email; + + Ok(()) + } + + pub async fn set_pronouns(&mut self, data: &Data, new_pronouns: String) -> Result<(), Error> { + let mut conn = data.pool.get().await?; + + use users::dsl; + update(users::table) + .filter(dsl::uuid.eq(self.uuid)) + .set((dsl::pronouns.eq(new_pronouns.as_str()),)) + .execute(&mut conn) + .await?; + + if data.get_cache_key(self.uuid.to_string()).await.is_ok() { + data.del_cache_key(self.uuid.to_string()).await? + } + + Ok(()) + } + + pub async fn set_about(&mut self, data: &Data, new_about: String) -> Result<(), Error> { + let mut conn = data.pool.get().await?; + + use users::dsl; + update(users::table) + .filter(dsl::uuid.eq(self.uuid)) + .set((dsl::about.eq(new_about.as_str()),)) + .execute(&mut conn) + .await?; + + if data.get_cache_key(self.uuid.to_string()).await.is_ok() { + data.del_cache_key(self.uuid.to_string()).await? + } + + Ok(()) + } +} diff --git a/src/objects/member.rs b/src/objects/member.rs new file mode 100644 index 0000000..f18e726 --- /dev/null +++ b/src/objects/member.rs @@ -0,0 +1,125 @@ +use diesel::{ + ExpressionMethods, Insertable, QueryDsl, Queryable, Selectable, SelectableHelper, insert_into, +}; +use diesel_async::RunQueryDsl; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::{Conn, Data, error::Error, schema::guild_members}; + +use super::{User, load_or_empty}; + +#[derive(Serialize, Queryable, Selectable, Insertable)] +#[diesel(table_name = guild_members)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct MemberBuilder { + pub uuid: Uuid, + pub nickname: Option, + pub user_uuid: Uuid, + pub guild_uuid: Uuid, +} + +impl MemberBuilder { + async fn build(&self, data: &Data) -> Result { + let user = User::fetch_one(data, self.user_uuid).await?; + + Ok(Member { + uuid: self.uuid, + nickname: self.nickname.clone(), + user_uuid: self.user_uuid, + guild_uuid: self.guild_uuid, + user, + }) + } +} + +#[derive(Serialize, Deserialize)] +pub struct Member { + pub uuid: Uuid, + pub nickname: Option, + pub user_uuid: Uuid, + pub guild_uuid: Uuid, + user: User, +} + +impl Member { + pub async fn count(conn: &mut Conn, guild_uuid: Uuid) -> Result { + use guild_members::dsl; + let count: i64 = dsl::guild_members + .filter(dsl::guild_uuid.eq(guild_uuid)) + .count() + .get_result(conn) + .await?; + + Ok(count) + } + + pub async fn check_membership( + conn: &mut Conn, + user_uuid: Uuid, + guild_uuid: Uuid, + ) -> Result<(), Error> { + use guild_members::dsl; + dsl::guild_members + .filter(dsl::user_uuid.eq(user_uuid)) + .filter(dsl::guild_uuid.eq(guild_uuid)) + .select(MemberBuilder::as_select()) + .get_result(conn) + .await?; + + Ok(()) + } + + pub async fn fetch_one(data: &Data, user_uuid: Uuid, guild_uuid: Uuid) -> Result { + let mut conn = data.pool.get().await?; + + use guild_members::dsl; + let member: MemberBuilder = dsl::guild_members + .filter(dsl::user_uuid.eq(user_uuid)) + .filter(dsl::guild_uuid.eq(guild_uuid)) + .select(MemberBuilder::as_select()) + .get_result(&mut conn) + .await?; + + member.build(data).await + } + + pub async fn fetch_all(data: &Data, guild_uuid: Uuid) -> Result, Error> { + let mut conn = data.pool.get().await?; + + use guild_members::dsl; + let member_builders: Vec = load_or_empty( + dsl::guild_members + .filter(dsl::guild_uuid.eq(guild_uuid)) + .select(MemberBuilder::as_select()) + .load(&mut conn) + .await, + )?; + + let member_futures = member_builders + .iter() + .map(async move |m| m.build(data).await); + + futures::future::try_join_all(member_futures).await + } + + pub async fn new(data: &Data, user_uuid: Uuid, guild_uuid: Uuid) -> Result { + let mut conn = data.pool.get().await?; + + let member_uuid = Uuid::now_v7(); + + let member = MemberBuilder { + uuid: member_uuid, + guild_uuid, + user_uuid, + nickname: None, + }; + + insert_into(guild_members::table) + .values(&member) + .execute(&mut conn) + .await?; + + member.build(data).await + } +} diff --git a/src/objects/message.rs b/src/objects/message.rs new file mode 100644 index 0000000..6c1700a --- /dev/null +++ b/src/objects/message.rs @@ -0,0 +1,40 @@ +use diesel::{Insertable, Queryable, Selectable}; +use serde::Serialize; +use uuid::Uuid; + +use crate::{Data, error::Error, schema::messages}; + +use super::User; + +#[derive(Clone, Queryable, Selectable, Insertable)] +#[diesel(table_name = messages)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct MessageBuilder { + pub uuid: Uuid, + pub channel_uuid: Uuid, + pub user_uuid: Uuid, + pub message: String, +} + +impl MessageBuilder { + pub async fn build(&self, data: &Data) -> Result { + let user = User::fetch_one(data, self.user_uuid).await?; + + Ok(Message { + uuid: self.uuid, + channel_uuid: self.channel_uuid, + user_uuid: self.user_uuid, + message: self.message.clone(), + user, + }) + } +} + +#[derive(Clone, Serialize)] +pub struct Message { + uuid: Uuid, + channel_uuid: Uuid, + user_uuid: Uuid, + message: String, + user: User, +} diff --git a/src/objects/mod.rs b/src/objects/mod.rs new file mode 100644 index 0000000..7b45957 --- /dev/null +++ b/src/objects/mod.rs @@ -0,0 +1,156 @@ +use lettre::{ + AsyncSmtpTransport, AsyncTransport, Message as Email, Tokio1Executor, + message::{Mailbox, MessageBuilder as EmailBuilder}, + transport::smtp::authentication::Credentials, +}; +use log::debug; +use serde::Deserialize; +use uuid::Uuid; + +mod channel; +mod email_token; +mod guild; +mod invite; +mod me; +mod member; +mod message; +mod password_reset_token; +mod role; +mod user; + +pub use channel::Channel; +pub use email_token::EmailToken; +pub use guild::Guild; +pub use invite::Invite; +pub use me::Me; +pub use member::Member; +pub use message::Message; +pub use password_reset_token::PasswordResetToken; +pub use role::Role; +pub use user::User; + +use crate::error::Error; + +pub trait HasUuid { + fn uuid(&self) -> &Uuid; +} + +pub trait HasIsAbove { + fn is_above(&self) -> Option<&Uuid>; +} + +fn load_or_empty( + query_result: Result, diesel::result::Error>, +) -> Result, diesel::result::Error> { + match query_result { + Ok(vec) => Ok(vec), + Err(diesel::result::Error::NotFound) => Ok(Vec::new()), + Err(e) => Err(e), + } +} + +#[derive(PartialEq, Eq, Clone)] +pub enum MailTls { + StartTls, + Tls, +} + +impl From for MailTls { + fn from(value: String) -> Self { + match &*value.to_lowercase() { + "starttls" => Self::StartTls, + _ => Self::Tls, + } + } +} + +#[derive(Clone)] +pub struct MailClient { + creds: Credentials, + smtp_server: String, + mbox: Mailbox, + tls: MailTls, +} + +impl MailClient { + pub fn new>( + creds: Credentials, + smtp_server: String, + mbox: String, + tls: T, + ) -> Result { + Ok(Self { + creds, + smtp_server, + mbox: mbox.parse()?, + tls: tls.into(), + }) + } + + pub fn message_builder(&self) -> EmailBuilder { + Email::builder().from(self.mbox.clone()) + } + + pub async fn send_mail(&self, email: Email) -> Result<(), Error> { + let mailer: AsyncSmtpTransport = match self.tls { + MailTls::StartTls => { + AsyncSmtpTransport::::starttls_relay(&self.smtp_server)? + .credentials(self.creds.clone()) + .build() + } + MailTls::Tls => AsyncSmtpTransport::::relay(&self.smtp_server)? + .credentials(self.creds.clone()) + .build(), + }; + + let response = mailer.send(email).await?; + + debug!("mail sending response: {:?}", response); + + Ok(()) + } +} + +#[derive(Clone, Copy)] +pub enum Permissions { + SendMessage = 1, + CreateChannel = 2, + DeleteChannel = 4, + ManageChannel = 8, + CreateRole = 16, + DeleteRole = 32, + ManageRole = 64, + CreateInvite = 128, + ManageInvite = 256, + ManageServer = 512, + ManageMember = 1024, +} + +impl Permissions { + pub fn fetch_permissions(permissions: i64) -> Vec { + let all_perms = vec![ + Self::SendMessage, + Self::CreateChannel, + Self::DeleteChannel, + Self::ManageChannel, + Self::CreateRole, + Self::DeleteRole, + Self::ManageRole, + Self::CreateInvite, + Self::ManageInvite, + Self::ManageServer, + Self::ManageMember, + ]; + + all_perms + .into_iter() + .filter(|p| permissions & (*p as i64) != 0) + .collect() + } +} + +#[derive(Deserialize)] +pub struct StartAmountQuery { + pub start: Option, + pub amount: Option, +} diff --git a/src/objects/password_reset_token.rs b/src/objects/password_reset_token.rs new file mode 100644 index 0000000..e3c7bca --- /dev/null +++ b/src/objects/password_reset_token.rs @@ -0,0 +1,160 @@ +use argon2::{ + PasswordHasher, + password_hash::{SaltString, rand_core::OsRng}, +}; +use chrono::Utc; +use diesel::{ + ExpressionMethods, QueryDsl, Queryable, Selectable, SelectableHelper, delete, dsl::now, + insert_into, update, +}; +use diesel_async::RunQueryDsl; +use lettre::message::MultiPart; +use uuid::Uuid; + +use crate::{ + Conn, Data, + error::Error, + schema::{password_reset_tokens, users}, + utils::{PASSWORD_REGEX, generate_refresh_token, global_checks, user_uuid_from_identifier}, +}; + +#[derive(Selectable, Queryable)] +#[diesel(table_name = password_reset_tokens)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct PasswordResetToken { + user_uuid: Uuid, + pub token: String, + pub created_at: chrono::DateTime, +} + +impl PasswordResetToken { + pub async fn get(conn: &mut Conn, token: String) -> Result { + use password_reset_tokens::dsl; + let password_reset_token = dsl::password_reset_tokens + .filter(dsl::token.eq(token)) + .select(PasswordResetToken::as_select()) + .get_result(conn) + .await?; + + Ok(password_reset_token) + } + + pub async fn get_with_identifier( + conn: &mut Conn, + identifier: String, + ) -> Result { + let user_uuid = user_uuid_from_identifier(conn, &identifier).await?; + + use password_reset_tokens::dsl; + let password_reset_token = dsl::password_reset_tokens + .filter(dsl::user_uuid.eq(user_uuid)) + .select(PasswordResetToken::as_select()) + .get_result(conn) + .await?; + + Ok(password_reset_token) + } + + #[allow(clippy::new_ret_no_self)] + pub async fn new(data: &Data, identifier: String) -> Result<(), Error> { + let token = generate_refresh_token()?; + + let mut conn = data.pool.get().await?; + + let user_uuid = user_uuid_from_identifier(&mut conn, &identifier).await?; + + global_checks(data, user_uuid).await?; + + use users::dsl as udsl; + let (username, email_address): (String, String) = udsl::users + .filter(udsl::uuid.eq(user_uuid)) + .select((udsl::username, udsl::email)) + .get_result(&mut conn) + .await?; + + use password_reset_tokens::dsl; + insert_into(password_reset_tokens::table) + .values(( + dsl::user_uuid.eq(user_uuid), + dsl::token.eq(&token), + dsl::created_at.eq(now), + )) + .execute(&mut conn) + .await?; + + let mut reset_endpoint = data.config.web.frontend_url.join("reset-password")?; + + reset_endpoint.set_query(Some(&format!("token={}", token))); + + let email = data + .mail_client + .message_builder() + .to(email_address.parse()?) + .subject(format!("{} Password Reset", data.config.instance.name)) + .multipart(MultiPart::alternative_plain_html( + format!("{} Password Reset\n\nHello, {}!\nSomeone requested a password reset for your Gorb account.\nClick the button below within 24 hours to reset your password.\n\n{}\n\nIf you didn't request a password reset, don't worry, your account is safe and you can safely ignore this email.\n\nThanks, The gorb team.", data.config.instance.name, username, reset_endpoint), + format!(r#"

{} Password Reset

Hello, {}!

Someone requested a password reset for your Gorb account.

Click the button below within 24 hours to reset your password.

RESET PASSWORD

If you didn't request a password reset, don't worry, your account is safe and you can safely ignore this email.

"#, data.config.instance.name, username, reset_endpoint) + ))?; + + data.mail_client.send_mail(email).await?; + + Ok(()) + } + + pub async fn set_password(&self, data: &Data, password: String) -> Result<(), Error> { + if !PASSWORD_REGEX.is_match(&password) { + return Err(Error::BadRequest( + "Please provide a valid password".to_string(), + )); + } + + let salt = SaltString::generate(&mut OsRng); + + let hashed_password = data + .argon2 + .hash_password(password.as_bytes(), &salt) + .map_err(|e| Error::PasswordHashError(e.to_string()))?; + + let mut conn = data.pool.get().await?; + + use users::dsl; + update(users::table) + .filter(dsl::uuid.eq(self.user_uuid)) + .set(dsl::password.eq(hashed_password.to_string())) + .execute(&mut conn) + .await?; + + let (username, email_address): (String, String) = dsl::users + .filter(dsl::uuid.eq(self.user_uuid)) + .select((dsl::username, dsl::email)) + .get_result(&mut conn) + .await?; + + let login_page = data.config.web.frontend_url.join("login")?; + + let email = data + .mail_client + .message_builder() + .to(email_address.parse()?) + .subject(format!("Your {} Password has been Reset", data.config.instance.name)) + .multipart(MultiPart::alternative_plain_html( + format!("{} Password Reset Confirmation\n\nHello, {}!\nYour password has been successfully reset for your Gorb account.\nIf you did not initiate this change, please click the link below to reset your password immediately.\n\n{}\n\nThanks, The gorb team.", data.config.instance.name, username, login_page), + format!(r#"

{} Password Reset Confirmation

Hello, {}!

Your password has been successfully reset for your Gorb account.

If you did not initiate this change, please click the button below to reset your password immediately.

RESET PASSWORD
"#, data.config.instance.name, username, login_page) + ))?; + + data.mail_client.send_mail(email).await?; + + self.delete(&mut conn).await + } + + pub async fn delete(&self, conn: &mut Conn) -> Result<(), Error> { + use password_reset_tokens::dsl; + delete(password_reset_tokens::table) + .filter(dsl::user_uuid.eq(self.user_uuid)) + .filter(dsl::token.eq(&self.token)) + .execute(conn) + .await?; + + Ok(()) + } +} diff --git a/src/objects/role.rs b/src/objects/role.rs new file mode 100644 index 0000000..f67dc6d --- /dev/null +++ b/src/objects/role.rs @@ -0,0 +1,96 @@ +use diesel::{ + ExpressionMethods, Insertable, QueryDsl, Queryable, Selectable, SelectableHelper, insert_into, + update, +}; +use diesel_async::RunQueryDsl; +use serde::Serialize; +use uuid::Uuid; + +use crate::{Conn, error::Error, schema::roles, utils::order_by_is_above}; + +use super::{HasIsAbove, HasUuid, load_or_empty}; + +#[derive(Serialize, Clone, Queryable, Selectable, Insertable)] +#[diesel(table_name = roles)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct Role { + uuid: Uuid, + guild_uuid: Uuid, + name: String, + color: i32, + is_above: Option, + permissions: i64, +} + +impl HasUuid for Role { + fn uuid(&self) -> &Uuid { + self.uuid.as_ref() + } +} + +impl HasIsAbove for Role { + fn is_above(&self) -> Option<&Uuid> { + self.is_above.as_ref() + } +} + +impl Role { + pub async fn fetch_all(conn: &mut Conn, guild_uuid: Uuid) -> Result, Error> { + use roles::dsl; + let roles: Vec = load_or_empty( + dsl::roles + .filter(dsl::guild_uuid.eq(guild_uuid)) + .select(Role::as_select()) + .load(conn) + .await, + )?; + + Ok(roles) + } + + pub async fn fetch_one(conn: &mut Conn, role_uuid: Uuid) -> Result { + use roles::dsl; + let role: Role = dsl::roles + .filter(dsl::uuid.eq(role_uuid)) + .select(Role::as_select()) + .get_result(conn) + .await?; + + Ok(role) + } + + pub async fn new(conn: &mut Conn, guild_uuid: Uuid, name: String) -> Result { + let role_uuid = Uuid::now_v7(); + + let roles = Self::fetch_all(conn, guild_uuid).await?; + + let roles_ordered = order_by_is_above(roles).await?; + + let last_role = roles_ordered.last(); + + let new_role = Role { + uuid: role_uuid, + guild_uuid, + name, + color: 16777215, + is_above: None, + permissions: 0, + }; + + insert_into(roles::table) + .values(new_role.clone()) + .execute(conn) + .await?; + + if let Some(old_last_role) = last_role { + use roles::dsl; + update(roles::table) + .filter(dsl::uuid.eq(old_last_role.uuid)) + .set(dsl::is_above.eq(new_role.uuid)) + .execute(conn) + .await?; + } + + Ok(new_role) + } +} diff --git a/src/objects/user.rs b/src/objects/user.rs new file mode 100644 index 0000000..98e5e80 --- /dev/null +++ b/src/objects/user.rs @@ -0,0 +1,60 @@ +use diesel::{ExpressionMethods, QueryDsl, Queryable, Selectable, SelectableHelper}; +use diesel_async::RunQueryDsl; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::{Conn, Data, error::Error, schema::users}; + +use super::load_or_empty; + +#[derive(Deserialize, Serialize, Clone, Queryable, Selectable)] +#[diesel(table_name = users)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct User { + uuid: Uuid, + username: String, + display_name: Option, + avatar: Option, + pronouns: Option, + about: Option, +} + +impl User { + pub async fn fetch_one(data: &Data, user_uuid: Uuid) -> Result { + let mut conn = data.pool.get().await?; + + if let Ok(cache_hit) = data.get_cache_key(user_uuid.to_string()).await { + return Ok(serde_json::from_str(&cache_hit)?); + } + + use users::dsl; + let user: User = dsl::users + .filter(dsl::uuid.eq(user_uuid)) + .select(User::as_select()) + .get_result(&mut conn) + .await?; + + data.set_cache_key(user_uuid.to_string(), user.clone(), 1800) + .await?; + + Ok(user) + } + + pub async fn fetch_amount( + conn: &mut Conn, + offset: i64, + amount: i64, + ) -> Result, Error> { + use users::dsl; + let users: Vec = load_or_empty( + dsl::users + .limit(amount) + .offset(offset) + .select(User::as_select()) + .load(conn) + .await, + )?; + + Ok(users) + } +} diff --git a/src/structs.rs b/src/structs.rs deleted file mode 100644 index b68aaf5..0000000 --- a/src/structs.rs +++ /dev/null @@ -1,1437 +0,0 @@ -use actix_web::web::BytesMut; -use argon2::{ - PasswordHasher, - password_hash::{SaltString, rand_core::OsRng}, -}; -use chrono::Utc; -use diesel::{ - ExpressionMethods, QueryDsl, Selectable, SelectableHelper, delete, - dsl::now, - insert_into, - prelude::{Insertable, Queryable}, - update, -}; -use diesel_async::{RunQueryDsl, pooled_connection::AsyncDieselConnectionManager}; -use lettre::{ - AsyncSmtpTransport, AsyncTransport, Message as Email, Tokio1Executor, - message::{Mailbox, MessageBuilder as EmailBuilder, MultiPart}, - transport::smtp::authentication::Credentials, -}; -use log::debug; -use serde::{Deserialize, Serialize}; -use tokio::task; -use url::Url; -use uuid::Uuid; - -use crate::{ - error::Error, schema::*, utils::{ - generate_refresh_token, global_checks, image_check, order_by_is_above, user_uuid_from_identifier, CHANNEL_REGEX, EMAIL_REGEX, PASSWORD_REGEX, USERNAME_REGEX - }, Conn, Data -}; - -pub trait HasUuid { - fn uuid(&self) -> &Uuid; -} - -pub trait HasIsAbove { - fn is_above(&self) -> Option<&Uuid>; -} - -fn load_or_empty( - query_result: Result, diesel::result::Error>, -) -> Result, diesel::result::Error> { - match query_result { - Ok(vec) => Ok(vec), - Err(diesel::result::Error::NotFound) => Ok(Vec::new()), - Err(e) => Err(e), - } -} - -#[derive(PartialEq, Eq, Clone)] -pub enum MailTls { - StartTls, - Tls, -} - -impl From for MailTls { - fn from(value: String) -> Self { - match &*value.to_lowercase() { - "starttls" => Self::StartTls, - _ => Self::Tls, - } - } -} - -#[derive(Clone)] -pub struct MailClient { - creds: Credentials, - smtp_server: String, - mbox: Mailbox, - tls: MailTls, -} - -impl MailClient { - pub fn new>( - creds: Credentials, - smtp_server: String, - mbox: String, - tls: T, - ) -> Result { - Ok(Self { - creds, - smtp_server, - mbox: mbox.parse()?, - tls: tls.into(), - }) - } - - pub fn message_builder(&self) -> EmailBuilder { - Email::builder().from(self.mbox.clone()) - } - - pub async fn send_mail(&self, email: Email) -> Result<(), Error> { - let mailer: AsyncSmtpTransport = match self.tls { - MailTls::StartTls => { - AsyncSmtpTransport::::starttls_relay(&self.smtp_server)? - .credentials(self.creds.clone()) - .build() - } - MailTls::Tls => AsyncSmtpTransport::::relay(&self.smtp_server)? - .credentials(self.creds.clone()) - .build(), - }; - - let response = mailer.send(email).await?; - - debug!("mail sending response: {:?}", response); - - Ok(()) - } -} - -#[derive(Queryable, Selectable, Insertable, Clone, Debug)] -#[diesel(table_name = channels)] -#[diesel(check_for_backend(diesel::pg::Pg))] -struct ChannelBuilder { - uuid: Uuid, - guild_uuid: Uuid, - name: String, - description: Option, - is_above: Option, -} - -impl ChannelBuilder { - async fn build(self, conn: &mut Conn) -> Result { - use self::channel_permissions::dsl::*; - let channel_permission: Vec = load_or_empty( - channel_permissions - .filter(channel_uuid.eq(self.uuid)) - .select(ChannelPermission::as_select()) - .load(conn) - .await, - )?; - - Ok(Channel { - uuid: self.uuid, - guild_uuid: self.guild_uuid, - name: self.name, - description: self.description, - is_above: self.is_above, - permissions: channel_permission, - }) - } -} - -#[derive(Serialize, Deserialize, Clone, Debug)] -pub struct Channel { - pub uuid: Uuid, - pub guild_uuid: Uuid, - name: String, - description: Option, - pub is_above: Option, - pub permissions: Vec, -} - -#[derive(Serialize, Deserialize, Clone, Queryable, Selectable, Debug)] -#[diesel(table_name = channel_permissions)] -#[diesel(check_for_backend(diesel::pg::Pg))] -pub struct ChannelPermission { - pub role_uuid: Uuid, - pub permissions: i64, -} - -impl HasUuid for Channel { - fn uuid(&self) -> &Uuid { - self.uuid.as_ref() - } -} - -impl HasIsAbove for Channel { - fn is_above(&self) -> Option<&Uuid> { - self.is_above.as_ref() - } -} - -impl Channel { - pub async fn fetch_all( - pool: &deadpool::managed::Pool< - AsyncDieselConnectionManager, - Conn, - >, - guild_uuid: Uuid, - ) -> Result, Error> { - let mut conn = pool.get().await?; - - use channels::dsl; - let channel_builders: Vec = load_or_empty( - dsl::channels - .filter(dsl::guild_uuid.eq(guild_uuid)) - .select(ChannelBuilder::as_select()) - .load(&mut conn) - .await, - )?; - - let channel_futures = channel_builders.iter().map(async move |c| { - let mut conn = pool.get().await?; - c.clone().build(&mut conn).await - }); - - futures::future::try_join_all(channel_futures).await - } - - pub async fn fetch_one(data: &Data, channel_uuid: Uuid) -> Result { - if let Ok(cache_hit) = data.get_cache_key(channel_uuid.to_string()).await { - return Ok(serde_json::from_str(&cache_hit)?); - } - - let mut conn = data.pool.get().await?; - - use channels::dsl; - let channel_builder: ChannelBuilder = dsl::channels - .filter(dsl::uuid.eq(channel_uuid)) - .select(ChannelBuilder::as_select()) - .get_result(&mut conn) - .await?; - - let channel = channel_builder.build(&mut conn).await?; - - data.set_cache_key(channel_uuid.to_string(), channel.clone(), 60) - .await?; - - Ok(channel) - } - - pub async fn new( - data: actix_web::web::Data, - guild_uuid: Uuid, - name: String, - description: Option, - ) -> Result { - if !CHANNEL_REGEX.is_match(&name) { - return Err(Error::BadRequest("Channel name is invalid".to_string())) - } - - let mut conn = data.pool.get().await?; - - let channel_uuid = Uuid::now_v7(); - - let channels = Self::fetch_all(&data.pool, guild_uuid).await?; - - let channels_ordered = order_by_is_above(channels).await?; - - let last_channel = channels_ordered.last(); - - let new_channel = ChannelBuilder { - uuid: channel_uuid, - guild_uuid, - name: name.clone(), - description: description.clone(), - is_above: None, - }; - - insert_into(channels::table) - .values(new_channel.clone()) - .execute(&mut conn) - .await?; - - if let Some(old_last_channel) = last_channel { - use channels::dsl; - update(channels::table) - .filter(dsl::uuid.eq(old_last_channel.uuid)) - .set(dsl::is_above.eq(new_channel.uuid)) - .execute(&mut conn) - .await?; - } - - // returns different object because there's no reason to build the channelbuilder (wastes 1 database request) - let channel = Self { - uuid: channel_uuid, - guild_uuid, - name, - description, - is_above: None, - permissions: vec![], - }; - - data.set_cache_key(channel_uuid.to_string(), channel.clone(), 1800) - .await?; - - if data - .get_cache_key(format!("{}_channels", guild_uuid)) - .await - .is_ok() - { - data.del_cache_key(format!("{}_channels", guild_uuid)) - .await?; - } - - Ok(channel) - } - - pub async fn delete(self, data: &Data) -> Result<(), Error> { - let mut conn = data.pool.get().await?; - - use channels::dsl; - delete(channels::table) - .filter(dsl::uuid.eq(self.uuid)) - .execute(&mut conn) - .await?; - - if data.get_cache_key(self.uuid.to_string()).await.is_ok() { - data.del_cache_key(self.uuid.to_string()).await?; - } - - Ok(()) - } - - pub async fn fetch_messages( - &self, - data: &Data, - amount: i64, - offset: i64, - ) -> Result, Error> { - let mut conn = data.pool.get().await?; - - use messages::dsl; - let messages: Vec = load_or_empty( - dsl::messages - .filter(dsl::channel_uuid.eq(self.uuid)) - .select(MessageBuilder::as_select()) - .order(dsl::uuid.desc()) - .limit(amount) - .offset(offset) - .load(&mut conn) - .await, - )?; - - let message_futures = messages.iter().map(async move |b| b.build(data).await); - - futures::future::try_join_all(message_futures).await - } - - pub async fn new_message( - &self, - data: &Data, - user_uuid: Uuid, - message: String, - ) -> Result { - let message_uuid = Uuid::now_v7(); - - let message = MessageBuilder { - uuid: message_uuid, - channel_uuid: self.uuid, - user_uuid, - message, - }; - - let mut conn = data.pool.get().await?; - - insert_into(messages::table) - .values(message.clone()) - .execute(&mut conn) - .await?; - - message.build(data).await - } - - pub async fn set_name(&mut self, data: &Data, new_name: String) -> Result<(), Error> { - if !CHANNEL_REGEX.is_match(&new_name) { - return Err(Error::BadRequest("Channel name is invalid".to_string())) - } - - let mut conn = data.pool.get().await?; - - use channels::dsl; - update(channels::table) - .filter(dsl::uuid.eq(self.uuid)) - .set(dsl::name.eq(&new_name)) - .execute(&mut conn) - .await?; - - self.name = new_name; - - Ok(()) - } - - pub async fn set_description(&mut self, data: &Data, new_description: String) -> Result<(), Error> { - let mut conn = data.pool.get().await?; - - use channels::dsl; - update(channels::table) - .filter(dsl::uuid.eq(self.uuid)) - .set(dsl::description.eq(&new_description)) - .execute(&mut conn) - .await?; - - self.description = Some(new_description); - - Ok(()) - } - - pub async fn move_channel(&mut self, data: &Data, new_is_above: Uuid) -> Result<(), Error> { - let mut conn = data.pool.get().await?; - - use channels::dsl; - let old_above_uuid: Option = match dsl::channels - .filter(dsl::is_above.eq(self.uuid)) - .select(dsl::uuid) - .get_result(&mut conn) - .await - { - Ok(r) => Ok(Some(r)), - Err(e) if e == diesel::result::Error::NotFound => Ok(None), - Err(e) => Err(e), - }?; - - if let Some(uuid) = old_above_uuid { - update(channels::table) - .filter(dsl::uuid.eq(uuid)) - .set(dsl::is_above.eq(None::)) - .execute(&mut conn) - .await?; - } - - match update(channels::table) - .filter(dsl::is_above.eq(new_is_above)) - .set(dsl::is_above.eq(self.uuid)) - .execute(&mut conn) - .await - { - Ok(r) => Ok(r), - Err(e) if e == diesel::result::Error::NotFound => Ok(0), - Err(e) => Err(e), - }?; - - update(channels::table) - .filter(dsl::uuid.eq(self.uuid)) - .set(dsl::is_above.eq(new_is_above)) - .execute(&mut conn) - .await?; - - if let Some(uuid) = old_above_uuid { - update(channels::table) - .filter(dsl::uuid.eq(uuid)) - .set(dsl::is_above.eq(self.is_above)) - .execute(&mut conn) - .await?; - } - - self.is_above = Some(new_is_above); - - Ok(()) - } -} - -#[derive(Clone, Copy)] -pub enum Permissions { - SendMessage = 1, - CreateChannel = 2, - DeleteChannel = 4, - ManageChannel = 8, - CreateRole = 16, - DeleteRole = 32, - ManageRole = 64, - CreateInvite = 128, - ManageInvite = 256, - ManageServer = 512, - ManageMember = 1024, -} - -impl Permissions { - pub fn fetch_permissions(permissions: i64) -> Vec { - let all_perms = vec![ - Self::SendMessage, - Self::CreateChannel, - Self::DeleteChannel, - Self::ManageChannel, - Self::CreateRole, - Self::DeleteRole, - Self::ManageRole, - Self::CreateInvite, - Self::ManageInvite, - Self::ManageServer, - Self::ManageMember, - ]; - - all_perms - .into_iter() - .filter(|p| permissions & (*p as i64) != 0) - .collect() - } -} - -#[derive(Serialize, Queryable, Selectable, Insertable, Clone)] -#[diesel(table_name = guilds)] -#[diesel(check_for_backend(diesel::pg::Pg))] -struct GuildBuilder { - uuid: Uuid, - name: String, - description: Option, - icon: Option, - owner_uuid: Uuid, -} - -impl GuildBuilder { - async fn build(self, conn: &mut Conn) -> Result { - let member_count = Member::count(conn, self.uuid).await?; - - let roles = Role::fetch_all(conn, self.uuid).await?; - - Ok(Guild { - uuid: self.uuid, - name: self.name, - description: self.description, - icon: self.icon.and_then(|i| i.parse().ok()), - owner_uuid: self.owner_uuid, - roles, - member_count, - }) - } -} - -#[derive(Serialize)] -pub struct Guild { - pub uuid: Uuid, - name: String, - description: Option, - icon: Option, - owner_uuid: Uuid, - pub roles: Vec, - member_count: i64, -} - -impl Guild { - pub async fn fetch_one(conn: &mut Conn, guild_uuid: Uuid) -> Result { - use guilds::dsl; - let guild_builder: GuildBuilder = dsl::guilds - .filter(dsl::uuid.eq(guild_uuid)) - .select(GuildBuilder::as_select()) - .get_result(conn) - .await?; - - guild_builder.build(conn).await - } - - pub async fn fetch_amount( - pool: &deadpool::managed::Pool< - AsyncDieselConnectionManager, - Conn, - >, - offset: i64, - amount: i64, - ) -> Result, Error> { - // Fetch guild data from database - let mut conn = pool.get().await?; - - use guilds::dsl; - let guild_builders: Vec = load_or_empty( - dsl::guilds - .select(GuildBuilder::as_select()) - .order_by(dsl::uuid) - .offset(offset) - .limit(amount) - .load(&mut conn) - .await, - )?; - - // Process each guild concurrently - let guild_futures = guild_builders.iter().map(async move |g| { - let mut conn = pool.get().await?; - g.clone().build(&mut conn).await - }); - - // Execute all futures concurrently and collect results - futures::future::try_join_all(guild_futures).await - } - - pub async fn new(conn: &mut Conn, name: String, owner_uuid: Uuid) -> Result { - let guild_uuid = Uuid::now_v7(); - - let guild_builder = GuildBuilder { - uuid: guild_uuid, - name: name.clone(), - description: None, - icon: None, - owner_uuid, - }; - - insert_into(guilds::table) - .values(guild_builder) - .execute(conn) - .await?; - - let member_uuid = Uuid::now_v7(); - - let member = MemberBuilder { - uuid: member_uuid, - nickname: None, - user_uuid: owner_uuid, - guild_uuid, - }; - - insert_into(guild_members::table) - .values(member) - .execute(conn) - .await?; - - Ok(Guild { - uuid: guild_uuid, - name, - description: None, - icon: None, - owner_uuid, - roles: vec![], - member_count: 1, - }) - } - - pub async fn get_invites(&self, conn: &mut Conn) -> Result, Error> { - use invites::dsl; - let invites = load_or_empty( - dsl::invites - .filter(dsl::guild_uuid.eq(self.uuid)) - .select(Invite::as_select()) - .load(conn) - .await, - )?; - - Ok(invites) - } - - pub async fn create_invite( - &self, - conn: &mut Conn, - user_uuid: Uuid, - custom_id: Option, - ) -> Result { - let invite_id; - - if let Some(id) = custom_id { - invite_id = id; - if invite_id.len() > 32 { - return Err(Error::BadRequest("MAX LENGTH".to_string())); - } - } else { - let charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; - - invite_id = random_string::generate(8, charset); - } - - let invite = Invite { - id: invite_id, - user_uuid, - guild_uuid: self.uuid, - }; - - insert_into(invites::table) - .values(invite.clone()) - .execute(conn) - .await?; - - Ok(invite) - } - - // 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 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.path().trim_start_matches('/'); - - bunny_cdn.storage.delete(relative_url).await?; - } - - let path = format!("icons/{}/icon.{}", self.uuid, image_type); - - bunny_cdn.storage.upload(path.clone(), icon.into()).await?; - - let icon_url = cdn_url.join(&path)?; - - use guilds::dsl; - update(guilds::table) - .filter(dsl::uuid.eq(self.uuid)) - .set(dsl::icon.eq(icon_url.as_str())) - .execute(conn) - .await?; - - self.icon = Some(icon_url); - - Ok(()) - } -} - -#[derive(Serialize, Clone, Queryable, Selectable, Insertable)] -#[diesel(table_name = roles)] -#[diesel(check_for_backend(diesel::pg::Pg))] -pub struct Role { - uuid: Uuid, - guild_uuid: Uuid, - name: String, - color: i32, - is_above: Option, - permissions: i64, -} - -impl HasUuid for Role { - fn uuid(&self) -> &Uuid { - self.uuid.as_ref() - } -} - -impl HasIsAbove for Role { - fn is_above(&self) -> Option<&Uuid> { - self.is_above.as_ref() - } -} - -impl Role { - pub async fn fetch_all(conn: &mut Conn, guild_uuid: Uuid) -> Result, Error> { - use roles::dsl; - let roles: Vec = load_or_empty( - dsl::roles - .filter(dsl::guild_uuid.eq(guild_uuid)) - .select(Role::as_select()) - .load(conn) - .await, - )?; - - Ok(roles) - } - - pub async fn fetch_one(conn: &mut Conn, role_uuid: Uuid) -> Result { - use roles::dsl; - let role: Role = dsl::roles - .filter(dsl::uuid.eq(role_uuid)) - .select(Role::as_select()) - .get_result(conn) - .await?; - - Ok(role) - } - - pub async fn new(conn: &mut Conn, guild_uuid: Uuid, name: String) -> Result { - let role_uuid = Uuid::now_v7(); - - let roles = Self::fetch_all(conn, guild_uuid).await?; - - let roles_ordered = order_by_is_above(roles).await?; - - let last_role = roles_ordered.last(); - - let new_role = Role { - uuid: role_uuid, - guild_uuid, - name, - color: 16777215, - is_above: None, - permissions: 0, - }; - - insert_into(roles::table) - .values(new_role.clone()) - .execute(conn) - .await?; - - if let Some(old_last_role) = last_role { - use roles::dsl; - update(roles::table) - .filter(dsl::uuid.eq(old_last_role.uuid)) - .set(dsl::is_above.eq(new_role.uuid)) - .execute(conn) - .await?; - } - - Ok(new_role) - } -} - -#[derive(Serialize, Queryable, Selectable, Insertable)] -#[diesel(table_name = guild_members)] -#[diesel(check_for_backend(diesel::pg::Pg))] -pub struct MemberBuilder { - pub uuid: Uuid, - pub nickname: Option, - pub user_uuid: Uuid, - pub guild_uuid: Uuid, -} - -impl MemberBuilder { - async fn build(&self, data: &Data) -> Result { - let user = User::fetch_one(data, self.user_uuid).await?; - - Ok(Member { - uuid: self.uuid, - nickname: self.nickname.clone(), - user_uuid: self.user_uuid, - guild_uuid: self.guild_uuid, - user, - }) - } -} - -#[derive(Serialize, Deserialize)] -pub struct Member { - pub uuid: Uuid, - pub nickname: Option, - pub user_uuid: Uuid, - pub guild_uuid: Uuid, - user: User, -} - -impl Member { - async fn count(conn: &mut Conn, guild_uuid: Uuid) -> Result { - use guild_members::dsl; - let count: i64 = dsl::guild_members - .filter(dsl::guild_uuid.eq(guild_uuid)) - .count() - .get_result(conn) - .await?; - - Ok(count) - } - - pub async fn check_membership( - conn: &mut Conn, - user_uuid: Uuid, - guild_uuid: Uuid, - ) -> Result<(), Error> { - use guild_members::dsl; - dsl::guild_members - .filter(dsl::user_uuid.eq(user_uuid)) - .filter(dsl::guild_uuid.eq(guild_uuid)) - .select(MemberBuilder::as_select()) - .get_result(conn) - .await?; - - Ok(()) - } - - pub async fn fetch_one(data: &Data, user_uuid: Uuid, guild_uuid: Uuid) -> Result { - let mut conn = data.pool.get().await?; - - use guild_members::dsl; - let member: MemberBuilder = dsl::guild_members - .filter(dsl::user_uuid.eq(user_uuid)) - .filter(dsl::guild_uuid.eq(guild_uuid)) - .select(MemberBuilder::as_select()) - .get_result(&mut conn) - .await?; - - member.build(data).await - } - - pub async fn fetch_all(data: &Data, guild_uuid: Uuid) -> Result, Error> { - let mut conn = data.pool.get().await?; - - use guild_members::dsl; - let member_builders: Vec = load_or_empty( - dsl::guild_members - .filter(dsl::guild_uuid.eq(guild_uuid)) - .select(MemberBuilder::as_select()) - .load(&mut conn) - .await, - )?; - - let member_futures = member_builders - .iter() - .map(async move |m| m.build(data).await); - - futures::future::try_join_all(member_futures).await - } - - pub async fn new(data: &Data, user_uuid: Uuid, guild_uuid: Uuid) -> Result { - let mut conn = data.pool.get().await?; - - let member_uuid = Uuid::now_v7(); - - let member = MemberBuilder { - uuid: member_uuid, - guild_uuid, - user_uuid, - nickname: None, - }; - - insert_into(guild_members::table) - .values(&member) - .execute(&mut conn) - .await?; - - member.build(data).await - } -} - -#[derive(Clone, Queryable, Selectable, Insertable)] -#[diesel(table_name = messages)] -#[diesel(check_for_backend(diesel::pg::Pg))] -pub struct MessageBuilder { - uuid: Uuid, - channel_uuid: Uuid, - user_uuid: Uuid, - message: String, -} - -impl MessageBuilder { - pub async fn build(&self, data: &Data) -> Result { - let user = User::fetch_one(data, self.user_uuid).await?; - - Ok(Message { - uuid: self.uuid, - channel_uuid: self.channel_uuid, - user_uuid: self.user_uuid, - message: self.message.clone(), - user, - }) - } -} - -#[derive(Clone, Serialize)] -pub struct Message { - uuid: Uuid, - channel_uuid: Uuid, - user_uuid: Uuid, - message: String, - user: User, -} - -/// Server invite struct -#[derive(Clone, Serialize, Queryable, Selectable, Insertable)] -pub struct Invite { - /// case-sensitive alphanumeric string with a fixed length of 8 characters, can be up to 32 characters for custom invites - id: String, - /// User that created the invite - user_uuid: Uuid, - /// UUID of the guild that the invite belongs to - pub guild_uuid: Uuid, -} - -impl Invite { - pub async fn fetch_one(conn: &mut Conn, invite_id: String) -> Result { - use invites::dsl; - let invite: Invite = dsl::invites - .filter(dsl::id.eq(invite_id)) - .select(Invite::as_select()) - .get_result(conn) - .await?; - - Ok(invite) - } -} - -#[derive(Deserialize, Serialize, Clone, Queryable, Selectable)] -#[diesel(table_name = users)] -#[diesel(check_for_backend(diesel::pg::Pg))] -pub struct User { - uuid: Uuid, - username: String, - display_name: Option, - avatar: Option, - pronouns: Option, - about: Option, -} - -impl User { - pub async fn fetch_one(data: &Data, user_uuid: Uuid) -> Result { - let mut conn = data.pool.get().await?; - - if let Ok(cache_hit) = data.get_cache_key(user_uuid.to_string()).await { - return Ok(serde_json::from_str(&cache_hit)?); - } - - use users::dsl; - let user: User = dsl::users - .filter(dsl::uuid.eq(user_uuid)) - .select(User::as_select()) - .get_result(&mut conn) - .await?; - - data.set_cache_key(user_uuid.to_string(), user.clone(), 1800) - .await?; - - Ok(user) - } - - pub async fn fetch_amount( - conn: &mut Conn, - offset: i64, - amount: i64, - ) -> Result, Error> { - use users::dsl; - let users: Vec = load_or_empty( - dsl::users - .limit(amount) - .offset(offset) - .select(User::as_select()) - .load(conn) - .await, - )?; - - Ok(users) - } -} - -#[derive(Serialize, Queryable, Selectable)] -#[diesel(table_name = users)] -#[diesel(check_for_backend(diesel::pg::Pg))] -pub struct Me { - pub uuid: Uuid, - username: String, - display_name: Option, - avatar: Option, - pronouns: Option, - about: Option, - email: String, - pub email_verified: bool, -} - -impl Me { - pub async fn get(conn: &mut Conn, user_uuid: Uuid) -> Result { - use users::dsl; - let me: Me = dsl::users - .filter(dsl::uuid.eq(user_uuid)) - .select(Me::as_select()) - .get_result(conn) - .await?; - - Ok(me) - } - - pub async fn fetch_memberships(&self, conn: &mut Conn) -> Result, Error> { - use guild_members::dsl; - let memberships: Vec = load_or_empty( - dsl::guild_members - .filter(dsl::user_uuid.eq(self.uuid)) - .select(MemberBuilder::as_select()) - .load(conn) - .await - )?; - - let mut guilds: Vec = vec![]; - - for membership in memberships { - use guilds::dsl; - guilds.push( - dsl::guilds - .filter(dsl::uuid.eq(membership.guild_uuid)) - .select(GuildBuilder::as_select()) - .get_result(conn) - .await? - .build(conn) - .await?, - ) - } - - Ok(guilds) - } - - pub async fn set_avatar( - &mut self, - data: &Data, - cdn_url: Url, - avatar: BytesMut, - ) -> Result<(), Error> { - let avatar_clone = avatar.clone(); - let image_type = task::spawn_blocking(move || image_check(avatar_clone)).await??; - - let mut conn = data.pool.get().await?; - - if let Some(avatar) = &self.avatar { - let avatar_url: Url = avatar.parse()?; - - let relative_url = avatar_url.path().trim_start_matches('/'); - - data.bunny_cdn.storage.delete(relative_url).await?; - } - - let path = format!("avatar/{}/avatar.{}", self.uuid, image_type); - - data. - 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(&mut conn) - .await?; - - if data.get_cache_key(self.uuid.to_string()).await.is_ok() { - data.del_cache_key(self.uuid.to_string()).await? - } - - self.avatar = Some(avatar_url.to_string()); - - Ok(()) - } - - pub async fn verify_email(&self, conn: &mut Conn) -> Result<(), Error> { - use users::dsl; - update(users::table) - .filter(dsl::uuid.eq(self.uuid)) - .set(dsl::email_verified.eq(true)) - .execute(conn) - .await?; - - Ok(()) - } - - pub async fn set_username( - &mut self, - data: &Data, - new_username: String, - ) -> Result<(), Error> { - if !USERNAME_REGEX.is_match(&new_username) { - return Err(Error::BadRequest("Invalid username".to_string())); - } - - let mut conn = data.pool.get().await?; - - use users::dsl; - update(users::table) - .filter(dsl::uuid.eq(self.uuid)) - .set(dsl::username.eq(new_username.as_str())) - .execute(&mut conn) - .await?; - - if data.get_cache_key(self.uuid.to_string()).await.is_ok() { - data.del_cache_key(self.uuid.to_string()).await? - } - - self.username = new_username; - - Ok(()) - } - - pub async fn set_display_name( - &mut self, - data: &Data, - new_display_name: String, - ) -> Result<(), Error> { - let mut conn = data.pool.get().await?; - - use users::dsl; - update(users::table) - .filter(dsl::uuid.eq(self.uuid)) - .set(dsl::display_name.eq(new_display_name.as_str())) - .execute(&mut conn) - .await?; - - if data.get_cache_key(self.uuid.to_string()).await.is_ok() { - data.del_cache_key(self.uuid.to_string()).await? - } - - self.display_name = Some(new_display_name); - - Ok(()) - } - - pub async fn set_email(&mut self, data: &Data, new_email: String) -> Result<(), Error> { - if !EMAIL_REGEX.is_match(&new_email) { - return Err(Error::BadRequest("Invalid username".to_string())); - } - - let mut conn = data.pool.get().await?; - - use users::dsl; - update(users::table) - .filter(dsl::uuid.eq(self.uuid)) - .set(( - dsl::email.eq(new_email.as_str()), - dsl::email_verified.eq(false), - )) - .execute(&mut conn) - .await?; - - if data.get_cache_key(self.uuid.to_string()).await.is_ok() { - data.del_cache_key(self.uuid.to_string()).await? - } - - self.email = new_email; - - Ok(()) - } - - pub async fn set_pronouns(&mut self, data: &Data, new_pronouns: String) -> Result<(), Error> { - let mut conn = data.pool.get().await?; - - use users::dsl; - update(users::table) - .filter(dsl::uuid.eq(self.uuid)) - .set(( - dsl::pronouns.eq(new_pronouns.as_str()), - )) - .execute(&mut conn) - .await?; - - if data.get_cache_key(self.uuid.to_string()).await.is_ok() { - data.del_cache_key(self.uuid.to_string()).await? - } - - Ok(()) - } - - pub async fn set_about(&mut self, data: &Data, new_about: String) -> Result<(), Error> { - let mut conn = data.pool.get().await?; - - use users::dsl; - update(users::table) - .filter(dsl::uuid.eq(self.uuid)) - .set(( - dsl::about.eq(new_about.as_str()), - )) - .execute(&mut conn) - .await?; - - if data.get_cache_key(self.uuid.to_string()).await.is_ok() { - data.del_cache_key(self.uuid.to_string()).await? - } - - Ok(()) - } -} - -#[derive(Deserialize)] -pub struct StartAmountQuery { - pub start: Option, - pub amount: Option, -} - -#[derive(Selectable, Queryable)] -#[diesel(table_name = email_tokens)] -#[diesel(check_for_backend(diesel::pg::Pg))] -pub struct EmailToken { - user_uuid: Uuid, - pub token: String, - pub created_at: chrono::DateTime, -} - -impl EmailToken { - pub async fn get(conn: &mut Conn, user_uuid: Uuid) -> Result { - use email_tokens::dsl; - let email_token = dsl::email_tokens - .filter(dsl::user_uuid.eq(user_uuid)) - .select(EmailToken::as_select()) - .get_result(conn) - .await?; - - Ok(email_token) - } - - #[allow(clippy::new_ret_no_self)] - pub async fn new(data: &Data, me: Me) -> Result<(), Error> { - let token = generate_refresh_token()?; - - let mut conn = data.pool.get().await?; - - use email_tokens::dsl; - insert_into(email_tokens::table) - .values(( - dsl::user_uuid.eq(me.uuid), - dsl::token.eq(&token), - dsl::created_at.eq(now), - )) - .execute(&mut conn) - .await?; - - let mut verify_endpoint = data.config.web.frontend_url.join("verify-email")?; - - verify_endpoint.set_query(Some(&format!("token={}", token))); - - let email = data - .mail_client - .message_builder() - .to(me.email.parse()?) - .subject(format!("{} E-mail Verification", data.config.instance.name)) - .multipart(MultiPart::alternative_plain_html( - format!("Verify your {} account\n\nHello, {}!\nThanks for creating a new account on Gorb.\nThe final step to create your account is to verify your email address by visiting the page, within 24 hours.\n\n{}\n\nIf you didn't ask to verify this address, you can safely ignore this email\n\nThanks, The gorb team.", data.config.instance.name, me.username, verify_endpoint), - format!(r#"

Verify your {} Account

Hello, {}!

Thanks for creating a new account on Gorb.

The final step to create your account is to verify your email address by clicking the button below, within 24 hours.

VERIFY ACCOUNT

If you didn't ask to verify this address, you can safely ignore this email.

"#, data.config.instance.name, me.username, verify_endpoint) - ))?; - - data.mail_client.send_mail(email).await?; - - Ok(()) - } - - pub async fn delete(&self, conn: &mut Conn) -> Result<(), Error> { - use email_tokens::dsl; - delete(email_tokens::table) - .filter(dsl::user_uuid.eq(self.user_uuid)) - .filter(dsl::token.eq(&self.token)) - .execute(conn) - .await?; - - Ok(()) - } -} - -#[derive(Selectable, Queryable)] -#[diesel(table_name = password_reset_tokens)] -#[diesel(check_for_backend(diesel::pg::Pg))] -pub struct PasswordResetToken { - user_uuid: Uuid, - pub token: String, - pub created_at: chrono::DateTime, -} - -impl PasswordResetToken { - pub async fn get(conn: &mut Conn, token: String) -> Result { - use password_reset_tokens::dsl; - let password_reset_token = dsl::password_reset_tokens - .filter(dsl::token.eq(token)) - .select(PasswordResetToken::as_select()) - .get_result(conn) - .await?; - - Ok(password_reset_token) - } - - pub async fn get_with_identifier( - conn: &mut Conn, - identifier: String, - ) -> Result { - let user_uuid = user_uuid_from_identifier(conn, &identifier).await?; - - use password_reset_tokens::dsl; - let password_reset_token = dsl::password_reset_tokens - .filter(dsl::user_uuid.eq(user_uuid)) - .select(PasswordResetToken::as_select()) - .get_result(conn) - .await?; - - Ok(password_reset_token) - } - - #[allow(clippy::new_ret_no_self)] - pub async fn new(data: &Data, identifier: String) -> Result<(), Error> { - let token = generate_refresh_token()?; - - let mut conn = data.pool.get().await?; - - let user_uuid = user_uuid_from_identifier(&mut conn, &identifier).await?; - - global_checks(data, user_uuid).await?; - - use users::dsl as udsl; - let (username, email_address): (String, String) = udsl::users - .filter(udsl::uuid.eq(user_uuid)) - .select((udsl::username, udsl::email)) - .get_result(&mut conn) - .await?; - - use password_reset_tokens::dsl; - insert_into(password_reset_tokens::table) - .values(( - dsl::user_uuid.eq(user_uuid), - dsl::token.eq(&token), - dsl::created_at.eq(now), - )) - .execute(&mut conn) - .await?; - - let mut reset_endpoint = data.config.web.frontend_url.join("reset-password")?; - - reset_endpoint.set_query(Some(&format!("token={}", token))); - - let email = data - .mail_client - .message_builder() - .to(email_address.parse()?) - .subject(format!("{} Password Reset", data.config.instance.name)) - .multipart(MultiPart::alternative_plain_html( - format!("{} Password Reset\n\nHello, {}!\nSomeone requested a password reset for your Gorb account.\nClick the button below within 24 hours to reset your password.\n\n{}\n\nIf you didn't request a password reset, don't worry, your account is safe and you can safely ignore this email.\n\nThanks, The gorb team.", data.config.instance.name, username, reset_endpoint), - format!(r#"

{} Password Reset

Hello, {}!

Someone requested a password reset for your Gorb account.

Click the button below within 24 hours to reset your password.

RESET PASSWORD

If you didn't request a password reset, don't worry, your account is safe and you can safely ignore this email.

"#, data.config.instance.name, username, reset_endpoint) - ))?; - - data.mail_client.send_mail(email).await?; - - Ok(()) - } - - pub async fn set_password(&self, data: &Data, password: String) -> Result<(), Error> { - if !PASSWORD_REGEX.is_match(&password) { - return Err(Error::BadRequest( - "Please provide a valid password".to_string(), - )); - } - - let salt = SaltString::generate(&mut OsRng); - - let hashed_password = data - .argon2 - .hash_password(password.as_bytes(), &salt) - .map_err(|e| Error::PasswordHashError(e.to_string()))?; - - let mut conn = data.pool.get().await?; - - use users::dsl; - update(users::table) - .filter(dsl::uuid.eq(self.user_uuid)) - .set(dsl::password.eq(hashed_password.to_string())) - .execute(&mut conn) - .await?; - - let (username, email_address): (String, String) = dsl::users - .filter(dsl::uuid.eq(self.user_uuid)) - .select((dsl::username, dsl::email)) - .get_result(&mut conn) - .await?; - - let login_page = data.config.web.frontend_url.join("login")?; - - let email = data - .mail_client - .message_builder() - .to(email_address.parse()?) - .subject(format!("Your {} Password has been Reset", data.config.instance.name)) - .multipart(MultiPart::alternative_plain_html( - format!("{} Password Reset Confirmation\n\nHello, {}!\nYour password has been successfully reset for your Gorb account.\nIf you did not initiate this change, please click the link below to reset your password immediately.\n\n{}\n\nThanks, The gorb team.", data.config.instance.name, username, login_page), - format!(r#"

{} Password Reset Confirmation

Hello, {}!

Your password has been successfully reset for your Gorb account.

If you did not initiate this change, please click the button below to reset your password immediately.

RESET PASSWORD
"#, data.config.instance.name, username, login_page) - ))?; - - data.mail_client.send_mail(email).await?; - - self.delete(&mut conn).await - } - - pub async fn delete(&self, conn: &mut Conn) -> Result<(), Error> { - use password_reset_tokens::dsl; - delete(password_reset_tokens::table) - .filter(dsl::user_uuid.eq(self.user_uuid)) - .filter(dsl::token.eq(&self.token)) - .execute(conn) - .await?; - - Ok(()) - } -} diff --git a/src/utils.rs b/src/utils.rs index 3bf7332..3172cec 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -19,8 +19,8 @@ use crate::{ Conn, Data, config::Config, error::Error, + objects::{HasIsAbove, HasUuid}, schema::users, - structs::{HasIsAbove, HasUuid}, }; pub static EMAIL_REGEX: LazyLock = LazyLock::new(|| { From c01570707de0277397b7e99cf2ff193a68742d54 Mon Sep 17 00:00:00 2001 From: Radical Date: Mon, 2 Jun 2025 00:30:10 +0200 Subject: [PATCH 83/92] style: cargo clippy --- src/main.rs | 2 +- src/objects/channel.rs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main.rs b/src/main.rs index 9a24d3d..540a237 100644 --- a/src/main.rs +++ b/src/main.rs @@ -155,7 +155,7 @@ async fn main() -> Result<(), Error> { App::new() .app_data(web::Data::new(data.clone())) .wrap(cors) - .service(api::web(&data.config.web.backend_url.path())) + .service(api::web(data.config.web.backend_url.path())) }) .bind((web.ip, web.port))? .run() diff --git a/src/objects/channel.rs b/src/objects/channel.rs index c9b5f1f..9b756f2 100644 --- a/src/objects/channel.rs +++ b/src/objects/channel.rs @@ -309,7 +309,7 @@ impl Channel { .await { Ok(r) => Ok(Some(r)), - Err(e) if e == diesel::result::Error::NotFound => Ok(None), + Err(diesel::result::Error::NotFound) => Ok(None), Err(e) => Err(e), }?; @@ -328,7 +328,7 @@ impl Channel { .await { Ok(r) => Ok(r), - Err(e) if e == diesel::result::Error::NotFound => Ok(0), + Err(diesel::result::Error::NotFound) => Ok(0), Err(e) => Err(e), }?; From 4cbe551061d5a0feb82eb4a993629cbd707adcc0 Mon Sep 17 00:00:00 2001 From: Radical Date: Mon, 2 Jun 2025 17:50:11 +0200 Subject: [PATCH 84/92] fix: make custom id optional --- src/api/v1/guilds/uuid/invites/mod.rs | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/api/v1/guilds/uuid/invites/mod.rs b/src/api/v1/guilds/uuid/invites/mod.rs index f4f06bc..bb8269c 100644 --- a/src/api/v1/guilds/uuid/invites/mod.rs +++ b/src/api/v1/guilds/uuid/invites/mod.rs @@ -12,7 +12,7 @@ use crate::{ #[derive(Deserialize)] struct InviteRequest { - custom_id: String, + custom_id: Option, } #[get("{uuid}/invites")] @@ -46,7 +46,7 @@ pub async fn get( pub async fn create( req: HttpRequest, path: web::Path<(Uuid,)>, - invite_request: web::Json>, + invite_request: web::Json, data: web::Data, ) -> Result { let headers = req.headers(); @@ -65,9 +65,7 @@ pub async fn create( let guild = Guild::fetch_one(&mut conn, guild_uuid).await?; - let custom_id = invite_request.as_ref().map(|ir| ir.custom_id.clone()); - - let invite = guild.create_invite(&mut conn, uuid, custom_id).await?; + let invite = guild.create_invite(&mut conn, uuid, invite_request.custom_id.clone()).await?; Ok(HttpResponse::Ok().json(invite)) } From b223dff4ba6734c692236cdd3d029590859704c7 Mon Sep 17 00:00:00 2001 From: Radical Date: Tue, 3 Jun 2025 11:01:33 +0000 Subject: [PATCH 85/92] feat: move email tokens to valkey No need to have them in permanent DB storage when they are temporary --- .../down.sql | 7 +++ .../up.sql | 2 + src/api/v1/auth/verify_email.rs | 13 ++--- src/objects/email_token.rs | 47 ++++++------------- src/schema.rs | 11 ----- 5 files changed, 27 insertions(+), 53 deletions(-) create mode 100644 migrations/2025-06-03-103311_remove_email_tokens/down.sql create mode 100644 migrations/2025-06-03-103311_remove_email_tokens/up.sql diff --git a/migrations/2025-06-03-103311_remove_email_tokens/down.sql b/migrations/2025-06-03-103311_remove_email_tokens/down.sql new file mode 100644 index 0000000..e8f0350 --- /dev/null +++ b/migrations/2025-06-03-103311_remove_email_tokens/down.sql @@ -0,0 +1,7 @@ +-- This file should undo anything in `up.sql` +CREATE TABLE email_tokens ( + token VARCHAR(64) NOT NULL, + user_uuid uuid UNIQUE NOT NULL REFERENCES users(uuid), + created_at TIMESTAMPTZ NOT NULL, + PRIMARY KEY (token, user_uuid) +); diff --git a/migrations/2025-06-03-103311_remove_email_tokens/up.sql b/migrations/2025-06-03-103311_remove_email_tokens/up.sql new file mode 100644 index 0000000..b41afe5 --- /dev/null +++ b/migrations/2025-06-03-103311_remove_email_tokens/up.sql @@ -0,0 +1,2 @@ +-- Your SQL goes here +DROP TABLE email_tokens; diff --git a/src/api/v1/auth/verify_email.rs b/src/api/v1/auth/verify_email.rs index 0f23649..e596500 100644 --- a/src/api/v1/auth/verify_email.rs +++ b/src/api/v1/auth/verify_email.rs @@ -46,20 +46,15 @@ pub async fn get( let me = Me::get(&mut conn, uuid).await?; - let email_token = EmailToken::get(&mut conn, me.uuid).await?; + let email_token = EmailToken::get(&data, me.uuid).await?; if query.token != email_token.token { return Ok(HttpResponse::Unauthorized().finish()); } - if Utc::now().signed_duration_since(email_token.created_at) > Duration::hours(24) { - email_token.delete(&mut conn).await?; - return Ok(HttpResponse::Gone().finish()); - } - me.verify_email(&mut conn).await?; - email_token.delete(&mut conn).await?; + email_token.delete(&data).await?; Ok(HttpResponse::Ok().finish()) } @@ -90,9 +85,9 @@ pub async fn post(req: HttpRequest, data: web::Data) -> Result Duration::hours(1) { - email_token.delete(&mut conn).await?; + email_token.delete(&data).await?; } else { return Err(Error::TooManyRequests( "Please allow 1 hour before sending a new email".to_string(), diff --git a/src/objects/email_token.rs b/src/objects/email_token.rs index e458cf7..f55de8c 100644 --- a/src/objects/email_token.rs +++ b/src/objects/email_token.rs @@ -1,19 +1,13 @@ use chrono::Utc; -use diesel::{ - ExpressionMethods, QueryDsl, Queryable, Selectable, SelectableHelper, delete, dsl::now, - insert_into, -}; -use diesel_async::RunQueryDsl; use lettre::message::MultiPart; +use serde::{Deserialize, Serialize}; use uuid::Uuid; -use crate::{Conn, Data, error::Error, schema::email_tokens, utils::generate_refresh_token}; +use crate::{Data, error::Error, utils::generate_refresh_token}; use super::Me; -#[derive(Selectable, Queryable)] -#[diesel(table_name = email_tokens)] -#[diesel(check_for_backend(diesel::pg::Pg))] +#[derive(Serialize, Deserialize)] pub struct EmailToken { user_uuid: Uuid, pub token: String, @@ -21,13 +15,8 @@ pub struct EmailToken { } impl EmailToken { - pub async fn get(conn: &mut Conn, user_uuid: Uuid) -> Result { - use email_tokens::dsl; - let email_token = dsl::email_tokens - .filter(dsl::user_uuid.eq(user_uuid)) - .select(EmailToken::as_select()) - .get_result(conn) - .await?; + pub async fn get(data: &Data, user_uuid: Uuid) -> Result { + let email_token = serde_json::from_str(&data.get_cache_key(format!("{}_email_verify", user_uuid)).await?)?; Ok(email_token) } @@ -36,17 +25,14 @@ impl EmailToken { pub async fn new(data: &Data, me: Me) -> Result<(), Error> { let token = generate_refresh_token()?; - let mut conn = data.pool.get().await?; + let email_token = EmailToken { + user_uuid: me.uuid, + token: token.clone(), + // TODO: Check if this can be replaced with something built into valkey + created_at: Utc::now() + }; - use email_tokens::dsl; - insert_into(email_tokens::table) - .values(( - dsl::user_uuid.eq(me.uuid), - dsl::token.eq(&token), - dsl::created_at.eq(now), - )) - .execute(&mut conn) - .await?; + data.set_cache_key(format!("{}_email_verify", me.uuid), email_token, 86400).await?; let mut verify_endpoint = data.config.web.frontend_url.join("verify-email")?; @@ -67,13 +53,8 @@ impl EmailToken { Ok(()) } - pub async fn delete(&self, conn: &mut Conn) -> Result<(), Error> { - use email_tokens::dsl; - delete(email_tokens::table) - .filter(dsl::user_uuid.eq(self.user_uuid)) - .filter(dsl::token.eq(&self.token)) - .execute(conn) - .await?; + pub async fn delete(&self, data: &Data) -> Result<(), Error> { + data.del_cache_key(format!("{}_email_verify", self.user_uuid)).await?; Ok(()) } diff --git a/src/schema.rs b/src/schema.rs index 09ea7a3..09fa08a 100644 --- a/src/schema.rs +++ b/src/schema.rs @@ -31,15 +31,6 @@ diesel::table! { } } -diesel::table! { - email_tokens (token, user_uuid) { - #[max_length = 64] - token -> Varchar, - user_uuid -> Uuid, - created_at -> Timestamptz, - } -} - diesel::table! { guild_members (uuid) { uuid -> Uuid, @@ -155,7 +146,6 @@ diesel::joinable!(access_tokens -> refresh_tokens (refresh_token)); diesel::joinable!(access_tokens -> users (uuid)); diesel::joinable!(channel_permissions -> channels (channel_uuid)); diesel::joinable!(channels -> guilds (guild_uuid)); -diesel::joinable!(email_tokens -> users (user_uuid)); diesel::joinable!(guild_members -> guilds (guild_uuid)); diesel::joinable!(guild_members -> users (user_uuid)); diesel::joinable!(guilds -> users (owner_uuid)); @@ -173,7 +163,6 @@ diesel::allow_tables_to_appear_in_same_query!( access_tokens, channel_permissions, channels, - email_tokens, guild_members, guilds, instance_permissions, From 419f37b108075eab5c67ad03c51bbfd8dc7e4a42 Mon Sep 17 00:00:00 2001 From: Radical Date: Tue, 3 Jun 2025 11:03:52 +0000 Subject: [PATCH 86/92] feat: move password reset tokens to valkey Also just as useless to keep in DB --- .../down.sql | 7 ++ .../up.sql | 2 + src/api/v1/auth/reset_password.rs | 15 +---- src/objects/password_reset_token.rs | 64 ++++++++----------- src/schema.rs | 11 ---- 5 files changed, 37 insertions(+), 62 deletions(-) create mode 100644 migrations/2025-06-03-110142_remove_password_reset_tokens/down.sql create mode 100644 migrations/2025-06-03-110142_remove_password_reset_tokens/up.sql diff --git a/migrations/2025-06-03-110142_remove_password_reset_tokens/down.sql b/migrations/2025-06-03-110142_remove_password_reset_tokens/down.sql new file mode 100644 index 0000000..009d9e4 --- /dev/null +++ b/migrations/2025-06-03-110142_remove_password_reset_tokens/down.sql @@ -0,0 +1,7 @@ +-- This file should undo anything in `up.sql` +CREATE TABLE password_reset_tokens ( + token VARCHAR(64) NOT NULL, + user_uuid uuid UNIQUE NOT NULL REFERENCES users(uuid), + created_at TIMESTAMPTZ NOT NULL, + PRIMARY KEY (token, user_uuid) +); diff --git a/migrations/2025-06-03-110142_remove_password_reset_tokens/up.sql b/migrations/2025-06-03-110142_remove_password_reset_tokens/up.sql new file mode 100644 index 0000000..181d7c5 --- /dev/null +++ b/migrations/2025-06-03-110142_remove_password_reset_tokens/up.sql @@ -0,0 +1,2 @@ +-- Your SQL goes here +DROP TABLE password_reset_tokens; diff --git a/src/api/v1/auth/reset_password.rs b/src/api/v1/auth/reset_password.rs index 4373a82..444266c 100644 --- a/src/api/v1/auth/reset_password.rs +++ b/src/api/v1/auth/reset_password.rs @@ -26,13 +26,11 @@ struct Query { /// #[get("/reset-password")] pub async fn get(query: web::Query, data: web::Data) -> Result { - let mut conn = data.pool.get().await?; - if let Ok(password_reset_token) = - PasswordResetToken::get_with_identifier(&mut conn, query.identifier.clone()).await + PasswordResetToken::get_with_identifier(&data, query.identifier.clone()).await { if Utc::now().signed_duration_since(password_reset_token.created_at) > Duration::hours(1) { - password_reset_token.delete(&mut conn).await?; + password_reset_token.delete(&data).await?; } else { return Err(Error::TooManyRequests( "Please allow 1 hour before sending a new email".to_string(), @@ -74,15 +72,8 @@ pub async fn post( reset_password: web::Json, data: web::Data, ) -> Result { - let mut conn = data.pool.get().await?; - let password_reset_token = - PasswordResetToken::get(&mut conn, reset_password.token.clone()).await?; - - if Utc::now().signed_duration_since(password_reset_token.created_at) > Duration::hours(24) { - password_reset_token.delete(&mut conn).await?; - return Ok(HttpResponse::Gone().finish()); - } + PasswordResetToken::get(&data, reset_password.token.clone()).await?; password_reset_token .set_password(&data, reset_password.password.clone()) diff --git a/src/objects/password_reset_token.rs b/src/objects/password_reset_token.rs index e3c7bca..0376d88 100644 --- a/src/objects/password_reset_token.rs +++ b/src/objects/password_reset_token.rs @@ -4,23 +4,21 @@ use argon2::{ }; use chrono::Utc; use diesel::{ - ExpressionMethods, QueryDsl, Queryable, Selectable, SelectableHelper, delete, dsl::now, - insert_into, update, + ExpressionMethods, QueryDsl, update, }; use diesel_async::RunQueryDsl; use lettre::message::MultiPart; +use serde::{Deserialize, Serialize}; use uuid::Uuid; use crate::{ - Conn, Data, + Data, error::Error, - schema::{password_reset_tokens, users}, + schema::users, utils::{PASSWORD_REGEX, generate_refresh_token, global_checks, user_uuid_from_identifier}, }; -#[derive(Selectable, Queryable)] -#[diesel(table_name = password_reset_tokens)] -#[diesel(check_for_backend(diesel::pg::Pg))] +#[derive(Serialize, Deserialize)] pub struct PasswordResetToken { user_uuid: Uuid, pub token: String, @@ -28,29 +26,22 @@ pub struct PasswordResetToken { } impl PasswordResetToken { - pub async fn get(conn: &mut Conn, token: String) -> Result { - use password_reset_tokens::dsl; - let password_reset_token = dsl::password_reset_tokens - .filter(dsl::token.eq(token)) - .select(PasswordResetToken::as_select()) - .get_result(conn) - .await?; + pub async fn get(data: &Data, token: String) -> Result { + let user_uuid: Uuid = serde_json::from_str(&data.get_cache_key(format!("{}", token)).await?)?; + let password_reset_token = serde_json::from_str(&data.get_cache_key(format!("{}_password_reset", user_uuid)).await?)?; Ok(password_reset_token) } pub async fn get_with_identifier( - conn: &mut Conn, + data: &Data, identifier: String, ) -> Result { - let user_uuid = user_uuid_from_identifier(conn, &identifier).await?; + let mut conn = data.pool.get().await?; - use password_reset_tokens::dsl; - let password_reset_token = dsl::password_reset_tokens - .filter(dsl::user_uuid.eq(user_uuid)) - .select(PasswordResetToken::as_select()) - .get_result(conn) - .await?; + let user_uuid = user_uuid_from_identifier(&mut conn, &identifier).await?; + + let password_reset_token = serde_json::from_str(&data.get_cache_key(format!("{}_password_reset", user_uuid)).await?)?; Ok(password_reset_token) } @@ -72,15 +63,14 @@ impl PasswordResetToken { .get_result(&mut conn) .await?; - use password_reset_tokens::dsl; - insert_into(password_reset_tokens::table) - .values(( - dsl::user_uuid.eq(user_uuid), - dsl::token.eq(&token), - dsl::created_at.eq(now), - )) - .execute(&mut conn) - .await?; + let password_reset_token = PasswordResetToken { + user_uuid, + token: token.clone(), + created_at: Utc::now(), + }; + + data.set_cache_key(format!("{}_password_reset", user_uuid), password_reset_token, 86400).await?; + data.set_cache_key(token.clone(), user_uuid, 86400).await?; let mut reset_endpoint = data.config.web.frontend_url.join("reset-password")?; @@ -144,16 +134,12 @@ impl PasswordResetToken { data.mail_client.send_mail(email).await?; - self.delete(&mut conn).await + self.delete(&data).await } - pub async fn delete(&self, conn: &mut Conn) -> Result<(), Error> { - use password_reset_tokens::dsl; - delete(password_reset_tokens::table) - .filter(dsl::user_uuid.eq(self.user_uuid)) - .filter(dsl::token.eq(&self.token)) - .execute(conn) - .await?; + pub async fn delete(&self, data: &Data) -> Result<(), Error> { + data.del_cache_key(format!("{}_password_reset", &self.user_uuid)).await?; + data.del_cache_key(format!("{}", &self.token)).await?; Ok(()) } diff --git a/src/schema.rs b/src/schema.rs index 09fa08a..aaef9c1 100644 --- a/src/schema.rs +++ b/src/schema.rs @@ -80,15 +80,6 @@ diesel::table! { } } -diesel::table! { - password_reset_tokens (token, user_uuid) { - #[max_length = 64] - token -> Varchar, - user_uuid -> Uuid, - created_at -> Timestamptz, - } -} - diesel::table! { refresh_tokens (token) { #[max_length = 64] @@ -154,7 +145,6 @@ diesel::joinable!(invites -> guilds (guild_uuid)); diesel::joinable!(invites -> users (user_uuid)); diesel::joinable!(messages -> channels (channel_uuid)); diesel::joinable!(messages -> users (user_uuid)); -diesel::joinable!(password_reset_tokens -> users (user_uuid)); diesel::joinable!(refresh_tokens -> users (uuid)); diesel::joinable!(role_members -> guild_members (member_uuid)); diesel::joinable!(roles -> guilds (guild_uuid)); @@ -168,7 +158,6 @@ diesel::allow_tables_to_appear_in_same_query!( instance_permissions, invites, messages, - password_reset_tokens, refresh_tokens, role_members, roles, From 05885418760e4bd786e9b25611edd686c183f1a8 Mon Sep 17 00:00:00 2001 From: Radical Date: Fri, 6 Jun 2025 17:20:02 +0200 Subject: [PATCH 87/92] feat: move ownership to member column instead of table column --- .../down.sql | 14 ++++++++++++++ .../up.sql | 14 ++++++++++++++ src/objects/guild.rs | 6 +----- src/objects/member.rs | 4 ++++ src/schema.rs | 3 +-- 5 files changed, 34 insertions(+), 7 deletions(-) create mode 100644 migrations/2025-06-06-145916_guild_ownership_changes/down.sql create mode 100644 migrations/2025-06-06-145916_guild_ownership_changes/up.sql diff --git a/migrations/2025-06-06-145916_guild_ownership_changes/down.sql b/migrations/2025-06-06-145916_guild_ownership_changes/down.sql new file mode 100644 index 0000000..21a08c9 --- /dev/null +++ b/migrations/2025-06-06-145916_guild_ownership_changes/down.sql @@ -0,0 +1,14 @@ +-- This file should undo anything in `up.sql` +ALTER TABLE guilds +ADD COLUMN owner_uuid UUID REFERENCES users(uuid); + +UPDATE guilds g +SET owner_uuid = gm.user_uuid +FROM guild_members gm +WHERE gm.guild_uuid = g.uuid AND gm.is_owner = TRUE; + +ALTER TABLE guilds +ALTER COLUMN owner_uuid SET NOT NULL; + +ALTER TABLE guild_members +DROP COLUMN is_owner; diff --git a/migrations/2025-06-06-145916_guild_ownership_changes/up.sql b/migrations/2025-06-06-145916_guild_ownership_changes/up.sql new file mode 100644 index 0000000..b94323f --- /dev/null +++ b/migrations/2025-06-06-145916_guild_ownership_changes/up.sql @@ -0,0 +1,14 @@ +-- Your SQL goes here +ALTER TABLE guild_members +ADD COLUMN is_owner BOOLEAN NOT NULL DEFAULT false; + +UPDATE guild_members gm +SET is_owner = true +FROM guilds g +WHERE gm.guild_uuid = g.uuid AND gm.user_uuid = g.owner_uuid; + +CREATE UNIQUE INDEX one_owner_per_guild ON guild_members (guild_uuid) +WHERE is_owner; + +ALTER TABLE guilds +DROP COLUMN owner_uuid; diff --git a/src/objects/guild.rs b/src/objects/guild.rs index f5e973d..7d55595 100644 --- a/src/objects/guild.rs +++ b/src/objects/guild.rs @@ -26,7 +26,6 @@ pub struct GuildBuilder { name: String, description: Option, icon: Option, - owner_uuid: Uuid, } impl GuildBuilder { @@ -40,7 +39,6 @@ impl GuildBuilder { name: self.name, description: self.description, icon: self.icon.and_then(|i| i.parse().ok()), - owner_uuid: self.owner_uuid, roles, member_count, }) @@ -53,7 +51,6 @@ pub struct Guild { name: String, description: Option, icon: Option, - owner_uuid: Uuid, pub roles: Vec, member_count: i64, } @@ -110,7 +107,6 @@ impl Guild { name: name.clone(), description: None, icon: None, - owner_uuid, }; insert_into(guilds::table) @@ -125,6 +121,7 @@ impl Guild { nickname: None, user_uuid: owner_uuid, guild_uuid, + is_owner: true, }; insert_into(guild_members::table) @@ -137,7 +134,6 @@ impl Guild { name, description: None, icon: None, - owner_uuid, roles: vec![], member_count: 1, }) diff --git a/src/objects/member.rs b/src/objects/member.rs index f18e726..67312e4 100644 --- a/src/objects/member.rs +++ b/src/objects/member.rs @@ -17,6 +17,7 @@ pub struct MemberBuilder { pub nickname: Option, pub user_uuid: Uuid, pub guild_uuid: Uuid, + pub is_owner: bool, } impl MemberBuilder { @@ -28,6 +29,7 @@ impl MemberBuilder { nickname: self.nickname.clone(), user_uuid: self.user_uuid, guild_uuid: self.guild_uuid, + is_owner: self.is_owner, user, }) } @@ -39,6 +41,7 @@ pub struct Member { pub nickname: Option, pub user_uuid: Uuid, pub guild_uuid: Uuid, + pub is_owner: bool, user: User, } @@ -113,6 +116,7 @@ impl Member { guild_uuid, user_uuid, nickname: None, + is_owner: false, }; insert_into(guild_members::table) diff --git a/src/schema.rs b/src/schema.rs index aaef9c1..c7a350c 100644 --- a/src/schema.rs +++ b/src/schema.rs @@ -38,13 +38,13 @@ diesel::table! { user_uuid -> Uuid, #[max_length = 100] nickname -> Nullable, + is_owner -> Bool, } } diesel::table! { guilds (uuid) { uuid -> Uuid, - owner_uuid -> Uuid, #[max_length = 100] name -> Varchar, #[max_length = 300] @@ -139,7 +139,6 @@ diesel::joinable!(channel_permissions -> channels (channel_uuid)); diesel::joinable!(channels -> guilds (guild_uuid)); diesel::joinable!(guild_members -> guilds (guild_uuid)); diesel::joinable!(guild_members -> users (user_uuid)); -diesel::joinable!(guilds -> users (owner_uuid)); diesel::joinable!(instance_permissions -> users (uuid)); diesel::joinable!(invites -> guilds (guild_uuid)); diesel::joinable!(invites -> users (user_uuid)); From 95c942eee419c89a4dad069f307ca366890ebaaa Mon Sep 17 00:00:00 2001 From: Radical Date: Fri, 6 Jun 2025 17:49:06 +0200 Subject: [PATCH 88/92] feat: use permission system --- src/api/v1/channels/uuid/mod.rs | 14 ++-- src/api/v1/guilds/uuid/channels.rs | 10 +-- src/api/v1/guilds/uuid/icon.rs | 10 ++- src/api/v1/guilds/uuid/invites/mod.rs | 10 ++- src/api/v1/guilds/uuid/roles/mod.rs | 10 +-- src/objects/member.rs | 22 ++++-- src/objects/mod.rs | 39 +---------- src/objects/role.rs | 98 +++++++++++++++++++++++++-- 8 files changed, 133 insertions(+), 80 deletions(-) diff --git a/src/api/v1/channels/uuid/mod.rs b/src/api/v1/channels/uuid/mod.rs index 1cb20c7..bece6ed 100644 --- a/src/api/v1/channels/uuid/mod.rs +++ b/src/api/v1/channels/uuid/mod.rs @@ -4,11 +4,7 @@ pub mod messages; pub mod socket; use crate::{ - Data, - api::v1::auth::check_access_token, - error::Error, - objects::{Channel, Member}, - utils::{get_auth_header, global_checks}, + api::v1::auth::check_access_token, error::Error, objects::{Channel, Member, Permissions}, utils::{get_auth_header, global_checks}, Data }; use actix_web::{HttpRequest, HttpResponse, delete, get, patch, web}; use serde::Deserialize; @@ -59,7 +55,9 @@ pub async fn delete( let channel = Channel::fetch_one(&data, channel_uuid).await?; - Member::check_membership(&mut conn, uuid, channel.guild_uuid).await?; + let member = Member::check_membership(&mut conn, uuid, channel.guild_uuid).await?; + + member.check_permission(&data, Permissions::DeleteChannel).await?; channel.delete(&data).await?; @@ -125,7 +123,9 @@ pub async fn patch( let mut channel = Channel::fetch_one(&data, channel_uuid).await?; - Member::check_membership(&mut conn, uuid, channel.guild_uuid).await?; + let member = Member::check_membership(&mut conn, uuid, channel.guild_uuid).await?; + + member.check_permission(&data, Permissions::ManageChannel).await?; if let Some(new_name) = &new_info.name { channel.set_name(&data, new_name.to_string()).await?; diff --git a/src/api/v1/guilds/uuid/channels.rs b/src/api/v1/guilds/uuid/channels.rs index 083553a..db895e4 100644 --- a/src/api/v1/guilds/uuid/channels.rs +++ b/src/api/v1/guilds/uuid/channels.rs @@ -1,9 +1,5 @@ use crate::{ - Data, - api::v1::auth::check_access_token, - error::Error, - objects::{Channel, Member}, - utils::{get_auth_header, global_checks, order_by_is_above}, + api::v1::auth::check_access_token, error::Error, objects::{Channel, Member, Permissions}, utils::{get_auth_header, global_checks, order_by_is_above}, Data }; use ::uuid::Uuid; use actix_web::{HttpRequest, HttpResponse, get, post, web}; @@ -74,9 +70,9 @@ pub async fn create( global_checks(&data, uuid).await?; - Member::check_membership(&mut conn, uuid, guild_uuid).await?; + let member = Member::check_membership(&mut conn, uuid, guild_uuid).await?; - // FIXME: Logic to check permissions, should probably be done in utils.rs + member.check_permission(&data, Permissions::CreateChannel).await?; let channel = Channel::new( data.clone(), diff --git a/src/api/v1/guilds/uuid/icon.rs b/src/api/v1/guilds/uuid/icon.rs index 5025416..4061585 100644 --- a/src/api/v1/guilds/uuid/icon.rs +++ b/src/api/v1/guilds/uuid/icon.rs @@ -5,11 +5,7 @@ use futures_util::StreamExt as _; use uuid::Uuid; use crate::{ - Data, - api::v1::auth::check_access_token, - error::Error, - objects::{Guild, Member}, - utils::{get_auth_header, global_checks}, + api::v1::auth::check_access_token, error::Error, objects::{Guild, Member, Permissions}, utils::{get_auth_header, global_checks}, Data }; /// `PUT /api/v1/guilds/{uuid}/icon` Icon upload @@ -36,7 +32,9 @@ pub async fn upload( global_checks(&data, uuid).await?; - Member::check_membership(&mut conn, uuid, guild_uuid).await?; + let member = Member::check_membership(&mut conn, uuid, guild_uuid).await?; + + member.check_permission(&data, Permissions::ManageServer).await?; let mut guild = Guild::fetch_one(&mut conn, guild_uuid).await?; diff --git a/src/api/v1/guilds/uuid/invites/mod.rs b/src/api/v1/guilds/uuid/invites/mod.rs index bb8269c..eb8d2ce 100644 --- a/src/api/v1/guilds/uuid/invites/mod.rs +++ b/src/api/v1/guilds/uuid/invites/mod.rs @@ -3,11 +3,7 @@ use serde::Deserialize; use uuid::Uuid; use crate::{ - Data, - api::v1::auth::check_access_token, - error::Error, - objects::{Guild, Member}, - utils::{get_auth_header, global_checks}, + api::v1::auth::check_access_token, error::Error, objects::{Guild, Member, Permissions}, utils::{get_auth_header, global_checks}, Data }; #[derive(Deserialize)] @@ -61,7 +57,9 @@ pub async fn create( global_checks(&data, uuid).await?; - Member::check_membership(&mut conn, uuid, guild_uuid).await?; + let member = Member::check_membership(&mut conn, uuid, guild_uuid).await?; + + member.check_permission(&data, Permissions::CreateInvite).await?; let guild = Guild::fetch_one(&mut conn, guild_uuid).await?; diff --git a/src/api/v1/guilds/uuid/roles/mod.rs b/src/api/v1/guilds/uuid/roles/mod.rs index 717b30b..c33f144 100644 --- a/src/api/v1/guilds/uuid/roles/mod.rs +++ b/src/api/v1/guilds/uuid/roles/mod.rs @@ -3,11 +3,7 @@ use actix_web::{HttpRequest, HttpResponse, get, post, web}; use serde::Deserialize; use crate::{ - Data, - api::v1::auth::check_access_token, - error::Error, - objects::{Member, Role}, - utils::{get_auth_header, global_checks, order_by_is_above}, + api::v1::auth::check_access_token, error::Error, objects::{Member, Permissions, Role}, utils::{get_auth_header, global_checks, order_by_is_above}, Data }; pub mod uuid; @@ -70,9 +66,9 @@ pub async fn create( global_checks(&data, uuid).await?; - Member::check_membership(&mut conn, uuid, guild_uuid).await?; + let member = Member::check_membership(&mut conn, uuid, guild_uuid).await?; - // FIXME: Logic to check permissions, should probably be done in utils.rs + member.check_permission(&data, Permissions::CreateRole).await?; let role = Role::new(&mut conn, guild_uuid, role_info.name.clone()).await?; diff --git a/src/objects/member.rs b/src/objects/member.rs index 67312e4..20bc848 100644 --- a/src/objects/member.rs +++ b/src/objects/member.rs @@ -5,7 +5,7 @@ use diesel_async::RunQueryDsl; use serde::{Deserialize, Serialize}; use uuid::Uuid; -use crate::{Conn, Data, error::Error, schema::guild_members}; +use crate::{error::Error, objects::{Permissions, Role}, schema::guild_members, Conn, Data}; use super::{User, load_or_empty}; @@ -21,7 +21,7 @@ pub struct MemberBuilder { } impl MemberBuilder { - async fn build(&self, data: &Data) -> Result { + pub async fn build(&self, data: &Data) -> Result { let user = User::fetch_one(data, self.user_uuid).await?; Ok(Member { @@ -33,6 +33,18 @@ impl MemberBuilder { user, }) } + + pub async fn check_permission(&self, data: &Data, permission: Permissions) -> Result<(), Error> { + if !self.is_owner { + let roles = Role::fetch_from_member(&data, self.uuid).await?; + let allowed = roles.iter().any(|r| r.permissions & permission as i64 != 0); + if !allowed { + return Err(Error::Forbidden("Not allowed".to_string())) + } + } + + Ok(()) + } } #[derive(Serialize, Deserialize)] @@ -61,16 +73,16 @@ impl Member { conn: &mut Conn, user_uuid: Uuid, guild_uuid: Uuid, - ) -> Result<(), Error> { + ) -> Result { use guild_members::dsl; - dsl::guild_members + let member_builder = dsl::guild_members .filter(dsl::user_uuid.eq(user_uuid)) .filter(dsl::guild_uuid.eq(guild_uuid)) .select(MemberBuilder::as_select()) .get_result(conn) .await?; - Ok(()) + Ok(member_builder) } pub async fn fetch_one(data: &Data, user_uuid: Uuid, guild_uuid: Uuid) -> Result { diff --git a/src/objects/mod.rs b/src/objects/mod.rs index 7b45957..30a0a64 100644 --- a/src/objects/mod.rs +++ b/src/objects/mod.rs @@ -27,6 +27,7 @@ pub use member::Member; pub use message::Message; pub use password_reset_token::PasswordResetToken; pub use role::Role; +pub use role::Permissions; pub use user::User; use crate::error::Error; @@ -111,44 +112,6 @@ impl MailClient { } } -#[derive(Clone, Copy)] -pub enum Permissions { - SendMessage = 1, - CreateChannel = 2, - DeleteChannel = 4, - ManageChannel = 8, - CreateRole = 16, - DeleteRole = 32, - ManageRole = 64, - CreateInvite = 128, - ManageInvite = 256, - ManageServer = 512, - ManageMember = 1024, -} - -impl Permissions { - pub fn fetch_permissions(permissions: i64) -> Vec { - let all_perms = vec![ - Self::SendMessage, - Self::CreateChannel, - Self::DeleteChannel, - Self::ManageChannel, - Self::CreateRole, - Self::DeleteRole, - Self::ManageRole, - Self::CreateInvite, - Self::ManageInvite, - Self::ManageServer, - Self::ManageMember, - ]; - - all_perms - .into_iter() - .filter(|p| permissions & (*p as i64) != 0) - .collect() - } -} - #[derive(Deserialize)] pub struct StartAmountQuery { pub start: Option, diff --git a/src/objects/role.rs b/src/objects/role.rs index f67dc6d..a78798a 100644 --- a/src/objects/role.rs +++ b/src/objects/role.rs @@ -3,14 +3,14 @@ use diesel::{ update, }; use diesel_async::RunQueryDsl; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use uuid::Uuid; -use crate::{Conn, error::Error, schema::roles, utils::order_by_is_above}; +use crate::{error::Error, schema::{role_members, roles}, utils::order_by_is_above, Conn, Data}; use super::{HasIsAbove, HasUuid, load_or_empty}; -#[derive(Serialize, Clone, Queryable, Selectable, Insertable)] +#[derive(Deserialize, Serialize, Clone, Queryable, Selectable, Insertable)] #[diesel(table_name = roles)] #[diesel(check_for_backend(diesel::pg::Pg))] pub struct Role { @@ -19,7 +19,28 @@ pub struct Role { name: String, color: i32, is_above: Option, - permissions: i64, + pub permissions: i64, +} + +#[derive(Serialize, Clone, Queryable, Selectable, Insertable)] +#[diesel(table_name = role_members)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct RoleMember { + role_uuid: Uuid, + member_uuid: Uuid, +} + +impl RoleMember { + async fn fetch_role(&self, conn: &mut Conn) -> Result { + use roles::dsl; + let role: Role = dsl::roles + .filter(dsl::uuid.eq(self.role_uuid)) + .select(Role::as_select()) + .get_result(conn) + .await?; + + Ok(role) + } } impl HasUuid for Role { @@ -48,6 +69,33 @@ impl Role { Ok(roles) } + pub async fn fetch_from_member(data: &Data, member_uuid: Uuid) -> Result, Error> { + if let Ok(roles) = data.get_cache_key(format!("{}_roles", member_uuid)).await { + return Ok(serde_json::from_str(&roles)?) + } + + let mut conn = data.pool.get().await?; + + use role_members::dsl; + let role_memberships: Vec = load_or_empty( + dsl::role_members + .filter(dsl::member_uuid.eq(member_uuid)) + .select(RoleMember::as_select()) + .load(&mut conn) + .await, + )?; + + let mut roles = vec![]; + + for membership in role_memberships { + roles.push(membership.fetch_role(&mut conn).await?); + } + + data.set_cache_key(format!("{}_roles", member_uuid), roles.clone(), 300).await?; + + Ok(roles) + } + pub async fn fetch_one(conn: &mut Conn, role_uuid: Uuid) -> Result { use roles::dsl; let role: Role = dsl::roles @@ -59,6 +107,10 @@ impl Role { Ok(role) } + pub async fn fetch_permissions(&self) -> Vec { + Permissions::fetch_permissions(self.permissions.clone()) + } + pub async fn new(conn: &mut Conn, guild_uuid: Uuid, name: String) -> Result { let role_uuid = Uuid::now_v7(); @@ -94,3 +146,41 @@ impl Role { Ok(new_role) } } + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum Permissions { + SendMessage = 1, + CreateChannel = 2, + DeleteChannel = 4, + ManageChannel = 8, + CreateRole = 16, + DeleteRole = 32, + ManageRole = 64, + CreateInvite = 128, + ManageInvite = 256, + ManageServer = 512, + ManageMember = 1024, +} + +impl Permissions { + pub fn fetch_permissions(permissions: i64) -> Vec { + let all_perms = vec![ + Self::SendMessage, + Self::CreateChannel, + Self::DeleteChannel, + Self::ManageChannel, + Self::CreateRole, + Self::DeleteRole, + Self::ManageRole, + Self::CreateInvite, + Self::ManageInvite, + Self::ManageServer, + Self::ManageMember, + ]; + + all_perms + .into_iter() + .filter(|p| permissions & (*p as i64) != 0) + .collect() + } +} From 8dca22de3a0a824dd318814a29c5c351ea0ced1d Mon Sep 17 00:00:00 2001 From: Radical Date: Fri, 6 Jun 2025 18:16:25 +0200 Subject: [PATCH 89/92] fix: make channel deletion work --- src/objects/channel.rs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/objects/channel.rs b/src/objects/channel.rs index 9b756f2..7a31150 100644 --- a/src/objects/channel.rs +++ b/src/objects/channel.rs @@ -198,15 +198,34 @@ impl Channel { let mut conn = data.pool.get().await?; use channels::dsl; + update(channels::table) + .filter(dsl::is_above.eq(self.uuid)) + .set(dsl::is_above.eq(None::)) + .execute(&mut conn) + .await?; delete(channels::table) .filter(dsl::uuid.eq(self.uuid)) .execute(&mut conn) .await?; + update(channels::table) + .filter(dsl::is_above.eq(self.uuid)) + .set(dsl::is_above.eq(self.is_above)) + .execute(&mut conn) + .await?; if data.get_cache_key(self.uuid.to_string()).await.is_ok() { data.del_cache_key(self.uuid.to_string()).await?; } + if data + .get_cache_key(format!("{}_channels", self.guild_uuid)) + .await + .is_ok() + { + data.del_cache_key(format!("{}_channels", self.guild_uuid)) + .await?; + } + Ok(()) } From f752cddd73d522b4011418ca3a5b75cdfd048388 Mon Sep 17 00:00:00 2001 From: Radical Date: Fri, 6 Jun 2025 18:19:40 +0200 Subject: [PATCH 90/92] fix: add missing match statements --- src/objects/channel.rs | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/src/objects/channel.rs b/src/objects/channel.rs index 7a31150..4d52353 100644 --- a/src/objects/channel.rs +++ b/src/objects/channel.rs @@ -198,20 +198,32 @@ impl Channel { let mut conn = data.pool.get().await?; use channels::dsl; - update(channels::table) + match update(channels::table) .filter(dsl::is_above.eq(self.uuid)) .set(dsl::is_above.eq(None::)) .execute(&mut conn) - .await?; + .await + { + Ok(r) => Ok(r), + Err(diesel::result::Error::NotFound) => Ok(0), + Err(e) => Err(e), + }?; + delete(channels::table) .filter(dsl::uuid.eq(self.uuid)) .execute(&mut conn) .await?; - update(channels::table) + + match update(channels::table) .filter(dsl::is_above.eq(self.uuid)) .set(dsl::is_above.eq(self.is_above)) .execute(&mut conn) - .await?; + .await + { + Ok(r) => Ok(r), + Err(diesel::result::Error::NotFound) => Ok(0), + Err(e) => Err(e), + }?; if data.get_cache_key(self.uuid.to_string()).await.is_ok() { data.del_cache_key(self.uuid.to_string()).await?; From 407460d2aa1391ac8a50731f970fb8c4622c589a Mon Sep 17 00:00:00 2001 From: Radical Date: Wed, 25 Jun 2025 13:25:39 +0200 Subject: [PATCH 91/92] style: use const generic for token length instead of multiple functions Simplifies codebase a bit and avoids having to add another function in future if we need another length of token --- src/api/v1/auth/login.rs | 6 +++--- src/api/v1/auth/refresh.rs | 6 +++--- src/api/v1/auth/register.rs | 6 +++--- src/objects/email_token.rs | 4 ++-- src/objects/password_reset_token.rs | 6 +++--- src/utils.rs | 10 ++-------- 6 files changed, 16 insertions(+), 22 deletions(-) diff --git a/src/api/v1/auth/login.rs b/src/api/v1/auth/login.rs index e190c2f..ac6c1ad 100644 --- a/src/api/v1/auth/login.rs +++ b/src/api/v1/auth/login.rs @@ -11,7 +11,7 @@ use crate::{ error::Error, schema::*, utils::{ - PASSWORD_REGEX, generate_access_token, generate_refresh_token, new_refresh_token_cookie, + PASSWORD_REGEX, generate_token, new_refresh_token_cookie, user_uuid_from_identifier, }, }; @@ -59,8 +59,8 @@ pub async fn response( )); } - let refresh_token = generate_refresh_token()?; - let access_token = generate_access_token()?; + let refresh_token = generate_token::<32>()?; + let access_token = generate_token::<16>()?; let current_time = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs() as i64; diff --git a/src/api/v1/auth/refresh.rs b/src/api/v1/auth/refresh.rs index 63e150e..1f4f406 100644 --- a/src/api/v1/auth/refresh.rs +++ b/src/api/v1/auth/refresh.rs @@ -11,7 +11,7 @@ use crate::{ access_tokens::{self, dsl}, refresh_tokens::{self, dsl as rdsl}, }, - utils::{generate_access_token, generate_refresh_token, new_refresh_token_cookie}, + utils::{generate_token, new_refresh_token_cookie}, }; use super::Response; @@ -55,7 +55,7 @@ pub async fn res(req: HttpRequest, data: web::Data) -> Result 1987200 { - let new_refresh_token = generate_refresh_token()?; + let new_refresh_token = generate_token::<32>()?; match update(refresh_tokens::table) .filter(rdsl::token.eq(&refresh_token)) @@ -75,7 +75,7 @@ pub async fn res(req: HttpRequest, data: web::Data) -> Result()?; update(access_tokens::table) .filter(dsl::refresh_token.eq(&refresh_token)) diff --git a/src/api/v1/auth/register.rs b/src/api/v1/auth/register.rs index 66e2989..1d28088 100644 --- a/src/api/v1/auth/register.rs +++ b/src/api/v1/auth/register.rs @@ -20,7 +20,7 @@ use crate::{ users::{self, dsl as udsl}, }, utils::{ - EMAIL_REGEX, PASSWORD_REGEX, USERNAME_REGEX, generate_access_token, generate_refresh_token, + EMAIL_REGEX, PASSWORD_REGEX, USERNAME_REGEX, generate_token, new_refresh_token_cookie, }, }; @@ -120,8 +120,8 @@ pub async fn res( .execute(&mut conn) .await?; - let refresh_token = generate_refresh_token()?; - let access_token = generate_access_token()?; + let refresh_token = generate_token::<32>()?; + let access_token = generate_token::<16>()?; let current_time = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs() as i64; diff --git a/src/objects/email_token.rs b/src/objects/email_token.rs index f55de8c..4ec6b7e 100644 --- a/src/objects/email_token.rs +++ b/src/objects/email_token.rs @@ -3,7 +3,7 @@ use lettre::message::MultiPart; use serde::{Deserialize, Serialize}; use uuid::Uuid; -use crate::{Data, error::Error, utils::generate_refresh_token}; +use crate::{Data, error::Error, utils::generate_token}; use super::Me; @@ -23,7 +23,7 @@ impl EmailToken { #[allow(clippy::new_ret_no_self)] pub async fn new(data: &Data, me: Me) -> Result<(), Error> { - let token = generate_refresh_token()?; + let token = generate_token::<32>()?; let email_token = EmailToken { user_uuid: me.uuid, diff --git a/src/objects/password_reset_token.rs b/src/objects/password_reset_token.rs index 0376d88..e14d25a 100644 --- a/src/objects/password_reset_token.rs +++ b/src/objects/password_reset_token.rs @@ -12,10 +12,10 @@ use serde::{Deserialize, Serialize}; use uuid::Uuid; use crate::{ - Data, error::Error, schema::users, - utils::{PASSWORD_REGEX, generate_refresh_token, global_checks, user_uuid_from_identifier}, + utils::{generate_token, global_checks, user_uuid_from_identifier, PASSWORD_REGEX}, + Data }; #[derive(Serialize, Deserialize)] @@ -48,7 +48,7 @@ impl PasswordResetToken { #[allow(clippy::new_ret_no_self)] pub async fn new(data: &Data, identifier: String) -> Result<(), Error> { - let token = generate_refresh_token()?; + let token = generate_token::<32>()?; let mut conn = data.pool.get().await?; diff --git a/src/utils.rs b/src/utils.rs index 3172cec..7a5581a 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -115,14 +115,8 @@ pub fn new_refresh_token_cookie(config: &Config, refresh_token: String) -> Cooki .finish() } -pub fn generate_access_token() -> Result { - let mut buf = [0u8; 16]; - fill(&mut buf)?; - Ok(encode(buf)) -} - -pub fn generate_refresh_token() -> Result { - let mut buf = [0u8; 32]; +pub fn generate_token() -> Result { + let mut buf = [0u8; N]; fill(&mut buf)?; Ok(encode(buf)) } From 36d3a18b0857691765c0d803dceef0f427f44bf4 Mon Sep 17 00:00:00 2001 From: Radical Date: Wed, 25 Jun 2025 14:33:05 +0200 Subject: [PATCH 92/92] build: update dependencies --- Cargo.toml | 8 ++++---- src/api/v1/guilds/uuid/icon.rs | 2 +- src/main.rs | 11 +++-------- src/objects/guild.rs | 6 +++--- src/objects/me.rs | 5 ++--- 5 files changed, 13 insertions(+), 19 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 30b5827..1c5f34b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,15 +27,15 @@ regex = "1.11" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" simple_logger = "5.0.0" -redis = { version = "0.31.0", features= ["tokio-comp"] } -tokio-tungstenite = { version = "0.26", features = ["native-tls", "url"] } +redis = { version = "0.32", features= ["tokio-comp"] } +tokio-tungstenite = { version = "0.27", features = ["native-tls", "url"] } toml = "0.8" url = { version = "2.5", features = ["serde"] } uuid = { version = "1.17", features = ["serde", "v7"] } random-string = "1.1" actix-ws = "0.3.0" futures-util = "0.3.31" -bunny-api-tokio = "0.3.0" +bunny-api-tokio = { version = "0.4", features = ["edge_storage"], default-features = false } bindet = "0.3.2" deadpool = "0.12" diesel = { version = "2.2", features = ["uuid", "chrono"], default-features = false } @@ -43,7 +43,7 @@ diesel-async = { version = "0.5", features = ["deadpool", "postgres", "async-con diesel_migrations = { version = "2.2.0", features = ["postgres"] } thiserror = "2.0.12" actix-multipart = "0.7.2" -lettre = { version = "0.11.16", features = ["tokio1", "tokio1-native-tls"] } +lettre = { version = "0.11", features = ["tokio1", "tokio1-native-tls"] } chrono = { version = "0.4.41", features = ["serde"] } [dependencies.tokio] diff --git a/src/api/v1/guilds/uuid/icon.rs b/src/api/v1/guilds/uuid/icon.rs index 4061585..0860435 100644 --- a/src/api/v1/guilds/uuid/icon.rs +++ b/src/api/v1/guilds/uuid/icon.rs @@ -45,7 +45,7 @@ pub async fn upload( guild .set_icon( - &data.bunny_cdn, + &data.bunny_storage, &mut conn, data.config.bunny.cdn_url.clone(), bytes, diff --git a/src/main.rs b/src/main.rs index 540a237..47794e3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -40,7 +40,7 @@ pub struct Data { pub config: Config, pub argon2: Argon2<'static>, pub start_time: SystemTime, - pub bunny_cdn: bunny_api_tokio::Client, + pub bunny_storage: bunny_api_tokio::EdgeStorageClient, pub mail_client: MailClient, } @@ -65,14 +65,9 @@ async fn main() -> Result<(), Error> { let cache_pool = redis::Client::open(config.cache_database.url())?; - let mut bunny_cdn = bunny_api_tokio::Client::new("").await?; - let bunny = config.bunny.clone(); - bunny_cdn - .storage - .init(bunny.api_key, bunny.endpoint, bunny.storage_zone) - .await?; + let bunny_storage = bunny_api_tokio::EdgeStorageClient::new(bunny.api_key, bunny.endpoint, bunny.storage_zone).await?; let mail = config.mail.clone(); @@ -122,7 +117,7 @@ async fn main() -> Result<(), Error> { // 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, + bunny_storage, mail_client, }; diff --git a/src/objects/guild.rs b/src/objects/guild.rs index 7d55595..47058ee 100644 --- a/src/objects/guild.rs +++ b/src/objects/guild.rs @@ -188,7 +188,7 @@ impl Guild { // FIXME: Horrible security pub async fn set_icon( &mut self, - bunny_cdn: &bunny_api_tokio::Client, + bunny_storage: &bunny_api_tokio::EdgeStorageClient, conn: &mut Conn, cdn_url: Url, icon: BytesMut, @@ -199,12 +199,12 @@ impl Guild { if let Some(icon) = &self.icon { let relative_url = icon.path().trim_start_matches('/'); - bunny_cdn.storage.delete(relative_url).await?; + bunny_storage.delete(relative_url).await?; } let path = format!("icons/{}/icon.{}", self.uuid, image_type); - bunny_cdn.storage.upload(path.clone(), icon.into()).await?; + bunny_storage.upload(path.clone(), icon.into()).await?; let icon_url = cdn_url.join(&path)?; diff --git a/src/objects/me.rs b/src/objects/me.rs index 6af5bce..e183c5d 100644 --- a/src/objects/me.rs +++ b/src/objects/me.rs @@ -85,13 +85,12 @@ impl Me { let relative_url = avatar_url.path().trim_start_matches('/'); - data.bunny_cdn.storage.delete(relative_url).await?; + data.bunny_storage.delete(relative_url).await?; } let path = format!("avatar/{}/avatar.{}", self.uuid, image_type); - data.bunny_cdn - .storage + data.bunny_storage .upload(path.clone(), avatar.into()) .await?;