use cookie::{Cookie as HttpCookie, SameSite};
use lru::LruCache;
use mlua::{Function, Lua, Result as LuaResult, Value as LuaValue};
use serde_json::Value as JsonValue;
use std::sync::{Arc, Mutex};
use std::time::{Duration, SystemTime};
use time::{format_description, OffsetDateTime};
use crate::scripting::lua_to_json_value;
pub struct HttpCache {
cache: Arc<Mutex<LruCache<String, CacheEntry>>>,
}
#[derive(Clone)]
struct CacheEntry {
value: JsonValue,
expires_at: SystemTime,
}
impl HttpCache {
pub fn new(capacity: usize) -> Self {
Self {
cache: Arc::new(Mutex::new(LruCache::new(
std::num::NonZeroUsize::new(capacity).unwrap(),
))),
}
}
pub fn get(&self, key: &str) -> Option<JsonValue> {
let mut cache = self.cache.lock().unwrap();
if let Some(entry) = cache.get(key) {
if entry.expires_at > SystemTime::now() {
return Some(entry.value.clone());
} else {
cache.pop(key);
}
}
None
}
pub fn set(&self, key: String, value: JsonValue, ttl_seconds: Option<u64>) {
let mut cache = self.cache.lock().unwrap();
let expires_at = if let Some(ttl) = ttl_seconds {
SystemTime::now() + Duration::from_secs(ttl)
} else {
SystemTime::now() + Duration::from_secs(3600) };
cache.put(key, CacheEntry { value, expires_at });
}
}
fn parse_allowed_origin(entry: &str) -> Option<(Option<String>, String, Option<u16>)> {
let entry = entry.trim();
if entry.is_empty() {
return None;
}
if entry.contains("://") {
let parsed = url::Url::parse(entry).ok()?;
let host = parsed.host_str()?.to_lowercase();
Some((Some(parsed.scheme().to_string()), host, parsed.port()))
} else {
let (host, port) = match entry.rsplit_once(':') {
Some((h, p)) if p.chars().all(|c| c.is_ascii_digit()) => {
(h.to_lowercase(), p.parse::<u16>().ok())
}
_ => (entry.to_lowercase(), None),
};
Some((None, host, port))
}
}
fn redirect_url_allowed(url_str: &str, allowed: &[&str]) -> bool {
let parsed = match url::Url::parse(url_str) {
Ok(u) => u,
Err(_) => return false,
};
let url_host = match parsed.host_str() {
Some(h) => h.to_lowercase(),
None => return false,
};
let url_scheme = parsed.scheme();
let url_port = parsed.port_or_known_default();
allowed.iter().any(|raw| {
let (allowed_scheme, allowed_host, allowed_port) = match parse_allowed_origin(raw) {
Some(t) => t,
None => return false,
};
if allowed_host != url_host {
return false;
}
if let Some(scheme) = &allowed_scheme {
if scheme != url_scheme {
return false;
}
}
if let Some(port) = allowed_port {
if Some(port) != url_port {
return false;
}
}
true
})
}
pub fn create_redirect_function(lua: &Lua) -> LuaResult<Function> {
lua.create_function(|_, url: String| {
let allowed_origins = std::env::var("SOLIDB_ALLOWED_REDIRECT_ORIGINS").unwrap_or_default();
let allowed_list: Vec<&str> = allowed_origins
.split(',')
.map(str::trim)
.filter(|o| !o.is_empty())
.collect();
let is_absolute = url.starts_with("http://") || url.starts_with("https://");
if is_absolute && !allowed_list.is_empty() && !redirect_url_allowed(&url, &allowed_list) {
return Err(mlua::Error::RuntimeError(
"REDIRECT: Forbidden - redirect to untrusted domain".to_string(),
));
}
Err::<LuaValue, mlua::Error>(mlua::Error::RuntimeError(format!("REDIRECT:{}", url)))
})
}
pub fn create_set_cookie_function(lua: &Lua) -> LuaResult<Function> {
lua.create_function(
move |_lua, (name, value, options): (String, String, Option<LuaValue>)| {
let mut cookie = HttpCookie::new(name, value);
if let Some(LuaValue::Table(t)) = options {
if let Ok(expires) = t.get::<String>("expires") {
if let Ok(timestamp) = expires.parse::<i64>() {
if let Ok(datetime) = OffsetDateTime::from_unix_timestamp(timestamp) {
cookie.set_expires(datetime);
}
} else if let Ok(datetime) =
OffsetDateTime::parse(&expires, &format_description::well_known::Rfc3339)
{
cookie.set_expires(datetime);
}
}
if let Ok(path) = t.get::<String>("path") {
cookie.set_path(path);
}
if let Ok(domain) = t.get::<String>("domain") {
cookie.set_domain(domain);
}
if let Ok(secure) = t.get::<bool>("secure") {
cookie.set_secure(secure);
}
if let Ok(http_only) = t.get::<bool>("httpOnly") {
cookie.set_http_only(http_only);
}
if let Ok(same_site) = t.get::<String>("sameSite") {
match same_site.as_str() {
"Strict" => cookie.set_same_site(SameSite::Strict),
"Lax" => cookie.set_same_site(SameSite::Lax),
"None" => cookie.set_same_site(SameSite::None),
_ => {}
}
}
}
let cookie_str = cookie.to_string();
tracing::debug!("Setting cookie: {}", cookie_str);
Ok(true)
},
)
}
fn get_http_cache() -> &'static HttpCache {
use std::sync::OnceLock;
static HTTP_CACHE: OnceLock<HttpCache> = OnceLock::new();
HTTP_CACHE.get_or_init(|| HttpCache::new(1000))
}
fn namespaced_cache_key(lua: &Lua, key: &str) -> LuaResult<String> {
let db = crate::scripting::types::script_db_name(lua)?;
Ok(format!("{}\u{0}{}", db, key))
}
pub fn create_cache_function(lua: &Lua) -> LuaResult<Function> {
lua.create_function(
move |lua, (key, value, ttl): (String, LuaValue, Option<u64>)| {
let key = namespaced_cache_key(lua, &key)?;
let json_value = lua_to_json_value(lua, value)?;
get_http_cache().set(key, json_value, ttl);
Ok(true)
},
)
}
pub fn create_cache_get_function(lua: &Lua) -> LuaResult<Function> {
lua.create_function(move |lua, key: String| {
let key = namespaced_cache_key(lua, &key)?;
if let Some(value) = get_http_cache().get(&key) {
json_to_lua(lua, &value)
} else {
Ok(LuaValue::Nil)
}
})
}
fn json_to_lua(lua: &Lua, json: &JsonValue) -> LuaResult<LuaValue> {
match json {
JsonValue::Null => Ok(LuaValue::Nil),
JsonValue::Bool(b) => Ok(LuaValue::Boolean(*b)),
JsonValue::Number(n) => {
if let Some(i) = n.as_i64() {
Ok(LuaValue::Integer(i))
} else if let Some(f) = n.as_f64() {
Ok(LuaValue::Number(f))
} else {
Ok(LuaValue::Nil)
}
}
JsonValue::String(s) => Ok(LuaValue::String(lua.create_string(s)?)),
JsonValue::Array(arr) => {
let table = lua.create_table()?;
for (i, v) in arr.iter().enumerate() {
table.set(i + 1, json_to_lua(lua, v)?)?;
}
Ok(LuaValue::Table(table))
}
JsonValue::Object(obj) => {
let table = lua.create_table()?;
for (k, v) in obj {
table.set(k.clone(), json_to_lua(lua, v)?)?;
}
Ok(LuaValue::Table(table))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use mlua::Lua;
#[test]
fn test_redirect_function() {
let lua = Lua::new();
let redirect_fn = create_redirect_function(&lua).unwrap();
let result: Result<LuaValue, _> = redirect_fn.call("https://example.com");
match result {
Ok(_) => panic!("Expected error"),
Err(e) => assert!(e.to_string().contains("REDIRECT:https://example.com")),
}
}
#[test]
fn test_cache_function() {
let lua = Lua::new();
lua.set_app_data(crate::scripting::types::ScriptDbName("db".to_string()));
let cache_fn = create_cache_function(&lua).unwrap();
let data = lua.create_table().unwrap();
data.set("test", "value").unwrap();
let result: Result<bool, _> =
cache_fn.call(("test_key".to_string(), LuaValue::Table(data), Some(60)));
assert!(result.unwrap());
}
#[test]
fn cache_is_namespaced_per_database() {
let lua_a = Lua::new();
lua_a.set_app_data(crate::scripting::types::ScriptDbName(
"tenant_a".to_string(),
));
let lua_b = Lua::new();
lua_b.set_app_data(crate::scripting::types::ScriptDbName(
"tenant_b".to_string(),
));
let set_a = create_cache_function(&lua_a).unwrap();
let _: bool = set_a
.call(("h6_session".to_string(), "secret-a".to_string(), Some(60)))
.unwrap();
let get_b = create_cache_get_function(&lua_b).unwrap();
let seen: LuaValue = get_b.call("h6_session".to_string()).unwrap();
assert!(
matches!(seen, LuaValue::Nil),
"tenant B must not see A's entry"
);
let get_a = create_cache_get_function(&lua_a).unwrap();
let own: String = get_a.call("h6_session".to_string()).unwrap();
assert_eq!(own, "secret-a");
let bare = Lua::new();
let get_bare = create_cache_get_function(&bare).unwrap();
assert!(get_bare.call::<LuaValue>("h6_session".to_string()).is_err());
}
}