chore: full stack stability and migration fixes, plus react UI progress
Some checks failed
CI / podman-build (push) Has been cancelled
CI / rust (push) Has been cancelled

This commit is contained in:
2026-03-18 09:01:38 +02:00
parent 38cab8c246
commit a66d908eff
142 changed files with 12210 additions and 3402 deletions

View File

@@ -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"

View File

@@ -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());
}
}

View File

@@ -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,

View File

@@ -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;