chore: full stack stability and migration fixes, plus react UI progress
This commit is contained in:
@@ -14,5 +14,7 @@ thiserror = "1.0"
|
||||
dotenvy = { workspace = true }
|
||||
config = { workspace = true }
|
||||
axum = { workspace = true }
|
||||
redis = { workspace = true }
|
||||
redis = { workspace = true, features = ["sentinel"] }
|
||||
tracing = { workspace = true }
|
||||
jsonwebtoken = { workspace = true }
|
||||
url = "2.5.8"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Multi-tier caching layer for MadBase
|
||||
use redis::{AsyncCommands, Client};
|
||||
use std::sync::Arc;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
use uuid::Uuid;
|
||||
@@ -47,22 +48,53 @@ impl DistributedLock {
|
||||
}
|
||||
}
|
||||
|
||||
enum RedisClientInner {
|
||||
Single(Client),
|
||||
Sentinel(tokio::sync::Mutex<redis::sentinel::SentinelClient>),
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct RedisClient {
|
||||
client: Client,
|
||||
inner: Arc<RedisClientInner>,
|
||||
}
|
||||
|
||||
impl RedisClient {
|
||||
pub fn new(redis_url: &str) -> CacheResult<Self> {
|
||||
let client = Client::open(redis_url)?;
|
||||
Ok(Self { client })
|
||||
}
|
||||
pub fn get_connection(&self) -> CacheResult<redis::Connection> {
|
||||
Ok(self.client.get_connection()?)
|
||||
if redis_url.starts_with("redis+sentinel://") {
|
||||
let parsed_url = url::Url::parse(redis_url).map_err(|_| CacheError::NotFound("Invalid Sentinel URL".into()))?;
|
||||
let master_name = parsed_url.path().trim_start_matches('/').to_string();
|
||||
let addresses = parsed_url.host_str().unwrap_or("");
|
||||
|
||||
let mut node_urls = Vec::new();
|
||||
for addr in addresses.split(',') {
|
||||
node_urls.push(format!("redis://{}", addr));
|
||||
}
|
||||
|
||||
let sentinel_client = redis::sentinel::SentinelClient::build(
|
||||
node_urls,
|
||||
master_name,
|
||||
None,
|
||||
redis::sentinel::SentinelServerType::Master
|
||||
)?;
|
||||
Ok(Self { inner: Arc::new(RedisClientInner::Sentinel(tokio::sync::Mutex::new(sentinel_client))) })
|
||||
} else {
|
||||
let client = Client::open(redis_url)?;
|
||||
Ok(Self { inner: Arc::new(RedisClientInner::Single(client)) })
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_async_connection(&self) -> CacheResult<redis::aio::MultiplexedConnection> {
|
||||
Ok(self.client.get_multiplexed_async_connection().await?)
|
||||
match &*self.inner {
|
||||
RedisClientInner::Single(client) => {
|
||||
Ok(client.get_multiplexed_async_connection().await?)
|
||||
}
|
||||
RedisClientInner::Sentinel(sentinel_mutex) => {
|
||||
let mut sentinel = sentinel_mutex.lock().await;
|
||||
Ok(sentinel.get_async_connection().await?)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn ping(&self) -> CacheResult<String> {
|
||||
let mut conn = self.get_async_connection().await?;
|
||||
let response: String = redis::cmd("PING").query_async(&mut conn).await?;
|
||||
@@ -147,6 +179,15 @@ impl CacheLayer {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn exists(&self, key: &str) -> CacheResult<bool> {
|
||||
if let Some(redis) = &self.redis {
|
||||
let mut conn = redis.get_async_connection().await?;
|
||||
let result: i32 = redis::cmd("EXISTS").arg(key).query_async(&mut conn).await?;
|
||||
return Ok(result > 0);
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -162,4 +203,9 @@ mod tests {
|
||||
let cache = CacheLayer::new(None, 3600);
|
||||
assert!(cache.redis.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_redis_client_new_invalid_url() {
|
||||
assert!(RedisClient::new("not_a_url").is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use serde::Deserialize;
|
||||
use std::env;
|
||||
use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Validation, encode, decode, Header, errors::Error as JwtError};
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub enum StorageMode {
|
||||
@@ -8,6 +9,108 @@ pub enum StorageMode {
|
||||
SelfHosted,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct JwtConfig {
|
||||
pub secret: String,
|
||||
pub issuer: String,
|
||||
pub algorithm: Algorithm,
|
||||
}
|
||||
|
||||
impl JwtConfig {
|
||||
pub fn from_env() -> Result<Self, JwtConfigError> {
|
||||
let secret = env::var("JWT_SECRET")
|
||||
.map_err(|_| JwtConfigError::NotSet)?;
|
||||
|
||||
if secret.len() < 32 {
|
||||
return Err(JwtConfigError::TooShort(secret.len()));
|
||||
}
|
||||
|
||||
Ok(JwtConfig {
|
||||
secret,
|
||||
issuer: env::var("JWT_ISSUER").unwrap_or_else(|_| "madbase".to_string()),
|
||||
algorithm: Algorithm::HS256,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn encoding_key(&self) -> EncodingKey {
|
||||
EncodingKey::from_secret(self.secret.as_bytes())
|
||||
}
|
||||
|
||||
pub fn decoding_key(&self) -> DecodingKey {
|
||||
DecodingKey::from_secret(self.secret.as_bytes())
|
||||
}
|
||||
|
||||
pub fn validation(&self) -> Validation {
|
||||
let mut validation = Validation::new(self.algorithm);
|
||||
validation.validate_exp = true;
|
||||
validation.validate_aud = false;
|
||||
validation.set_issuer(&[&self.issuer]);
|
||||
validation
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum JwtConfigError {
|
||||
#[error("JWT_SECRET environment variable is not set")]
|
||||
NotSet,
|
||||
#[error("JWT_SECRET is too short: {0} characters (minimum 32 required)")]
|
||||
TooShort(usize),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct JwtClaims {
|
||||
pub sub: String,
|
||||
pub email: Option<String>,
|
||||
pub role: String,
|
||||
pub exp: usize,
|
||||
pub iss: String,
|
||||
pub aud: Option<String>,
|
||||
}
|
||||
|
||||
impl JwtConfig {
|
||||
pub fn generate_anon_token(&self) -> Result<String, JwtError> {
|
||||
let claims = JwtClaims {
|
||||
sub: "anon".to_string(),
|
||||
email: None,
|
||||
role: "anon".to_string(),
|
||||
exp: (chrono::Utc::now() + chrono::Duration::hours(24)).timestamp() as usize,
|
||||
iss: self.issuer.clone(),
|
||||
aud: None,
|
||||
};
|
||||
|
||||
encode(
|
||||
&Header::default(),
|
||||
&claims,
|
||||
&self.encoding_key(),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn generate_service_role_token(&self) -> Result<String, JwtError> {
|
||||
let claims = JwtClaims {
|
||||
sub: "service_role".to_string(),
|
||||
email: None,
|
||||
role: "service_role".to_string(),
|
||||
exp: (chrono::Utc::now() + chrono::Duration::hours(24)).timestamp() as usize,
|
||||
iss: self.issuer.clone(),
|
||||
aud: None,
|
||||
};
|
||||
|
||||
encode(
|
||||
&Header::default(),
|
||||
&claims,
|
||||
&self.encoding_key(),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn validate_token(&self, token: &str) -> Result<JwtClaims, JwtError> {
|
||||
decode::<JwtClaims>(
|
||||
token,
|
||||
&self.decoding_key(),
|
||||
&self.validation(),
|
||||
).map(|data| data.claims)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct Config {
|
||||
pub database_url: String,
|
||||
|
||||
@@ -5,6 +5,6 @@ pub mod error;
|
||||
pub mod rls;
|
||||
|
||||
pub use cache::{CacheLayer, CacheError, CacheResult, SessionData};
|
||||
pub use config::{Config, ProjectContext};
|
||||
pub use config::{Config, ProjectContext, JwtConfig, JwtClaims, JwtConfigError};
|
||||
pub use db::init_pool;
|
||||
pub use rls::RlsTransaction;
|
||||
|
||||
Reference in New Issue
Block a user