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, UNIX_EPOCH};
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))
}
pub fn create_cache_function(lua: &Lua) -> LuaResult<Function> {
lua.create_function(
move |lua, (key, value, ttl): (String, LuaValue, Option<u64>)| {
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| {
if let Some(value) = get_http_cache().get(&key) {
json_to_lua(lua, &value)
} else {
Ok(LuaValue::Nil)
}
})
}
pub fn create_response_html_function(_lua: &Lua) -> LuaResult<Function> {
let lua_ref = _lua;
lua_ref.create_function(move |lua, content: String| {
Ok(LuaValue::String(
lua.create_string(format!("HTML_RESPONSE:{}", content))
.unwrap(),
))
})
}
pub fn create_response_file_function(_lua: &Lua) -> LuaResult<Function> {
let lua_ref = _lua;
lua_ref.create_function(move |lua, path: String| {
let p = std::path::Path::new(&path);
let has_parent_dir = p
.components()
.any(|c| matches!(c, std::path::Component::ParentDir));
if p.is_absolute() || has_parent_dir {
let file_info = lua.create_table()?;
file_info.set(
"error",
"Invalid path: absolute paths and parent-dir traversal are not allowed",
)?;
file_info.set("exists", false)?;
return Ok(LuaValue::Table(file_info));
}
match std::fs::metadata(&path) {
Ok(metadata) => {
let file_info = lua.create_table()?;
file_info.set("path", path.clone())?;
file_info.set("size", metadata.len())?;
file_info.set("exists", true)?;
if let Ok(modified) = metadata.modified() {
if let Ok(duration) = modified.duration_since(UNIX_EPOCH) {
file_info.set("modified", duration.as_secs())?;
}
}
Ok(LuaValue::Table(file_info))
}
Err(_) => {
let file_info = lua.create_table()?;
file_info.set("path", path)?;
file_info.set("exists", false)?;
Ok(LuaValue::Table(file_info))
}
}
})
}
pub fn create_response_stream_function(lua: &Lua) -> LuaResult<Function> {
lua.create_function(|lua, data: LuaValue| {
let stream_info = lua.create_table()?;
stream_info.set("type", "stream")?;
stream_info.set("data", data)?;
Ok(LuaValue::Table(stream_info))
})
}
pub fn create_response_cors_function(lua: &Lua) -> LuaResult<Function> {
lua.create_function(|lua, options: Option<LuaValue>| {
let cors_info = lua.create_table()?;
if let Some(opts) = options {
if let LuaValue::Table(t) = opts {
if let Ok(origins) = t.get::<LuaValue>("origins") {
cors_info.set("origins", origins)?;
}
if let Ok(methods) = t.get::<LuaValue>("methods") {
cors_info.set("methods", methods)?;
}
if let Ok(headers) = t.get::<LuaValue>("headers") {
cors_info.set("headers", headers)?;
}
if let Ok(credentials) = t.get::<bool>("credentials") {
cors_info.set("credentials", credentials)?;
}
if let Ok(max_age) = t.get::<u64>("max_age") {
cors_info.set("max_age", max_age)?;
}
}
} else {
cors_info.set("origins", "*")?;
cors_info.set("methods", "GET, POST, PUT, DELETE, OPTIONS")?;
cors_info.set("headers", "Content-Type, Authorization")?;
}
Ok(LuaValue::Table(cors_info))
})
}
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();
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 test_response_html() {
let lua = Lua::new();
let html_fn = create_response_html_function(&lua).unwrap();
let result: Result<LuaValue, _> = html_fn.call("<h1>Test</h1>");
match result {
Ok(LuaValue::String(s)) => {
assert!(s.to_str().unwrap().starts_with("HTML_RESPONSE:"));
}
_ => panic!("Expected string result"),
}
}
}