From 8fcfef59d35820fb19c9386b88e6d65ee23e30d2 Mon Sep 17 00:00:00 2001 From: Radical Date: Fri, 2 May 2025 23:15:27 +0200 Subject: [PATCH 01/22] add test implementation for messaging --- src/api/mod.rs | 3 +++ src/api/v0/channel.rs | 61 +++++++++++++++++++++++++++++++++++++++++++ src/api/v0/mod.rs | 10 +++++++ src/api/v0/send.rs | 60 ++++++++++++++++++++++++++++++++++++++++++ src/api/v1/mod.rs | 2 +- src/main.rs | 6 +++++ 6 files changed, 141 insertions(+), 1 deletion(-) create mode 100644 src/api/v0/channel.rs create mode 100644 src/api/v0/mod.rs create mode 100644 src/api/v0/send.rs diff --git a/src/api/mod.rs b/src/api/mod.rs index 80dc442..14eb428 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -1,2 +1,5 @@ pub mod v1; pub mod versions; + +// This is purely for testing, will be deleted at a later date +pub mod v0; \ No newline at end of file diff --git a/src/api/v0/channel.rs b/src/api/v0/channel.rs new file mode 100644 index 0000000..eb25c66 --- /dev/null +++ b/src/api/v0/channel.rs @@ -0,0 +1,61 @@ +use actix_web::{Error, HttpResponse, error, post, web}; +use futures::StreamExt; +use log::error; +use serde::{Deserialize, Serialize}; +use sqlx::prelude::FromRow; + +use crate::{Data, api::v1::auth::check_access_token}; + +#[derive(Deserialize)] +struct Request { + access_token: String, + start: i32, + amount: i32, +} + +#[derive(Serialize, FromRow)] +struct Response { + uuid: String, + message: String, +} + +const MAX_SIZE: usize = 262_144; + +#[post("/channel")] +pub async fn res( + mut payload: web::Payload, + data: web::Data, +) -> Result { + let mut body = web::BytesMut::new(); + while let Some(chunk) = payload.next().await { + let chunk = chunk?; + // limit max size of in-memory payload + if (body.len() + chunk.len()) > MAX_SIZE { + return Err(error::ErrorBadRequest("overflow")); + } + body.extend_from_slice(&chunk); + } + + let request = serde_json::from_slice::(&body)?; + + let authorized = check_access_token(request.access_token, &data.pool).await; + + if let Err(error) = authorized { + return Ok(error); + } + + let row = sqlx::query_as("SELECT timestamp, (uuid AS VARCHAR), message FROM channel ORDERED BY timestamp DESC LIMIT $1 OFFSET $2") + .bind(request.amount) + .bind(request.start) + .fetch_all(&data.pool) + .await; + + if let Err(error) = row { + error!("{}", error); + return Ok(HttpResponse::InternalServerError().finish()); + } + + let messages: Vec = row.unwrap(); + + Ok(HttpResponse::Ok().json(messages)) +} diff --git a/src/api/v0/mod.rs b/src/api/v0/mod.rs new file mode 100644 index 0000000..310de36 --- /dev/null +++ b/src/api/v0/mod.rs @@ -0,0 +1,10 @@ +use actix_web::{Scope, web}; + +mod channel; +mod send; + +pub fn web() -> Scope { + web::scope("/v1") + .service(channel::res) + .service(send::res) +} diff --git a/src/api/v0/send.rs b/src/api/v0/send.rs new file mode 100644 index 0000000..640700f --- /dev/null +++ b/src/api/v0/send.rs @@ -0,0 +1,60 @@ +use std::time::{SystemTime, UNIX_EPOCH}; + +use actix_web::{Error, HttpResponse, error, post, web}; +use futures::StreamExt; +use log::error; +use serde::Deserialize; + +use crate::{Data, api::v1::auth::check_access_token}; + +#[derive(Deserialize)] +struct Request { + access_token: String, + message: String, +} + +const MAX_SIZE: usize = 262_144; + +#[post("/channel")] +pub async fn res( + mut payload: web::Payload, + data: web::Data, +) -> Result { + let mut body = web::BytesMut::new(); + while let Some(chunk) = payload.next().await { + let chunk = chunk?; + // limit max size of in-memory payload + if (body.len() + chunk.len()) > MAX_SIZE { + return Err(error::ErrorBadRequest("overflow")); + } + body.extend_from_slice(&chunk); + } + + let request = serde_json::from_slice::(&body)?; + + let authorized = check_access_token(request.access_token, &data.pool).await; + + if let Err(error) = authorized { + return Ok(error); + } + + let uuid = authorized.unwrap(); + + let current_time = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs() as i64; + + let row = sqlx::query(&format!("INSERT INTO channel (timestamp, uuid, message) VALUES ($1, '{}', $2)", uuid)) + .bind(current_time) + .bind(request.message) + .execute(&data.pool) + .await; + + if let Err(error) = row { + error!("{}", error); + return Ok(HttpResponse::InternalServerError().finish()); + } + + Ok(HttpResponse::Ok().finish()) +} diff --git a/src/api/v1/mod.rs b/src/api/v1/mod.rs index a5fd58a..d81db79 100644 --- a/src/api/v1/mod.rs +++ b/src/api/v1/mod.rs @@ -1,6 +1,6 @@ use actix_web::{Scope, web}; -mod auth; +pub mod auth; mod stats; mod users; diff --git a/src/main.rs b/src/main.rs index 4c909b1..9a3fc9b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -71,6 +71,11 @@ async fn main() -> Result<(), Error> { refresh_token varchar(64) UNIQUE NOT NULL REFERENCES refresh_tokens(token), uuid uuid NOT NULL REFERENCES users(uuid), created int8 NOT NULL + ); + CREATE TABLE IF NOT EXISTS channel ( + timestamp int8 PRIMARY KEY NOT NULL, + uuid uuid NOT NULL REFERENCES users(uuid), + message varchar(2000) NOT NULL, ) "#, ) @@ -90,6 +95,7 @@ async fn main() -> Result<(), Error> { .app_data(web::Data::new(data.clone())) .service(api::versions::res) .service(api::v1::web()) + .service(api::v0::web()) }) .bind((web.url, web.port))? .run() From 8766ca57aafc18a07d740ceb266eb022cbf627ef Mon Sep 17 00:00:00 2001 From: Radical Date: Fri, 2 May 2025 23:16:20 +0200 Subject: [PATCH 02/22] correct paths --- src/api/v0/mod.rs | 2 +- src/api/v0/send.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/api/v0/mod.rs b/src/api/v0/mod.rs index 310de36..99d2ab9 100644 --- a/src/api/v0/mod.rs +++ b/src/api/v0/mod.rs @@ -4,7 +4,7 @@ mod channel; mod send; pub fn web() -> Scope { - web::scope("/v1") + web::scope("/v0") .service(channel::res) .service(send::res) } diff --git a/src/api/v0/send.rs b/src/api/v0/send.rs index 640700f..ea5e353 100644 --- a/src/api/v0/send.rs +++ b/src/api/v0/send.rs @@ -15,7 +15,7 @@ struct Request { const MAX_SIZE: usize = 262_144; -#[post("/channel")] +#[post("/send")] pub async fn res( mut payload: web::Payload, data: web::Data, From 8b0efd16fe6ff69116b7d775d2c296d029d5864c Mon Sep 17 00:00:00 2001 From: Radical Date: Fri, 2 May 2025 23:23:40 +0200 Subject: [PATCH 03/22] fix sql request --- src/main.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main.rs b/src/main.rs index 9a3fc9b..ac7f116 100644 --- a/src/main.rs +++ b/src/main.rs @@ -75,7 +75,7 @@ async fn main() -> Result<(), Error> { CREATE TABLE IF NOT EXISTS channel ( timestamp int8 PRIMARY KEY NOT NULL, uuid uuid NOT NULL REFERENCES users(uuid), - message varchar(2000) NOT NULL, + message varchar(2000) NOT NULL ) "#, ) From 3369c6f0845e90c6f81d29ac41d56c60557330c8 Mon Sep 17 00:00:00 2001 From: Radical Date: Fri, 2 May 2025 23:31:45 +0200 Subject: [PATCH 04/22] fix response and sql request --- src/api/v0/channel.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/api/v0/channel.rs b/src/api/v0/channel.rs index eb25c66..b87f15b 100644 --- a/src/api/v0/channel.rs +++ b/src/api/v0/channel.rs @@ -15,6 +15,7 @@ struct Request { #[derive(Serialize, FromRow)] struct Response { + timestamp: i64, uuid: String, message: String, } @@ -44,7 +45,7 @@ pub async fn res( return Ok(error); } - let row = sqlx::query_as("SELECT timestamp, (uuid AS VARCHAR), message FROM channel ORDERED BY timestamp DESC LIMIT $1 OFFSET $2") + let row = sqlx::query_as("SELECT timestamp, CAST(uuid AS VARCHAR), message FROM channel ORDER BY timestamp DESC LIMIT $1 OFFSET $2") .bind(request.amount) .bind(request.start) .fetch_all(&data.pool) From b530de8f52429b10d7cee257df4e9fe131c6b1df Mon Sep 17 00:00:00 2001 From: SauceyRed Date: Sat, 3 May 2025 02:20:37 +0200 Subject: [PATCH 05/22] fix: username regex --- src/api/v1/auth/mod.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/api/v1/auth/mod.rs b/src/api/v1/auth/mod.rs index ff74c6b..64dd5f6 100644 --- a/src/api/v1/auth/mod.rs +++ b/src/api/v1/auth/mod.rs @@ -19,8 +19,7 @@ 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() }); -// FIXME: This regex doesnt seem to be working -static USERNAME_REGEX: LazyLock = LazyLock::new(|| Regex::new(r"[a-zA-Z0-9.-_]").unwrap()); +static USERNAME_REGEX: LazyLock = LazyLock::new(|| Regex::new(r"^[\w.-]+$").unwrap()); // Password is expected to be hashed using SHA3-384 static PASSWORD_REGEX: LazyLock = LazyLock::new(|| Regex::new(r"[0-9a-f]{96}").unwrap()); From e29940d080cf1ea9b97558cd0fc60c7ae28ca4bc Mon Sep 17 00:00:00 2001 From: SauceyRed Date: Sat, 3 May 2025 03:04:07 +0200 Subject: [PATCH 06/22] feat: only allow lowercase usernames --- src/api/v1/auth/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/api/v1/auth/mod.rs b/src/api/v1/auth/mod.rs index 64dd5f6..a9ee803 100644 --- a/src/api/v1/auth/mod.rs +++ b/src/api/v1/auth/mod.rs @@ -19,7 +19,7 @@ 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"^[\w.-]+$").unwrap()); +static USERNAME_REGEX: LazyLock = LazyLock::new(|| Regex::new(r"^[a-z_.-]+$").unwrap()); // Password is expected to be hashed using SHA3-384 static PASSWORD_REGEX: LazyLock = LazyLock::new(|| Regex::new(r"[0-9a-f]{96}").unwrap()); From aa865e2ed464a1bf8cfecc68a1a3b45230892ba6 Mon Sep 17 00:00:00 2001 From: Radical Date: Sun, 4 May 2025 18:11:12 +0200 Subject: [PATCH 07/22] feat: add utils.rs provides a function that extracts auth header from headers --- src/main.rs | 1 + src/utils.rs | 17 +++++++++++++++++ 2 files changed, 18 insertions(+) create mode 100644 src/utils.rs diff --git a/src/main.rs b/src/main.rs index 4c909b1..b3dfe86 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,6 +8,7 @@ mod config; use config::{Config, ConfigBuilder}; mod api; pub mod crypto; +pub mod utils; type Error = Box; diff --git a/src/utils.rs b/src/utils.rs new file mode 100644 index 0000000..3b02593 --- /dev/null +++ b/src/utils.rs @@ -0,0 +1,17 @@ +use actix_web::{HttpResponse, http::header::HeaderMap}; + +pub fn get_auth_header(headers: &HeaderMap) -> Result<&str, HttpResponse> { + let auth_token = headers.get(actix_web::http::header::CONTENT_TYPE); + + if let None = auth_token { + return Err(HttpResponse::Unauthorized().finish()); + } + + let auth = auth_token.unwrap().to_str(); + + if let Err(error) = auth { + return Err(HttpResponse::Unauthorized().json(format!(r#" {{ "error": "{}" }} "#, error))); + } + + Ok(auth.unwrap()) +} From 6c706d973edb854f92bb8fcc984cd2be7c87a89c Mon Sep 17 00:00:00 2001 From: Radical Date: Sun, 4 May 2025 19:05:31 +0200 Subject: [PATCH 08/22] style: use created_at instead of created --- src/api/v1/auth/login.rs | 4 ++-- src/api/v1/auth/register.rs | 4 ++-- src/main.rs | 6 +++--- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/api/v1/auth/login.rs b/src/api/v1/auth/login.rs index 3be5474..1bd781f 100644 --- a/src/api/v1/auth/login.rs +++ b/src/api/v1/auth/login.rs @@ -160,7 +160,7 @@ async fn login( .as_secs() as i64; if let Err(error) = sqlx::query(&format!( - "INSERT INTO refresh_tokens (token, uuid, created, device_name) VALUES ($1, '{}', $2, $3 )", + "INSERT INTO refresh_tokens (token, uuid, created_at, device_name) VALUES ($1, '{}', $2, $3 )", uuid )) .bind(&refresh_token) @@ -174,7 +174,7 @@ async fn login( } if let Err(error) = sqlx::query(&format!( - "INSERT INTO access_tokens (token, refresh_token, uuid, created) VALUES ($1, $2, '{}', $3 )", + "INSERT INTO access_tokens (token, refresh_token, uuid, created_at) VALUES ($1, $2, '{}', $3 )", uuid )) .bind(&access_token) diff --git a/src/api/v1/auth/register.rs b/src/api/v1/auth/register.rs index 5abe127..3971585 100644 --- a/src/api/v1/auth/register.rs +++ b/src/api/v1/auth/register.rs @@ -139,7 +139,7 @@ pub async fn res(mut payload: web::Payload, data: web::Data) -> Result) -> Result Result<(), Error> { CREATE TABLE IF NOT EXISTS refresh_tokens ( token varchar(64) PRIMARY KEY UNIQUE NOT NULL, uuid uuid NOT NULL REFERENCES users(uuid), - created int8 NOT NULL, + 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), + 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 int8 NOT NULL + created_at int8 NOT NULL ) "#, ) From cbf0131d14d975e50011e6c8a94f8fe02cf128e3 Mon Sep 17 00:00:00 2001 From: Radical Date: Sun, 4 May 2025 19:05:51 +0200 Subject: [PATCH 09/22] feat: switch to headers for auth --- src/api/v1/auth/mod.rs | 27 ++++++++++--------- src/api/v1/auth/refresh.rs | 54 +++++++++++--------------------------- src/api/v1/auth/revoke.rs | 51 +++++++++++++++-------------------- src/api/v1/users/me.rs | 37 ++++++++------------------ src/api/v1/users/mod.rs | 50 +++++++++++++++-------------------- src/api/v1/users/uuid.rs | 36 +++++++++---------------- 6 files changed, 96 insertions(+), 159 deletions(-) diff --git a/src/api/v1/auth/mod.rs b/src/api/v1/auth/mod.rs index ff74c6b..e9b29d1 100644 --- a/src/api/v1/auth/mod.rs +++ b/src/api/v1/auth/mod.rs @@ -34,33 +34,36 @@ pub fn web() -> Scope { } pub async fn check_access_token( - access_token: String, + access_token: &str, pool: &sqlx::Pool, ) -> Result { - let row = sqlx::query_as( - "SELECT CAST(uuid as VARCHAR), created FROM access_tokens WHERE token = $1", - ) - .bind(&access_token) - .fetch_one(pool) - .await; + 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()) + 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" }"#)) + return Err(HttpResponse::InternalServerError().json( + r#"{ "error": "Unhandled exception occured, contact the server administrator" }"#, + )); } - let (uuid, created): (String, i64) = row.unwrap(); + let (uuid, created_at): (String, i64) = row.unwrap(); let current_time = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap() .as_secs() as i64; - let lifetime = current_time - created; + let lifetime = current_time - created_at; if lifetime > 3600 { return Err(HttpResponse::Unauthorized().finish()); diff --git a/src/api/v1/auth/refresh.rs b/src/api/v1/auth/refresh.rs index 5ac2402..e17ecc7 100644 --- a/src/api/v1/auth/refresh.rs +++ b/src/api/v1/auth/refresh.rs @@ -1,7 +1,6 @@ -use actix_web::{Error, HttpResponse, error, post, web}; -use futures::StreamExt; +use actix_web::{post, web, Error, HttpRequest, HttpResponse}; use log::error; -use serde::{Deserialize, Serialize}; +use serde::Serialize; use std::time::{SystemTime, UNIX_EPOCH}; use crate::{ @@ -9,32 +8,21 @@ use crate::{ crypto::{generate_access_token, generate_refresh_token}, }; -#[derive(Deserialize)] -struct RefreshRequest { - refresh_token: String, -} - #[derive(Serialize)] struct Response { refresh_token: String, access_token: String, } -const MAX_SIZE: usize = 262_144; - #[post("/refresh")] -pub async fn res(mut payload: web::Payload, data: web::Data) -> Result { - let mut body = web::BytesMut::new(); - while let Some(chunk) = payload.next().await { - let chunk = chunk?; - // limit max size of in-memory payload - if (body.len() + chunk.len()) > MAX_SIZE { - return Err(error::ErrorBadRequest("overflow")); - } - body.extend_from_slice(&chunk); +pub async fn res(req: HttpRequest, data: web::Data) -> Result { + let refresh_token_cookie = req.cookie("refresh_token"); + + if let None = refresh_token_cookie { + return Ok(HttpResponse::Unauthorized().finish()) } - let refresh_request = serde_json::from_slice::(&body)?; + let mut refresh_token = String::from(refresh_token_cookie.unwrap().value()); let current_time = SystemTime::now() .duration_since(UNIX_EPOCH) @@ -42,26 +30,18 @@ pub async fn res(mut payload: web::Payload, data: web::Data) -> Result 2592000 { if let Err(error) = sqlx::query("DELETE FROM refresh_tokens WHERE token = $1") - .bind(&refresh_request.refresh_token) + .bind(&refresh_token) .execute(&data.pool) .await { @@ -76,8 +56,6 @@ pub async fn res(mut payload: web::Payload, data: web::Data) -> Result 1987200 { let new_refresh_token = generate_refresh_token(); @@ -88,7 +66,7 @@ pub async fn res(mut payload: web::Payload, data: web::Data) -> Result) -> Result) -> Result { +pub async fn res( + req: HttpRequest, + 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 mut body = web::BytesMut::new(); while let Some(chunk) = payload.next().await { let chunk = chunk?; @@ -40,7 +51,7 @@ pub async fn res(mut payload: web::Payload, data: web::Data) -> Result(&body)?; - let authorized = check_access_token(revoke_request.access_token, &data.pool).await; + let authorized = check_access_token(auth_header.unwrap(), &data.pool).await; if let Err(error) = authorized { return Ok(error); @@ -94,16 +105,9 @@ pub async fn res(mut payload: web::Payload, data: web::Data) -> Result = tokens_raw.unwrap(); - let mut access_tokens_delete = vec![]; let mut refresh_tokens_delete = vec![]; for token in tokens { - access_tokens_delete.push( - sqlx::query("DELETE FROM access_tokens WHERE refresh_token = $1") - .bind(token.clone()) - .execute(&data.pool), - ); - refresh_tokens_delete.push( sqlx::query("DELETE FROM refresh_tokens WHERE token = $1") .bind(token.clone()) @@ -111,29 +115,16 @@ pub async fn res(mut payload: web::Payload, data: web::Data) -> Result> = - results_access_tokens - .iter() - .filter(|r| r.is_err()) - .collect(); - let refresh_tokens_errors: Vec<&Result> = - results_refresh_tokens + let errors: Vec<&Result> = + results .iter() .filter(|r| r.is_err()) .collect(); - if !access_tokens_errors.is_empty() && !refresh_tokens_errors.is_empty() { - error!("{:?}", access_tokens_errors); - error!("{:?}", refresh_tokens_errors); - return Ok(HttpResponse::InternalServerError().finish()); - } else if !access_tokens_errors.is_empty() { - error!("{:?}", access_tokens_errors); - return Ok(HttpResponse::InternalServerError().finish()); - } else if !refresh_tokens_errors.is_empty() { - error!("{:?}", refresh_tokens_errors); + if !errors.is_empty() { + error!("{:?}", errors); return Ok(HttpResponse::InternalServerError().finish()); } diff --git a/src/api/v1/users/me.rs b/src/api/v1/users/me.rs index 18e6ba8..f641678 100644 --- a/src/api/v1/users/me.rs +++ b/src/api/v1/users/me.rs @@ -1,14 +1,8 @@ -use actix_web::{Error, HttpResponse, error, post, web}; -use futures::StreamExt; +use actix_web::{Error, HttpRequest, HttpResponse, get, web}; use log::error; -use serde::{Deserialize, Serialize}; +use serde::Serialize; -use crate::{Data, api::v1::auth::check_access_token}; - -#[derive(Deserialize)] -struct AuthenticationRequest { - access_token: String, -} +use crate::{Data, api::v1::auth::check_access_token, utils::get_auth_header}; #[derive(Serialize)] struct Response { @@ -17,26 +11,17 @@ struct Response { display_name: String, } -const MAX_SIZE: usize = 262_144; +#[get("/me")] +pub async fn res(req: HttpRequest, data: web::Data) -> Result { + let headers = req.headers(); -#[post("/me")] -pub async fn res( - mut payload: web::Payload, - data: web::Data, -) -> Result { - let mut body = web::BytesMut::new(); - while let Some(chunk) = payload.next().await { - let chunk = chunk?; - // limit max size of in-memory payload - if (body.len() + chunk.len()) > MAX_SIZE { - return Err(error::ErrorBadRequest("overflow")); - } - body.extend_from_slice(&chunk); + let auth_header = get_auth_header(headers); + + if let Err(error) = auth_header { + return Ok(error); } - let authentication_request = serde_json::from_slice::(&body)?; - - let authorized = check_access_token(authentication_request.access_token, &data.pool).await; + let authorized = check_access_token(auth_header.unwrap(), &data.pool).await; if let Err(error) = authorized { return Ok(error); diff --git a/src/api/v1/users/mod.rs b/src/api/v1/users/mod.rs index 937d857..d7cb1c6 100644 --- a/src/api/v1/users/mod.rs +++ b/src/api/v1/users/mod.rs @@ -1,18 +1,16 @@ -use actix_web::{error, post, web, Error, HttpResponse, Scope}; -use futures::StreamExt; +use crate::{Data, api::v1::auth::check_access_token, utils::get_auth_header}; +use actix_web::{get, web, Error, HttpRequest, HttpResponse, Scope}; use log::error; use serde::{Deserialize, Serialize}; use sqlx::prelude::FromRow; -use crate::{Data, api::v1::auth::check_access_token}; mod me; mod uuid; #[derive(Deserialize)] -struct Request { - access_token: String, - start: i32, - amount: i32, +struct RequestQuery { + start: Option, + amount: Option, } #[derive(Serialize, FromRow)] @@ -23,8 +21,6 @@ struct Response { email: String, } -const MAX_SIZE: usize = 262_144; - pub fn web() -> Scope { web::scope("/users") .service(res) @@ -32,36 +28,33 @@ pub fn web() -> Scope { .service(uuid::res) } -#[post("")] +#[get("")] pub async fn res( - mut payload: web::Payload, + req: HttpRequest, + request_query: web::Query, data: web::Data, ) -> Result { - let mut body = web::BytesMut::new(); - while let Some(chunk) = payload.next().await { - let chunk = chunk?; - // limit max size of in-memory payload - if (body.len() + chunk.len()) > MAX_SIZE { - return Err(error::ErrorBadRequest("overflow")); - } - body.extend_from_slice(&chunk); + let headers = req.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 amount > 100 { + return Ok(HttpResponse::BadRequest().finish()); } - let request = serde_json::from_slice::(&body)?; - - if request.amount > 100 { - return Ok(HttpResponse::BadRequest().finish()) - } - - let authorized = check_access_token(request.access_token, &data.pool).await; + let authorized = check_access_token(auth_header.unwrap(), &data.pool).await; if let Err(error) = authorized { 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(request.amount) - .bind(request.start) + .bind(amount) + .bind(start) .fetch_all(&data.pool) .await; @@ -74,4 +67,3 @@ pub async fn res( Ok(HttpResponse::Ok().json(accounts)) } - diff --git a/src/api/v1/users/uuid.rs b/src/api/v1/users/uuid.rs index 41d87cc..f4c1f13 100644 --- a/src/api/v1/users/uuid.rs +++ b/src/api/v1/users/uuid.rs @@ -1,15 +1,9 @@ -use actix_web::{Error, HttpResponse, error, post, web}; -use futures::StreamExt; +use actix_web::{Error, HttpRequest, HttpResponse, get, web}; use log::error; -use serde::{Deserialize, Serialize}; +use serde::Serialize; use uuid::Uuid; -use crate::{Data, api::v1::auth::check_access_token}; - -#[derive(Deserialize)] -struct AuthenticationRequest { - access_token: String, -} +use crate::{Data, api::v1::auth::check_access_token, utils::get_auth_header}; #[derive(Serialize)] struct Response { @@ -18,29 +12,23 @@ struct Response { display_name: String, } -const MAX_SIZE: usize = 262_144; - -#[post("/{uuid}")] +#[get("/{uuid}")] pub async fn res( - mut payload: web::Payload, + req: HttpRequest, path: web::Path<(Uuid,)>, data: web::Data, ) -> Result { - let mut body = web::BytesMut::new(); - while let Some(chunk) = payload.next().await { - let chunk = chunk?; - // limit max size of in-memory payload - if (body.len() + chunk.len()) > MAX_SIZE { - return Err(error::ErrorBadRequest("overflow")); - } - body.extend_from_slice(&chunk); - } + let headers = req.headers(); let uuid = path.into_inner().0; - let authentication_request = serde_json::from_slice::(&body)?; + let auth_header = get_auth_header(headers); - let authorized = check_access_token(authentication_request.access_token, &data.pool).await; + 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); From a3846a26200625058669afc69785726ad3e70a3b Mon Sep 17 00:00:00 2001 From: Radical Date: Sun, 4 May 2025 20:30:28 +0200 Subject: [PATCH 10/22] fix: use correct header --- src/utils.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils.rs b/src/utils.rs index 3b02593..56b43fa 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,7 +1,7 @@ use actix_web::{HttpResponse, http::header::HeaderMap}; pub fn get_auth_header(headers: &HeaderMap) -> Result<&str, HttpResponse> { - let auth_token = headers.get(actix_web::http::header::CONTENT_TYPE); + let auth_token = headers.get(actix_web::http::header::AUTHORIZATION); if let None = auth_token { return Err(HttpResponse::Unauthorized().finish()); From f12f81d584982144a94db396450e2cdbe938d8a5 Mon Sep 17 00:00:00 2001 From: Radical Date: Sun, 4 May 2025 21:30:33 +0200 Subject: [PATCH 11/22] fix: extract auth value --- src/utils.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/utils.rs b/src/utils.rs index 56b43fa..d80bed4 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -13,5 +13,11 @@ pub fn get_auth_header(headers: &HeaderMap) -> Result<&str, HttpResponse> { return Err(HttpResponse::Unauthorized().json(format!(r#" {{ "error": "{}" }} "#, error))); } - Ok(auth.unwrap()) + let auth_value = auth.unwrap().split_whitespace().nth(1); + + if let None = auth_value { + return Err(HttpResponse::BadRequest().finish()); + } + + Ok(auth_value.unwrap()) } From ebb4286c088158ec4235c5e92dfeaef177131cdb Mon Sep 17 00:00:00 2001 From: Radical Date: Sun, 4 May 2025 22:13:05 +0200 Subject: [PATCH 12/22] refactor: move api to /api serve api under /api --- src/api/mod.rs | 13 +++++++++++-- src/main.rs | 3 +-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/api/mod.rs b/src/api/mod.rs index 80dc442..b79c824 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -1,2 +1,11 @@ -pub mod v1; -pub mod versions; +use actix_web::Scope; +use actix_web::web; + +mod v1; +mod versions; + +pub fn web() -> Scope { + web::scope("/api") + .service(v1::web()) + .service(versions::res) +} diff --git a/src/main.rs b/src/main.rs index fbde53b..e967021 100644 --- a/src/main.rs +++ b/src/main.rs @@ -89,8 +89,7 @@ async fn main() -> Result<(), Error> { HttpServer::new(move || { App::new() .app_data(web::Data::new(data.clone())) - .service(api::versions::res) - .service(api::v1::web()) + .service(api::web()) }) .bind((web.url, web.port))? .run() From 0f897dc0c6235d57776ee85aaeee028730273d22 Mon Sep 17 00:00:00 2001 From: Radical Date: Sun, 4 May 2025 22:13:28 +0200 Subject: [PATCH 13/22] feat: return refresh_token in cookie --- src/api/v1/auth/login.rs | 19 ++++++------------- src/api/v1/auth/mod.rs | 6 ++++++ src/api/v1/auth/refresh.rs | 19 ++++++------------- src/api/v1/auth/register.rs | 9 +++------ src/utils.rs | 12 +++++++++++- 5 files changed, 32 insertions(+), 33 deletions(-) diff --git a/src/api/v1/auth/login.rs b/src/api/v1/auth/login.rs index 1bd781f..0ea3d83 100644 --- a/src/api/v1/auth/login.rs +++ b/src/api/v1/auth/login.rs @@ -1,17 +1,17 @@ use std::time::{SystemTime, UNIX_EPOCH}; -use actix_web::{Error, HttpResponse, error, post, web}; +use actix_web::{error, post, web, Error, HttpResponse}; use argon2::{PasswordHash, PasswordVerifier}; use futures::StreamExt; use log::error; -use serde::{Deserialize, Serialize}; +use serde::Deserialize; use crate::{ - Data, - api::v1::auth::{EMAIL_REGEX, PASSWORD_REGEX, USERNAME_REGEX}, - crypto::{generate_access_token, generate_refresh_token}, + api::v1::auth::{EMAIL_REGEX, PASSWORD_REGEX, USERNAME_REGEX}, crypto::{generate_access_token, generate_refresh_token}, utils::refresh_token_cookie, Data }; +use super::Response; + #[derive(Deserialize)] struct LoginInformation { username: String, @@ -19,12 +19,6 @@ struct LoginInformation { device_name: String, } -#[derive(Serialize)] -pub struct Response { - pub access_token: String, - pub refresh_token: String, -} - const MAX_SIZE: usize = 262_144; #[post("/login")] @@ -187,8 +181,7 @@ async fn login( return HttpResponse::InternalServerError().finish() } - HttpResponse::Ok().json(Response { + HttpResponse::Ok().cookie(refresh_token_cookie(refresh_token)).json(Response { access_token, - refresh_token, }) } diff --git a/src/api/v1/auth/mod.rs b/src/api/v1/auth/mod.rs index e9b29d1..bfd32af 100644 --- a/src/api/v1/auth/mod.rs +++ b/src/api/v1/auth/mod.rs @@ -7,6 +7,7 @@ use std::{ use actix_web::{HttpResponse, Scope, web}; use log::error; use regex::Regex; +use serde::Serialize; use sqlx::Postgres; use uuid::Uuid; @@ -15,6 +16,11 @@ mod refresh; mod register; mod revoke; +#[derive(Serialize)] +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() }); diff --git a/src/api/v1/auth/refresh.rs b/src/api/v1/auth/refresh.rs index e17ecc7..7e331a2 100644 --- a/src/api/v1/auth/refresh.rs +++ b/src/api/v1/auth/refresh.rs @@ -1,28 +1,22 @@ use actix_web::{post, web, Error, HttpRequest, HttpResponse}; use log::error; -use serde::Serialize; use std::time::{SystemTime, UNIX_EPOCH}; use crate::{ - Data, - crypto::{generate_access_token, generate_refresh_token}, + crypto::{generate_access_token, generate_refresh_token}, utils::refresh_token_cookie, Data }; -#[derive(Serialize)] -struct Response { - refresh_token: String, - access_token: String, -} +use super::Response; #[post("/refresh")] pub async fn res(req: HttpRequest, data: web::Data) -> Result { - let refresh_token_cookie = req.cookie("refresh_token"); + let recv_refresh_token_cookie = req.cookie("refresh_token"); - if let None = refresh_token_cookie { + if let None = recv_refresh_token_cookie { return Ok(HttpResponse::Unauthorized().finish()) } - let mut refresh_token = String::from(refresh_token_cookie.unwrap().value()); + let mut refresh_token = String::from(recv_refresh_token_cookie.unwrap().value()); let current_time = SystemTime::now() .duration_since(UNIX_EPOCH) @@ -101,8 +95,7 @@ pub async fn res(req: HttpRequest, data: web::Data) -> Result) -> Result { diff --git a/src/utils.rs b/src/utils.rs index d80bed4..b432d19 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,4 +1,4 @@ -use actix_web::{HttpResponse, http::header::HeaderMap}; +use actix_web::{cookie::{time::Duration, Cookie, SameSite}, http::header::HeaderMap, HttpResponse}; pub fn get_auth_header(headers: &HeaderMap) -> Result<&str, HttpResponse> { let auth_token = headers.get(actix_web::http::header::AUTHORIZATION); @@ -21,3 +21,13 @@ pub fn get_auth_header(headers: &HeaderMap) -> Result<&str, HttpResponse> { Ok(auth_value.unwrap()) } + +pub fn refresh_token_cookie(refresh_token: String) -> Cookie<'static> { + Cookie::build("refresh_token", refresh_token) + .http_only(true) + .secure(true) + .same_site(SameSite::None) + .path("/api") + .max_age(Duration::days(30)) + .finish() +} From c61f96ffe7b516643e8e31bc5c39bd71021a698d Mon Sep 17 00:00:00 2001 From: Radical Date: Sun, 4 May 2025 23:02:17 +0200 Subject: [PATCH 14/22] feat: expire refresh_token immediately on unauthorized response --- src/api/v1/auth/refresh.rs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/api/v1/auth/refresh.rs b/src/api/v1/auth/refresh.rs index 7e331a2..8c3e7d0 100644 --- a/src/api/v1/auth/refresh.rs +++ b/src/api/v1/auth/refresh.rs @@ -42,7 +42,11 @@ pub async fn res(req: HttpRequest, data: web::Data) -> Result) -> Result Date: Sun, 4 May 2025 23:25:48 +0200 Subject: [PATCH 15/22] fix: add numbers to username regex --- src/api/v1/auth/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/api/v1/auth/mod.rs b/src/api/v1/auth/mod.rs index a9ee803..69f3fe3 100644 --- a/src/api/v1/auth/mod.rs +++ b/src/api/v1/auth/mod.rs @@ -19,7 +19,7 @@ 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-z_.-]+$").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()); From 77245e98c51e67a24e94400219cf9b39cfcdae54 Mon Sep 17 00:00:00 2001 From: Radical Date: Sun, 4 May 2025 23:50:38 +0200 Subject: [PATCH 16/22] refactor: combine crypto.rs with utils.rs --- src/api/v1/auth/login.rs | 2 +- src/api/v1/auth/refresh.rs | 2 +- src/api/v1/auth/register.rs | 2 +- src/crypto.rs | 14 -------------- src/main.rs | 2 +- src/utils.rs | 17 ++++++++++++++++- 6 files changed, 20 insertions(+), 19 deletions(-) delete mode 100644 src/crypto.rs diff --git a/src/api/v1/auth/login.rs b/src/api/v1/auth/login.rs index 0ea3d83..bc6af8c 100644 --- a/src/api/v1/auth/login.rs +++ b/src/api/v1/auth/login.rs @@ -7,7 +7,7 @@ use log::error; use serde::Deserialize; use crate::{ - api::v1::auth::{EMAIL_REGEX, PASSWORD_REGEX, USERNAME_REGEX}, crypto::{generate_access_token, generate_refresh_token}, utils::refresh_token_cookie, Data + api::v1::auth::{EMAIL_REGEX, PASSWORD_REGEX, USERNAME_REGEX}, utils::{generate_access_token, generate_refresh_token, refresh_token_cookie}, Data }; use super::Response; diff --git a/src/api/v1/auth/refresh.rs b/src/api/v1/auth/refresh.rs index 8c3e7d0..008420b 100644 --- a/src/api/v1/auth/refresh.rs +++ b/src/api/v1/auth/refresh.rs @@ -3,7 +3,7 @@ use log::error; use std::time::{SystemTime, UNIX_EPOCH}; use crate::{ - crypto::{generate_access_token, generate_refresh_token}, utils::refresh_token_cookie, Data + utils::{generate_access_token, generate_refresh_token, refresh_token_cookie}, Data }; use super::Response; diff --git a/src/api/v1/auth/register.rs b/src/api/v1/auth/register.rs index 7bbbbae..a56dd0e 100644 --- a/src/api/v1/auth/register.rs +++ b/src/api/v1/auth/register.rs @@ -12,7 +12,7 @@ use uuid::Uuid; use super::Response; use crate::{ - api::v1::auth::{EMAIL_REGEX, PASSWORD_REGEX, USERNAME_REGEX}, crypto::{generate_access_token, generate_refresh_token}, utils::refresh_token_cookie, Data + api::v1::auth::{EMAIL_REGEX, PASSWORD_REGEX, USERNAME_REGEX}, utils::{generate_access_token, generate_refresh_token, refresh_token_cookie}, Data }; #[derive(Deserialize)] diff --git a/src/crypto.rs b/src/crypto.rs deleted file mode 100644 index c4d96c8..0000000 --- a/src/crypto.rs +++ /dev/null @@ -1,14 +0,0 @@ -use getrandom::fill; -use hex::encode; - -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]; - fill(&mut buf)?; - Ok(encode(buf)) -} diff --git a/src/main.rs b/src/main.rs index e967021..36fa6ba 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,7 +7,7 @@ use std::time::SystemTime; mod config; use config::{Config, ConfigBuilder}; mod api; -pub mod crypto; + pub mod utils; type Error = Box; diff --git a/src/utils.rs b/src/utils.rs index b432d19..6571fab 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,4 +1,6 @@ use actix_web::{cookie::{time::Duration, Cookie, SameSite}, http::header::HeaderMap, HttpResponse}; +use getrandom::fill; +use hex::encode; pub fn get_auth_header(headers: &HeaderMap) -> Result<&str, HttpResponse> { let auth_token = headers.get(actix_web::http::header::AUTHORIZATION); @@ -30,4 +32,17 @@ pub fn refresh_token_cookie(refresh_token: String) -> Cookie<'static> { .path("/api") .max_age(Duration::days(30)) .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]; + fill(&mut buf)?; + Ok(encode(buf)) +} + From c0f2948b760029314b87b94fc920852365edadac Mon Sep 17 00:00:00 2001 From: SauceyRed Date: Tue, 6 May 2025 00:41:23 +0200 Subject: [PATCH 17/22] feat: implement cors --- Cargo.toml | 1 + src/main.rs | 30 ++++++++++++++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/Cargo.toml b/Cargo.toml index e34d9b6..aca7977 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ lto = true codegen-units = 1 [dependencies] +actix-cors = "0.7.1" actix-web = "4.10" argon2 = { version = "0.5.3", features = ["std"] } clap = { version = "4.5.37", features = ["derive"] } diff --git a/src/main.rs b/src/main.rs index 36fa6ba..48b1c4a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,4 @@ +use actix_cors::Cors; use actix_web::{App, HttpServer, web}; use argon2::Argon2; use clap::Parser; @@ -86,9 +87,38 @@ async fn main() -> Result<(), Error> { start_time: SystemTime::now(), }; + HttpServer::new(move || { + // Set CORS headers + let cors = Cors::default() + /* + Set Allowed-Control-Allow-Origin header to whatever + the request's Origin header is. Must be done like this + rather than setting it to "*" due to CORS not allowing + sending of credentials (cookies) with wildcard origin. + */ + .allowed_origin_fn(|_origin, _req_head| { + true + }) + /* + Allows any request method in CORS preflight requests. + This will be restricted to only ones actually in use later. + */ + .allow_any_method() + /* + Allows any header(s) in request in CORS preflight requests. + This wll be restricted to only ones actually in use later. + */ + .allow_any_header() + /* + Allows browser to include cookies in requests. + This is needed for receiving the secure HttpOnly refresh_token cookie. + */ + .supports_credentials(); + App::new() .app_data(web::Data::new(data.clone())) + .wrap(cors) .service(api::web()) }) .bind((web.url, web.port))? From 7ecc8c4270eb4a477f18630b1542e062e2026378 Mon Sep 17 00:00:00 2001 From: Radical Date: Wed, 7 May 2025 20:32:32 +0200 Subject: [PATCH 18/22] feat: add redis caching --- Cargo.toml | 1 + src/config.rs | 42 ++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 7 ++++++- src/utils.rs | 27 +++++++++++++++++++++++++++ 4 files changed, 76 insertions(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index aca7977..4e2f58d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,7 @@ 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.30", features= ["tokio-comp"] } toml = "0.8" url = { version = "2.5", features = ["serde"] } uuid = { version = "1.16", features = ["serde", "v7"] } diff --git a/src/config.rs b/src/config.rs index a2a6192..65a5965 100644 --- a/src/config.rs +++ b/src/config.rs @@ -7,6 +7,7 @@ use tokio::fs::read_to_string; #[derive(Debug, Deserialize)] pub struct ConfigBuilder { database: Database, + cache_database: CacheDatabase, web: Option, } @@ -19,6 +20,15 @@ pub struct Database { port: u16, } +#[derive(Debug, Deserialize, Clone)] +pub struct CacheDatabase { + username: Option, + password: Option, + host: String, + database: Option, + port: u16, +} + #[derive(Debug, Deserialize)] struct WebBuilder { url: Option, @@ -51,6 +61,7 @@ impl ConfigBuilder { Config { database: self.database, + cache_database: self.cache_database, web, } } @@ -59,6 +70,7 @@ impl ConfigBuilder { #[derive(Debug, Clone)] pub struct Config { pub database: Database, + pub cache_database: CacheDatabase, pub web: Web, } @@ -78,3 +90,33 @@ impl Database { .port(self.port) } } + +impl CacheDatabase { + pub fn url(&self) -> String { + let mut url = String::from("redis://"); + + if let Some(username) = &self.username { + url += username; + } + + if let Some(password) = &self.password { + url += ":"; + url += password; + } + + if self.username.is_some() || self.password.is_some() { + url += "@"; + } + + url += &self.host; + url += ":"; + url += &self.port.to_string(); + + if let Some(database) = &self.database { + url += "/"; + url += database; + } + + url + } +} diff --git a/src/main.rs b/src/main.rs index 48b1c4a..c93e760 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,9 +2,10 @@ use actix_cors::Cors; use actix_web::{App, HttpServer, web}; use argon2::Argon2; use clap::Parser; +use redis::aio::MultiplexedConnection; use simple_logger::SimpleLogger; use sqlx::{PgPool, Pool, Postgres}; -use std::time::SystemTime; +use std::{cell::Cell, time::SystemTime}; mod config; use config::{Config, ConfigBuilder}; mod api; @@ -23,6 +24,7 @@ struct Args { #[derive(Clone)] struct Data { pub pool: Pool, + pub cache_pool: redis::Client, pub _config: Config, pub argon2: Argon2<'static>, pub start_time: SystemTime, @@ -44,6 +46,8 @@ async fn main() -> Result<(), Error> { let pool = PgPool::connect_with(config.database.connect_options()).await?; + let cache_pool = redis::Client::open(config.cache_database.url())?; + /* 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" @@ -81,6 +85,7 @@ async fn main() -> Result<(), Error> { let data = Data { pool, + cache_pool, _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(), diff --git a/src/utils.rs b/src/utils.rs index 6571fab..5d6b51c 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,6 +1,11 @@ use actix_web::{cookie::{time::Duration, Cookie, SameSite}, http::header::HeaderMap, HttpResponse}; use getrandom::fill; use hex::encode; +use redis::{AsyncCommands, RedisError}; +use serde::Serialize; +use serde_json::json; + +use crate::Data; pub fn get_auth_header(headers: &HeaderMap) -> Result<&str, HttpResponse> { let auth_token = headers.get(actix_web::http::header::AUTHORIZATION); @@ -46,3 +51,25 @@ pub fn generate_refresh_token() -> Result { Ok(encode(buf)) } +impl Data { + pub async fn set_cache_key(&self, key: String, value: impl Serialize, expire: u32) -> Result<(), RedisError> { + let mut conn = self.cache_pool.get_multiplexed_tokio_connection().await?; + + let key_encoded = encode(key); + + let value_json = json!(value).to_string(); + + redis::cmd("SET",).arg(&[key_encoded.clone(), value_json]).exec_async(&mut conn).await?; + + redis::cmd("EXPIRE").arg(&[key_encoded, expire.to_string()]).exec_async(&mut conn).await + } + + pub async fn get_cache_key(&self, key: String) -> Result { + let mut conn = self.cache_pool.get_multiplexed_tokio_connection().await?; + + let key_encoded = encode(key); + + redis::cmd("GET").arg(key_encoded).query_async(&mut conn).await + } +} + From 529ccd1b518fb73c5cc619e873fa39f11a905e02 Mon Sep 17 00:00:00 2001 From: Radical Date: Wed, 7 May 2025 20:33:23 +0200 Subject: [PATCH 19/22] feat: use caching on user lookup this needs to be deleted/expired on user update, we'll implement this when we get ways to "update" things like channels, servers and users --- src/api/v1/users/uuid.rs | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/src/api/v1/users/uuid.rs b/src/api/v1/users/uuid.rs index f4c1f13..577af0e 100644 --- a/src/api/v1/users/uuid.rs +++ b/src/api/v1/users/uuid.rs @@ -1,11 +1,12 @@ use actix_web::{Error, HttpRequest, HttpResponse, get, web}; use log::error; use serde::Serialize; +use tokio::sync::Mutex; use uuid::Uuid; use crate::{Data, api::v1::auth::check_access_token, utils::get_auth_header}; -#[derive(Serialize)] +#[derive(Serialize, Clone)] struct Response { uuid: String, username: String, @@ -34,6 +35,12 @@ pub async fn res( return Ok(error); } + let cache_result = data.get_cache_key(uuid.to_string()).await; + + if let Ok(cache_hit) = cache_result { + return Ok(HttpResponse::Ok().json(cache_hit)) + } + let row = sqlx::query_as(&format!( "SELECT username, display_name FROM users WHERE uuid = '{}'", uuid @@ -48,9 +55,18 @@ pub async fn res( let (username, display_name): (String, Option) = row.unwrap(); - Ok(HttpResponse::Ok().json(Response { + let user = Response { uuid: uuid.to_string(), username, display_name: display_name.unwrap_or_default(), - })) + }; + + let cache_result = data.set_cache_key(uuid.to_string(), user.clone(), 1800).await; + + if let Err(error) = cache_result { + error!("{}", error); + return Ok(HttpResponse::InternalServerError().finish()); + } + + Ok(HttpResponse::Ok().json(user)) } From 3e64a49338626bb782b33567fc3d3d30dfbfede7 Mon Sep 17 00:00:00 2001 From: Radical Date: Wed, 7 May 2025 20:57:01 +0200 Subject: [PATCH 20/22] chore: add valkey configuration to docker --- Dockerfile | 8 +++++++- compose.dev.yml | 5 +++++ compose.yml | 5 +++++ entrypoint.sh | 4 ++++ 4 files changed, 21 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 7867f8b..d9a0389 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,6 +18,12 @@ RUN useradd --create-home --home-dir /gorb gorb USER gorb -ENV DATABASE_USERNAME="gorb" DATABASE_PASSWORD="gorb" DATABASE="gorb" DATABASE_HOST="localhost" DATABASE_PORT="5432" +ENV DATABASE_USERNAME="gorb" \ +DATABASE_PASSWORD="gorb" \ +DATABASE="gorb" \ +DATABASE_HOST="database" \ +DATABASE_PORT="5432" \ +CACHE_DB_HOST="valkey" \ +CACHE_DB_PORT="6379" ENTRYPOINT ["/usr/bin/entrypoint.sh"] diff --git a/compose.dev.yml b/compose.dev.yml index 02f46a3..d064beb 100644 --- a/compose.dev.yml +++ b/compose.dev.yml @@ -34,3 +34,8 @@ services: - POSTGRES_USER=gorb - POSTGRES_PASSWORD=gorb - POSTGRES_DB=gorb + valkey: + image: valkey/valkey + restart: always + networks: + - gorb diff --git a/compose.yml b/compose.yml index 4544dea..84e6695 100644 --- a/compose.yml +++ b/compose.yml @@ -32,3 +32,8 @@ services: - POSTGRES_USER=gorb - POSTGRES_PASSWORD=gorb - POSTGRES_DB=gorb + valkey: + image: valkey/valkey + restart: always + networks: + - gorb diff --git a/entrypoint.sh b/entrypoint.sh index 63bfa84..a212f8e 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -16,6 +16,10 @@ password = "${DATABASE_PASSWORD}" database = "${DATABASE}" host = "${DATABASE_HOST}" port = ${DATABASE_PORT} + +[cache_database] +host = "${CACHE_DB_HOST}" +port = ${CACHE_DB_PORT} EOF fi From 9e56eec0217e0b92817c909575ce2cd0ac1ae44b Mon Sep 17 00:00:00 2001 From: Radical Date: Wed, 7 May 2025 21:22:38 +0200 Subject: [PATCH 21/22] fix: remove unused imports --- src/api/v1/users/uuid.rs | 1 - src/main.rs | 3 +-- src/utils.rs | 4 ++-- 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/src/api/v1/users/uuid.rs b/src/api/v1/users/uuid.rs index 577af0e..04c05bd 100644 --- a/src/api/v1/users/uuid.rs +++ b/src/api/v1/users/uuid.rs @@ -1,7 +1,6 @@ use actix_web::{Error, HttpRequest, HttpResponse, get, web}; use log::error; use serde::Serialize; -use tokio::sync::Mutex; use uuid::Uuid; use crate::{Data, api::v1::auth::check_access_token, utils::get_auth_header}; diff --git a/src/main.rs b/src/main.rs index c93e760..7f21e2e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,10 +2,9 @@ use actix_cors::Cors; use actix_web::{App, HttpServer, web}; use argon2::Argon2; use clap::Parser; -use redis::aio::MultiplexedConnection; use simple_logger::SimpleLogger; use sqlx::{PgPool, Pool, Postgres}; -use std::{cell::Cell, time::SystemTime}; +use std::time::SystemTime; mod config; use config::{Config, ConfigBuilder}; mod api; diff --git a/src/utils.rs b/src/utils.rs index 5d6b51c..2d63024 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,7 +1,7 @@ use actix_web::{cookie::{time::Duration, Cookie, SameSite}, http::header::HeaderMap, HttpResponse}; use getrandom::fill; use hex::encode; -use redis::{AsyncCommands, RedisError}; +use redis::RedisError; use serde::Serialize; use serde_json::json; @@ -57,7 +57,7 @@ impl Data { let key_encoded = encode(key); - let value_json = json!(value).to_string(); + let value_json = json!(value).as_str().unwrap().to_string(); redis::cmd("SET",).arg(&[key_encoded.clone(), value_json]).exec_async(&mut conn).await?; From 3e65cffe39e64e8ad91d759dbf2ee332116e9f91 Mon Sep 17 00:00:00 2001 From: Radical Date: Wed, 7 May 2025 22:21:59 +0200 Subject: [PATCH 22/22] fix: fix user uuid cache hits --- src/api/v1/users/uuid.rs | 2 +- src/utils.rs | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/api/v1/users/uuid.rs b/src/api/v1/users/uuid.rs index 04c05bd..5e4db39 100644 --- a/src/api/v1/users/uuid.rs +++ b/src/api/v1/users/uuid.rs @@ -37,7 +37,7 @@ pub async fn res( let cache_result = data.get_cache_key(uuid.to_string()).await; if let Ok(cache_hit) = cache_result { - return Ok(HttpResponse::Ok().json(cache_hit)) + return Ok(HttpResponse::Ok().content_type("application/json").body(cache_hit)) } let row = sqlx::query_as(&format!( diff --git a/src/utils.rs b/src/utils.rs index 2d63024..15e5e2e 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -3,7 +3,6 @@ use getrandom::fill; use hex::encode; use redis::RedisError; use serde::Serialize; -use serde_json::json; use crate::Data; @@ -57,7 +56,7 @@ impl Data { let key_encoded = encode(key); - let value_json = json!(value).as_str().unwrap().to_string(); + let value_json = serde_json::to_string(&value).unwrap(); redis::cmd("SET",).arg(&[key_encoded.clone(), value_json]).exec_async(&mut conn).await?;