Compare commits

...

15 commits

Author SHA1 Message Date
71f0cc14be Merge branch 'main' into wip/messaging 2025-05-07 23:23:36 +02:00
c4dafa1f2c Merge pull request 'feat: add redis caching' (#11) from wip/redis-caching into main
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
Reviewed-on: #11
2025-05-07 21:04:40 +00:00
3e65cffe39 fix: fix user uuid cache hits 2025-05-07 22:21:59 +02:00
9e56eec021 fix: remove unused imports 2025-05-07 21:22:38 +02:00
3e64a49338 chore: add valkey configuration to docker 2025-05-07 20:57:01 +02:00
529ccd1b51 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
2025-05-07 20:33:23 +02:00
7ecc8c4270 feat: add redis caching 2025-05-07 20:32:32 +02:00
ca63a2a13c Merge pull request 'feat: implement cors' (#10) from wip/cors into main
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
Reviewed-on: #10
Reviewed-by: Radical <radical@radical.fun>
2025-05-06 08:06:32 +00:00
c0f2948b76
feat: implement cors 2025-05-06 00:41:23 +02:00
135375f5b7 Merge pull request 'wip/username-regex' (#6) from wip/username-regex into main
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
Reviewed-on: #6
Reviewed-by: Radical <radical@radical.fun>
2025-05-05 01:16:31 +00:00
77245e98c5 refactor: combine crypto.rs with utils.rs
All checks were successful
ci/woodpecker/push/build-and-publish Pipeline was successful
2025-05-04 23:50:38 +02:00
8a1467c26a Merge branch 'main' into wip/username-regex 2025-05-04 21:41:40 +00:00
ab5c85c4f5
fix: add numbers to username regex 2025-05-04 23:25:48 +02:00
e29940d080
feat: only allow lowercase usernames 2025-05-03 03:04:07 +02:00
b530de8f52
fix: username regex 2025-05-03 02:20:37 +02:00
14 changed files with 164 additions and 25 deletions

View file

@ -9,6 +9,7 @@ lto = true
codegen-units = 1 codegen-units = 1
[dependencies] [dependencies]
actix-cors = "0.7.1"
actix-web = "4.10" actix-web = "4.10"
argon2 = { version = "0.5.3", features = ["std"] } argon2 = { version = "0.5.3", features = ["std"] }
clap = { version = "4.5.37", features = ["derive"] } clap = { version = "4.5.37", features = ["derive"] }
@ -21,6 +22,7 @@ serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0" serde_json = "1.0"
simple_logger = "5.0.0" simple_logger = "5.0.0"
sqlx = { version = "0.8", features = ["runtime-tokio", "tls-native-tls", "postgres"] } sqlx = { version = "0.8", features = ["runtime-tokio", "tls-native-tls", "postgres"] }
redis = { version = "0.30", features= ["tokio-comp"] }
tokio-tungstenite = { version = "0.26", features = ["native-tls", "url"] } tokio-tungstenite = { version = "0.26", features = ["native-tls", "url"] }
toml = "0.8" toml = "0.8"
url = { version = "2.5", features = ["serde"] } url = { version = "2.5", features = ["serde"] }

View file

@ -18,6 +18,12 @@ RUN useradd --create-home --home-dir /gorb gorb
USER 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"] ENTRYPOINT ["/usr/bin/entrypoint.sh"]

View file

@ -34,3 +34,8 @@ services:
- POSTGRES_USER=gorb - POSTGRES_USER=gorb
- POSTGRES_PASSWORD=gorb - POSTGRES_PASSWORD=gorb
- POSTGRES_DB=gorb - POSTGRES_DB=gorb
valkey:
image: valkey/valkey
restart: always
networks:
- gorb

View file

@ -32,3 +32,8 @@ services:
- POSTGRES_USER=gorb - POSTGRES_USER=gorb
- POSTGRES_PASSWORD=gorb - POSTGRES_PASSWORD=gorb
- POSTGRES_DB=gorb - POSTGRES_DB=gorb
valkey:
image: valkey/valkey
restart: always
networks:
- gorb

View file

@ -16,6 +16,10 @@ password = "${DATABASE_PASSWORD}"
database = "${DATABASE}" database = "${DATABASE}"
host = "${DATABASE_HOST}" host = "${DATABASE_HOST}"
port = ${DATABASE_PORT} port = ${DATABASE_PORT}
[cache_database]
host = "${CACHE_DB_HOST}"
port = ${CACHE_DB_PORT}
EOF EOF
fi fi

View file

@ -7,7 +7,7 @@ use log::error;
use serde::Deserialize; use serde::Deserialize;
use crate::{ 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; use super::Response;

View file

@ -25,8 +25,7 @@ static EMAIL_REGEX: LazyLock<Regex> = 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() 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<Regex> = LazyLock::new(|| Regex::new(r"^[a-z0-9_.-]+$").unwrap());
static USERNAME_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"[a-zA-Z0-9.-_]").unwrap());
// Password is expected to be hashed using SHA3-384 // Password is expected to be hashed using SHA3-384
static PASSWORD_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"[0-9a-f]{96}").unwrap()); static PASSWORD_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"[0-9a-f]{96}").unwrap());

View file

@ -3,7 +3,7 @@ use log::error;
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
use crate::{ 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; use super::Response;

View file

@ -12,7 +12,7 @@ use uuid::Uuid;
use super::Response; use super::Response;
use crate::{ 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)] #[derive(Deserialize)]

View file

@ -5,7 +5,7 @@ use uuid::Uuid;
use crate::{Data, api::v1::auth::check_access_token, utils::get_auth_header}; use crate::{Data, api::v1::auth::check_access_token, utils::get_auth_header};
#[derive(Serialize)] #[derive(Serialize, Clone)]
struct Response { struct Response {
uuid: String, uuid: String,
username: String, username: String,
@ -34,6 +34,12 @@ pub async fn res(
return Ok(error); 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().content_type("application/json").body(cache_hit))
}
let row = sqlx::query_as(&format!( let row = sqlx::query_as(&format!(
"SELECT username, display_name FROM users WHERE uuid = '{}'", "SELECT username, display_name FROM users WHERE uuid = '{}'",
uuid uuid
@ -48,9 +54,18 @@ pub async fn res(
let (username, display_name): (String, Option<String>) = row.unwrap(); let (username, display_name): (String, Option<String>) = row.unwrap();
Ok(HttpResponse::Ok().json(Response { let user = Response {
uuid: uuid.to_string(), uuid: uuid.to_string(),
username, username,
display_name: display_name.unwrap_or_default(), 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))
} }

View file

@ -7,6 +7,7 @@ use tokio::fs::read_to_string;
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct ConfigBuilder { pub struct ConfigBuilder {
database: Database, database: Database,
cache_database: CacheDatabase,
web: Option<WebBuilder>, web: Option<WebBuilder>,
} }
@ -19,6 +20,15 @@ pub struct Database {
port: u16, port: u16,
} }
#[derive(Debug, Deserialize, Clone)]
pub struct CacheDatabase {
username: Option<String>,
password: Option<String>,
host: String,
database: Option<String>,
port: u16,
}
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
struct WebBuilder { struct WebBuilder {
url: Option<String>, url: Option<String>,
@ -51,6 +61,7 @@ impl ConfigBuilder {
Config { Config {
database: self.database, database: self.database,
cache_database: self.cache_database,
web, web,
} }
} }
@ -59,6 +70,7 @@ impl ConfigBuilder {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct Config { pub struct Config {
pub database: Database, pub database: Database,
pub cache_database: CacheDatabase,
pub web: Web, pub web: Web,
} }
@ -78,3 +90,33 @@ impl Database {
.port(self.port) .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
}
}

View file

@ -1,14 +0,0 @@
use getrandom::fill;
use hex::encode;
pub fn generate_access_token() -> Result<String, getrandom::Error> {
let mut buf = [0u8; 16];
fill(&mut buf)?;
Ok(encode(buf))
}
pub fn generate_refresh_token() -> Result<String, getrandom::Error> {
let mut buf = [0u8; 32];
fill(&mut buf)?;
Ok(encode(buf))
}

View file

@ -1,3 +1,4 @@
use actix_cors::Cors;
use actix_web::{App, HttpServer, web}; use actix_web::{App, HttpServer, web};
use argon2::Argon2; use argon2::Argon2;
use clap::Parser; use clap::Parser;
@ -7,7 +8,7 @@ use std::time::SystemTime;
mod config; mod config;
use config::{Config, ConfigBuilder}; use config::{Config, ConfigBuilder};
mod api; mod api;
pub mod crypto;
pub mod utils; pub mod utils;
type Error = Box<dyn std::error::Error>; type Error = Box<dyn std::error::Error>;
@ -22,6 +23,7 @@ struct Args {
#[derive(Clone)] #[derive(Clone)]
struct Data { struct Data {
pub pool: Pool<Postgres>, pub pool: Pool<Postgres>,
pub cache_pool: redis::Client,
pub _config: Config, pub _config: Config,
pub argon2: Argon2<'static>, pub argon2: Argon2<'static>,
pub start_time: SystemTime, pub start_time: SystemTime,
@ -43,6 +45,8 @@ async fn main() -> Result<(), Error> {
let pool = PgPool::connect_with(config.database.connect_options()).await?; 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. 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" Also figure out if these should be different types from what they currently are and if we should add more "constraints"
@ -153,15 +157,45 @@ async fn main() -> Result<(), Error> {
let data = Data { let data = Data {
pool, pool,
cache_pool,
_config: config, _config: config,
// TODO: Possibly implement "pepper" into this (thinking it could generate one if it doesnt exist and store it on disk) // TODO: Possibly implement "pepper" into this (thinking it could generate one if it doesnt exist and store it on disk)
argon2: Argon2::default(), argon2: Argon2::default(),
start_time: SystemTime::now(), start_time: SystemTime::now(),
}; };
HttpServer::new(move || { 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::new()
.app_data(web::Data::new(data.clone())) .app_data(web::Data::new(data.clone()))
.wrap(cors)
.service(api::web()) .service(api::web())
}) })
.bind((web.url, web.port))? .bind((web.url, web.port))?

View file

@ -1,4 +1,10 @@
use actix_web::{cookie::{time::Duration, Cookie, SameSite}, http::header::HeaderMap, HttpResponse}; use actix_web::{cookie::{time::Duration, Cookie, SameSite}, http::header::HeaderMap, HttpResponse};
use getrandom::fill;
use hex::encode;
use redis::RedisError;
use serde::Serialize;
use crate::Data;
pub fn get_auth_header(headers: &HeaderMap) -> Result<&str, HttpResponse> { pub fn get_auth_header(headers: &HeaderMap) -> Result<&str, HttpResponse> {
let auth_token = headers.get(actix_web::http::header::AUTHORIZATION); let auth_token = headers.get(actix_web::http::header::AUTHORIZATION);
@ -30,4 +36,39 @@ pub fn refresh_token_cookie(refresh_token: String) -> Cookie<'static> {
.path("/api") .path("/api")
.max_age(Duration::days(30)) .max_age(Duration::days(30))
.finish() .finish()
} }
pub fn generate_access_token() -> Result<String, getrandom::Error> {
let mut buf = [0u8; 16];
fill(&mut buf)?;
Ok(encode(buf))
}
pub fn generate_refresh_token() -> Result<String, getrandom::Error> {
let mut buf = [0u8; 32];
fill(&mut buf)?;
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 = serde_json::to_string(&value).unwrap();
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<String, RedisError> {
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
}
}