Compare commits

..

No commits in common. '24eb878b299fd9f5bbd39b214350f3f4526558cb' and 'e92bbdb62cf0437b6a930a50c4070f25dddf9f09' have entirely different histories.

6
.gitignore vendored

@ -1,7 +1 @@
more_modules more_modules
env.fish
rust_core/target/
build/
*.so
*.log
rgl_data.db

@ -12,13 +12,59 @@ Prioritized backlog of issues, improvements, and feature ideas.
## High ## High
*No high issues currently.* ### Add proper .gitignore
Current `.gitignore` only has `more_modules`. Missing:
- `rust_core/target/`
- `*.so`
- `rgl_data.db`
- `*.log`
- `.DS_Store`
### Create build script
No Makefile or build automation exists. Need a script for:
- Cross-compile Rust for aarch64-linux-android
- Deploy .so to device via ADB
- Optional: rebuild on file change
### Improve Rust error handling
50+ `unwrap()` calls across Rust codebase. Key areas:
- `bridge.rs`: mutex locks can panic on poisoning — use `unwrap_or_else()` with recovery
- `db.rs`: 30+ unwrap/unwrap_or_default — silent failures on DB errors
- `server.rs`: 15+ unwraps in HTTP handlers
Should introduce proper error types (thiserror crate) or at minimum `unwrap_or_else()` with logging.
### Silent JSON parse failures in db.rs
`execute_batch()` returns `"[]"` on JSON parse error without logging. Should log the error for debugging.
--- ---
## Medium ## Medium
*No medium issues currently.* ### Fix BTC module global state
`more_modules/btc/init.lua` uses `btc_visible = false` as a global variable (for mimgui OnFrame). Should be moved to a proper module state mechanism to avoid global namespace pollution.
### Implement WebSocket backpressure
`bridge.rs` broadcast channel has capacity 256. Events are silently dropped when full. Should either increase capacity, add warning logging, or implement backpressure.
### Improve fallback JSON encoder
`rgl_framework.lua` fallback JSON decoder only handles flat `{"key":"value"}` — fails on nested objects, arrays, numbers, booleans. Since cjson is always expected to be present, consider making it a hard requirement instead of silently degrading.
### Add integration tests
Only `db.rs` has tests (6 batch operation tests). Missing:
- Bridge request/response cycle
- Event system overflow/blocking
- HTTP handler edge cases
- Module loading/unloading
- Win-1251 encoding conversion
### Module loading error handling
`load_all_modules()` uses `io.popen('ls ...')` which can fail if directory is deleted between listing and loading. Use `pcall(io.open)` instead.
### Add auth/CORS for web API
Currently any network client can call all APIs. Consider at minimum:
- Bind to localhost only (or configurable)
- Basic auth token
- CORS headers for web clients
--- ---
@ -30,6 +76,9 @@ No protection against API spam. Could add simple per-endpoint rate limits.
### Add request/response logging middleware ### Add request/response logging middleware
No HTTP access logging in Rust. Add optional access log for debugging API calls. No HTTP access logging in Rust. Add optional access log for debugging API calls.
### Optimize broadcast channel capacity
Current 256 capacity is arbitrary. Profile actual event rates and set appropriately.
### Add module versioning ### Add module versioning
No way to track which version of a module is loaded. Could add `M.version` field and display in admin panel. No way to track which version of a module is loaded. Could add `M.version` field and display in admin panel.

@ -1,54 +1,6 @@
-- Lua Console module — execute Lua code in the game's main thread -- Lua Console module — execute Lua code in the game's main thread
local M = {} local M = {}
-- Serialize a Lua value into a readable string
local function serialize(val, depth)
depth = depth or 0
if depth > 4 then return "..." end
local t = type(val)
if t == "nil" then
return "nil"
elseif t == "string" then
if #val > 200 then
return '"' .. val:sub(1, 200):gsub('[\n\r]', '\\n') .. '..." [' .. #val .. ' chars]'
end
return '"' .. val:gsub('[\n\r]', '\\n'):gsub('"', '\\"') .. '"'
elseif t == "number" or t == "boolean" then
return tostring(val)
elseif t == "table" then
local items = {}
local n = 0
-- Array part
for i, v in ipairs(val) do
if n >= 20 then
items[#items + 1] = "... +" .. (#val - n) .. " more"
break
end
items[#items + 1] = serialize(v, depth + 1)
n = n + 1
end
-- Hash part
for k, v in pairs(val) do
if type(k) ~= "number" or k < 1 or k > #val or k ~= math.floor(k) then
if n >= 20 then
items[#items + 1] = "..."
break
end
local ks = type(k) == "string" and k or "[" .. tostring(k) .. "]"
items[#items + 1] = ks .. " = " .. serialize(v, depth + 1)
n = n + 1
end
end
if #items == 0 then return "{}" end
return "{ " .. table.concat(items, ", ") .. " }"
elseif t == "function" then
return "function: " .. tostring(val)
else
return tostring(val)
end
end
function M.init(fw) function M.init(fw)
local static_dir = fw.modules_dir .. "/console/static" local static_dir = fw.modules_dir .. "/console/static"
fw.register_module("console", static_dir) fw.register_module("console", static_dir)
@ -96,4 +48,52 @@ function M.init(fw)
fw.log("INFO", "CONSOLE", "Module loaded") fw.log("INFO", "CONSOLE", "Module loaded")
end end
-- Serialize a Lua value into a readable string
function serialize(val, depth)
depth = depth or 0
if depth > 4 then return "..." end
local t = type(val)
if t == "nil" then
return "nil"
elseif t == "string" then
if #val > 200 then
return '"' .. val:sub(1, 200):gsub('[\n\r]', '\\n') .. '..." [' .. #val .. ' chars]'
end
return '"' .. val:gsub('[\n\r]', '\\n'):gsub('"', '\\"') .. '"'
elseif t == "number" or t == "boolean" then
return tostring(val)
elseif t == "table" then
local items = {}
local n = 0
-- Array part
for i, v in ipairs(val) do
if n >= 20 then
items[#items + 1] = "... +" .. (#val - n) .. " more"
break
end
items[#items + 1] = serialize(v, depth + 1)
n = n + 1
end
-- Hash part
for k, v in pairs(val) do
if type(k) ~= "number" or k < 1 or k > #val or k ~= math.floor(k) then
if n >= 20 then
items[#items + 1] = "..."
break
end
local ks = type(k) == "string" and k or "[" .. tostring(k) .. "]"
items[#items + 1] = ks .. " = " .. serialize(v, depth + 1)
n = n + 1
end
end
if #items == 0 then return "{}" end
return "{ " .. table.concat(items, ", ") .. " }"
elseif t == "function" then
return "function: " .. tostring(val)
else
return tostring(val)
end
end
return M return M

@ -24,11 +24,6 @@ ffi.cdef[[
unsigned int rgl_db_submit(const char* ops_json); unsigned int rgl_db_submit(const char* ops_json);
char* rgl_db_poll(unsigned int id); char* rgl_db_poll(unsigned int id);
void rgl_free(char* s); void rgl_free(char* s);
void rgl_auth_init(const char* secret_dir);
void rgl_auth_set(const char* login, const char* password);
void rgl_auth_clear();
void rgl_auth_reset();
int rgl_auth_enabled();
]] ]]
local rust = nil local rust = nil
@ -40,13 +35,11 @@ local module_states = {} -- persistent state per module for render()
local command_handlers = {} local command_handlers = {}
local framework = {} local framework = {}
local _current_module = nil local _current_module = nil
local admin_visible = false admin_visible = false
local admin_state = {}
local recent_logs = {} local recent_logs = {}
local MAX_LOGS = 100 local MAX_LOGS = 100
local notifications = {} -- {text, level, time, start} local notifications = {} -- {text, level, time, start}
local module_errors = {} -- ["module_name"] = "last error" local module_errors = {} -- ["module_name"] = "last error"
local module_windows = {} -- {name = {visible, title, width, height}}
local function log(level, tag, ...) local function log(level, tag, ...)
if not rust then return print("[" .. level .. "][" .. tag .. "]", ...) end if not rust then return print("[" .. level .. "][" .. tag .. "]", ...) end
@ -93,10 +86,7 @@ function main()
rust.rgl_db_init(getWorkingDirectory() .. "/rgl_data.db") rust.rgl_db_init(getWorkingDirectory() .. "/rgl_data.db")
log("INFO", "INIT", "DB initialized") log("INFO", "INIT", "DB initialized")
rust.rgl_auth_init(getWorkingDirectory()) setup_framework()
log("INFO", "INIT", "Auth initialized")
if setup_framework() == false then return end
log("INFO", "INIT", "Framework ready") log("INFO", "INIT", "Framework ready")
register_admin() register_admin()
log("INFO", "INIT", "Admin registered") log("INFO", "INIT", "Admin registered")
@ -362,12 +352,13 @@ function setup_framework()
end end
local cjson_ok, cjson = pcall(require, "cjson") local cjson_ok, cjson = pcall(require, "cjson")
if not cjson_ok then if cjson_ok then
log("ERROR", "INIT", "cjson not found — framework cannot start")
return false
end
framework.json_encode = cjson.encode framework.json_encode = cjson.encode
framework.json_decode = cjson.decode framework.json_decode = cjson.decode
else
framework.json_decode = function(s) local t = {} for k,v in s:gmatch('"([^"]+)"%s*:%s*"([^"]*)"') do t[k]=v end return t end
framework.json_encode = function(t) local p = {} for k,v in pairs(t) do p[#p+1]='"'..k..'":"'..tostring(v)..'"' end return "{"..table.concat(p,",").."}" end
end
framework.rust = rust framework.rust = rust
framework.log = log framework.log = log
@ -512,33 +503,6 @@ function setup_framework()
rust.rgl_register_command(name, owner) rust.rgl_register_command(name, owner)
log("INFO", "CMD", "Registered /" .. name .. " (" .. owner .. ")") log("INFO", "CMD", "Registered /" .. name .. " (" .. owner .. ")")
end end
-- Module imgui window registry
framework.register_window = function(opts)
opts = opts or {}
local mod_name = _current_module or "__unknown"
module_windows[mod_name] = {
visible = false,
title = opts.title or mod_name,
width = opts.width or 450,
height = opts.height or 400,
}
end
framework.toggle_window = function(name)
local w = module_windows[name or _current_module]
if w then w.visible = not w.visible end
end
framework.show_window = function(name)
local w = module_windows[name or _current_module]
if w then w.visible = true end
end
framework.hide_window = function(name)
local w = module_windows[name or _current_module]
if w then w.visible = false end
end
end end
---------------------------------------------------------------- ----------------------------------------------------------------
@ -550,15 +514,8 @@ function load_single_module(name)
local f = io.open(path); if not f then return false, "not found" end; f:close() local f = io.open(path); if not f then return false, "not found" end; f:close()
_current_module = name _current_module = name
local chunk, cerr = loadfile(path) local ok, mod = pcall(dofile, path)
if not chunk then _current_module = nil; return false, "load: " .. tostring(cerr) end if not ok then _current_module = nil; return false, "load: " .. tostring(mod) end
-- Sandbox: module gets its own environment, reads fall through to _G
local sandbox = setmetatable({}, {__index = _G})
setfenv(chunk, sandbox)
local ok, mod = pcall(chunk)
if not ok then _current_module = nil; return false, "exec: " .. tostring(mod) end
if not mod or not mod.init then _current_module = nil; return false, "no init()" end if not mod or not mod.init then _current_module = nil; return false, "no init()" end
-- Create per-module framework wrapper with bound db prefix -- Create per-module framework wrapper with bound db prefix
@ -608,7 +565,7 @@ function load_single_module(name)
_current_module = nil _current_module = nil
if not iok then return false, "init: " .. tostring(ierr) end if not iok then return false, "init: " .. tostring(ierr) end
loaded_modules[name] = {mod = mod, status = "loaded", sandbox = sandbox} loaded_modules[name] = {mod = mod, status = "loaded"}
module_states[name] = module_states[name] or {} module_states[name] = module_states[name] or {}
register_module_render(name) register_module_render(name)
log("INFO", "MODS", "Loaded: " .. name) log("INFO", "MODS", "Loaded: " .. name)
@ -628,38 +585,23 @@ function unload_single_module(name)
event_interceptors[ev] = new event_interceptors[ev] = new
end end
rust.rgl_register_module(name, "") rust.rgl_register_module(name, "")
module_windows[name] = nil
loaded_modules[name] = nil loaded_modules[name] = nil
log("INFO", "MODS", "Unloaded: " .. name) log("INFO", "MODS", "Unloaded: " .. name)
return true return true
end end
-- List module directories safely (wraps io.popen in pcall) function load_all_modules()
function list_module_dirs()
local dir = framework.modules_dir local dir = framework.modules_dir
local result = {} local ls = io.popen('ls "' .. dir .. '" 2>/dev/null')
local ok, ls = pcall(io.popen, 'ls "' .. dir .. '" 2>/dev/null') if not ls then return end
if not ok or not ls then
log("WARN", "MODS", "Failed to list modules dir: " .. tostring(ls))
return result
end
for name in ls:lines() do for name in ls:lines() do
local path = dir .. "/" .. name .. "/init.lua" local path = dir .. "/" .. name .. "/init.lua"
local f = io.open(path) local f = io.open(path)
if f then if f then f:close(); local ok,err = load_single_module(name)
f:close() if not ok then log("ERROR", "MODS", name .. ": " .. err) end
result[#result + 1] = name
end end
end end
ls:close() ls:close()
return result
end
function load_all_modules()
for _, name in ipairs(list_module_dirs()) do
local ok, err = load_single_module(name)
if not ok then log("ERROR", "MODS", name .. ": " .. err) end
end
end end
---------------------------------------------------------------- ----------------------------------------------------------------
@ -700,13 +642,23 @@ function admin_render(ui, state)
end end
end end
ui.separator() ui.separator()
for _, name in ipairs(list_module_dirs()) do local dir = framework.modules_dir
local ls = io.popen('ls "' .. dir .. '" 2>/dev/null')
if ls then
for name in ls:lines() do
if not loaded_modules[name] then if not loaded_modules[name] then
local path = dir .. "/" .. name .. "/init.lua"
local f = io.open(path)
if f then
f:close()
ui.text_colored(0.5, 0.5, 0.5, 1, name) ui.text_colored(0.5, 0.5, 0.5, 1, name)
ui.sameline() ui.sameline()
if ui.button("Load##" .. name) then load_single_module(name) end if ui.button("Load##" .. name) then load_single_module(name) end
end end
end end
end
ls:close()
end
ui.tab_end() ui.tab_end()
end end
@ -768,38 +720,6 @@ function admin_render(ui, state)
ui.tab_end() ui.tab_end()
end end
if ui.tab_item("Auth") then
local auth_on = rust.rgl_auth_enabled() == 1
if auth_on then
ui.text_colored(0.3, 0.8, 0.3, 1, "Auth: ON")
ui.spacing()
if ui.button("Disable") then
rust.rgl_auth_clear()
log("INFO", "AUTH", "Auth disabled")
end
ui.sameline()
if ui.button("Reset") then
rust.rgl_auth_reset()
log("INFO", "AUTH", "Auth fully reset")
end
else
ui.text_colored(0.5, 0.5, 0.5, 1, "Auth: OFF")
end
ui.separator()
ui.text("Set credentials:")
state.auth_login = ui.input("Login", state.auth_login or "")
state.auth_pass = ui.input("Password", state.auth_pass or "")
if ui.button("Save") then
if #(state.auth_login or "") > 0 and #(state.auth_pass or "") > 0 then
rust.rgl_auth_set(state.auth_login, state.auth_pass)
log("INFO", "AUTH", "Credentials saved")
state.auth_login = ""
state.auth_pass = ""
end
end
ui.tab_end()
end
end end
end end
@ -811,7 +731,7 @@ function register_admin()
pcall(function() interactions = framework.json_decode(body) end) pcall(function() interactions = framework.json_decode(body) end)
end end
local ui = create_ui_builder(interactions) local ui = create_ui_builder(interactions)
admin_render(ui, admin_state) admin_render(ui, {})
return framework.json_encode({widgets = ui._get_widgets()}) return framework.json_encode({widgets = ui._get_widgets()})
end, owner = "__admin"} end, owner = "__admin"}
@ -1031,7 +951,7 @@ if imgui_loaded and imgui then
local ui = create_ui_imgui() local ui = create_ui_imgui()
if ui then if ui then
local rok, rerr = pcall(admin_render, ui, admin_state) local rok, rerr = pcall(admin_render, ui, {})
if not rok then if not rok then
imgui.TextColored(imgui.ImVec4(1, 0.3, 0.3, 1), "Render error:") imgui.TextColored(imgui.ImVec4(1, 0.3, 0.3, 1), "Render error:")
imgui.TextWrapped(tostring(rerr)) imgui.TextWrapped(tostring(rerr))
@ -1044,30 +964,20 @@ if imgui_loaded and imgui then
end end
) )
-- Dynamic module windows (registered via fw.register_window) -- BTC module window (and any other module with render())
local mod_window_bool = imgui.new.bool() local btc_window = imgui.new.bool()
imgui.OnFrame( imgui.OnFrame(
function() return btc_visible end,
function() function()
for _, w in pairs(module_windows) do btc_window[0] = true
if w.visible then return true end imgui.SetNextWindowSize(imgui.ImVec2(450 * dpi, 400 * dpi), imgui.Cond.FirstUseEver)
end imgui.Begin("BTC Miner", btc_window, imgui.WindowFlags.NoCollapse)
return false
end,
function()
for name, w in pairs(module_windows) do
if w.visible then
mod_window_bool[0] = true
imgui.SetNextWindowSize(
imgui.ImVec2(w.width * dpi, w.height * dpi),
imgui.Cond.FirstUseEver
)
imgui.Begin(w.title, mod_window_bool, imgui.WindowFlags.NoCollapse)
local entry = loaded_modules and loaded_modules[name] local mod = loaded_modules and loaded_modules["btc"]
if entry and entry.mod and entry.mod.render then if mod and mod.mod and mod.mod.render then
local ui = create_ui_imgui() local ui = create_ui_imgui()
if ui then if ui then
local rok, rerr = pcall(entry.mod.render, ui, module_states[name] or {}) local rok, rerr = pcall(mod.mod.render, ui, module_states["btc"] or {})
if not rok then if not rok then
imgui.TextColored(imgui.ImVec4(1, 0.3, 0.3, 1), "Render error:") imgui.TextColored(imgui.ImVec4(1, 0.3, 0.3, 1), "Render error:")
imgui.TextWrapped(tostring(rerr)) imgui.TextWrapped(tostring(rerr))
@ -1075,13 +985,11 @@ if imgui_loaded and imgui then
imgui.EndTabBar() imgui.EndTabBar()
end end
else else
imgui.Text(name .. " module not loaded") imgui.Text("BTC module not loaded")
end end
imgui.End() imgui.End()
if not mod_window_bool[0] then w.visible = false end if not btc_window[0] then btc_visible = false end
end
end
end end
) )

@ -16,10 +16,8 @@ name = "arz-core"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"axum", "axum",
"base64",
"chrono", "chrono",
"encoding_rs", "encoding_rs",
"rand",
"serde", "serde",
"serde_json", "serde_json",
"tokio", "tokio",

@ -15,8 +15,6 @@ encoding_rs = "0.8"
tower-http = { version = "0.6", features = ["fs"] } tower-http = { version = "0.6", features = ["fs"] }
tokio-rusqlite = { version = "0.7", features = ["bundled"] } tokio-rusqlite = { version = "0.7", features = ["bundled"] }
chrono = { version = "0.4", default-features = false, features = ["clock"] } chrono = { version = "0.4", default-features = false, features = ["clock"] }
rand = "0.9"
base64 = "0.22"
[profile.release] [profile.release]
lto = true lto = true

@ -1,305 +0,0 @@
//! Authentication — secret generation, credential storage, request verification.
//!
//! Secret is generated once and stored in a file outside the modules directory.
//! Credentials (login/password) are XOR-encrypted with the secret and stored in
//! a separate `auth` DB table that modules cannot access through the kv API.
use std::sync::{Mutex, OnceLock};
use rand::Rng;
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
use crate::logging;
struct AuthState {
secret: String,
credentials: Option<(String, String)>,
secret_paths: Vec<String>,
}
static STATE: OnceLock<Mutex<AuthState>> = OnceLock::new();
fn state() -> &'static Mutex<AuthState> {
STATE.get_or_init(|| Mutex::new(AuthState {
secret: String::new(),
credentials: None,
secret_paths: Vec::new(),
}))
}
fn lock_state() -> std::sync::MutexGuard<'static, AuthState> {
state().lock().unwrap_or_else(|e| e.into_inner())
}
/// Generate a 32-byte hex secret.
fn generate_secret() -> String {
let bytes: [u8; 32] = rand::rng().random();
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
/// XOR encrypt/decrypt data with the secret (symmetric).
fn xor_with_secret(data: &str, secret: &str) -> String {
let secret_bytes = secret.as_bytes();
let encrypted: Vec<u8> = data.as_bytes().iter().enumerate()
.map(|(i, b)| b ^ secret_bytes[i % secret_bytes.len()])
.collect();
BASE64.encode(&encrypted)
}
/// XOR decrypt base64 data with the secret.
fn decrypt_with_secret(encoded: &str, secret: &str) -> Option<String> {
let encrypted = BASE64.decode(encoded).ok()?;
let secret_bytes = secret.as_bytes();
let decrypted: Vec<u8> = encrypted.iter().enumerate()
.map(|(i, b)| b ^ secret_bytes[i % secret_bytes.len()])
.collect();
String::from_utf8(decrypted).ok()
}
/// Initialize auth: load or generate secret, load credentials from DB.
pub async fn init(secret_paths: &[String], db_conn: &tokio_rusqlite::Connection) {
// Create auth table
if let Err(e) = db_conn.call(|conn| {
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS auth (key TEXT PRIMARY KEY, value TEXT NOT NULL)"
)?;
Ok::<_, tokio_rusqlite::rusqlite::Error>(())
}).await {
logging::log("ERROR", "AUTH", &format!("failed to create auth table: {e}"));
return;
}
// Load or generate secret
let secret = load_or_generate_secret(secret_paths).await;
logging::log("INFO", "AUTH", "secret ready");
// Load credentials from DB
let creds = db_conn.call(|conn| {
let login: Option<String> = conn.query_row(
"SELECT value FROM auth WHERE key = 'login'", [], |r| r.get(0),
).ok();
let password: Option<String> = conn.query_row(
"SELECT value FROM auth WHERE key = 'password'", [], |r| r.get(0),
).ok();
Ok::<_, tokio_rusqlite::rusqlite::Error>((login, password))
}).await.unwrap_or((None, None));
let mut s = lock_state();
s.secret_paths = secret_paths.to_vec();
if let (Some(enc_login), Some(enc_pass)) = creds {
if let (Some(login), Some(password)) = (
decrypt_with_secret(&enc_login, &secret),
decrypt_with_secret(&enc_pass, &secret),
) {
logging::log("INFO", "AUTH", &format!("credentials loaded for user '{login}'"));
s.credentials = Some((login, password));
} else {
logging::log("WARN", "AUTH", "credentials in DB couldn't be decrypted (secret changed?), auth disabled");
}
}
s.secret = secret;
}
async fn load_or_generate_secret(paths: &[String]) -> String {
for path in paths {
if let Ok(content) = tokio::fs::read_to_string(path).await {
let trimmed = content.trim().to_string();
if trimmed.len() == 64 {
logging::log("DEBUG", "AUTH", &format!("secret loaded from {path}"));
return trimmed;
}
}
}
let secret = generate_secret();
for path in paths {
if let Some(parent) = std::path::Path::new(path).parent() {
let _ = tokio::fs::create_dir_all(parent).await;
}
if tokio::fs::write(path, &secret).await.is_ok() {
logging::log("INFO", "AUTH", &format!("new secret written to {path}"));
return secret;
}
}
logging::log("WARN", "AUTH", "couldn't persist secret to any path, using ephemeral");
secret
}
/// Set credentials. Encrypts and stores in DB.
pub fn set_credentials(login: &str, password: &str) {
let (enc_login, enc_pass) = {
let mut s = lock_state();
if s.secret.is_empty() { return; }
let enc_login = xor_with_secret(login, &s.secret);
let enc_pass = xor_with_secret(password, &s.secret);
s.credentials = Some((login.to_string(), password.to_string()));
(enc_login, enc_pass)
};
if let Some(handle) = crate::server::runtime_handle() {
handle.spawn(async move {
if let Some(conn) = crate::db::get_connection() {
let _ = conn.call(move |conn| {
conn.execute("INSERT OR REPLACE INTO auth (key, value) VALUES ('login', ?1)", [&enc_login])?;
conn.execute("INSERT OR REPLACE INTO auth (key, value) VALUES ('password', ?1)", [&enc_pass])?;
Ok::<_, tokio_rusqlite::rusqlite::Error>(())
}).await;
logging::log("INFO", "AUTH", "credentials saved");
}
});
}
}
/// Clear credentials (disable auth, keep secret).
pub fn clear_credentials() {
lock_state().credentials = None;
if let Some(handle) = crate::server::runtime_handle() {
handle.spawn(async move {
if let Some(conn) = crate::db::get_connection() {
let _ = conn.call(|conn| {
conn.execute("DELETE FROM auth WHERE key IN ('login', 'password')", [])?;
Ok::<_, tokio_rusqlite::rusqlite::Error>(())
}).await;
logging::log("INFO", "AUTH", "credentials cleared");
}
});
}
}
/// Full reset: clear credentials + regenerate secret.
pub fn reset() {
let paths = {
let mut s = lock_state();
s.credentials = None;
s.secret = generate_secret();
let new_secret = s.secret.clone();
let paths = s.secret_paths.clone();
drop(s);
// Write new secret to file and clear DB
if let Some(handle) = crate::server::runtime_handle() {
let paths_clone = paths.clone();
handle.spawn(async move {
// Write new secret
for path in &paths_clone {
if let Some(parent) = std::path::Path::new(path).parent() {
let _ = tokio::fs::create_dir_all(parent).await;
}
if tokio::fs::write(path, &new_secret).await.is_ok() {
logging::log("INFO", "AUTH", &format!("new secret written to {path}"));
break;
}
}
// Clear credentials from DB
if let Some(conn) = crate::db::get_connection() {
let _ = conn.call(|conn| {
conn.execute("DELETE FROM auth WHERE key IN ('login', 'password')", [])?;
Ok::<_, tokio_rusqlite::rusqlite::Error>(())
}).await;
}
logging::log("INFO", "AUTH", "auth fully reset with new secret");
});
}
paths
};
let _ = paths; // suppress unused warning
}
/// Check if auth is enabled (credentials are set).
pub fn has_auth() -> bool {
lock_state().credentials.is_some()
}
/// Check an HTTP request's authorization.
pub fn check_auth(auth_header: Option<&str>) -> bool {
let s = lock_state();
let Some((ref login, ref password)) = s.credentials else {
return true; // no auth configured
};
let Some(header) = auth_header else {
return false;
};
// Bearer token (secret)
if let Some(token) = header.strip_prefix("Bearer ") {
return token == s.secret;
}
// Basic auth
if let Some(encoded) = header.strip_prefix("Basic ") {
if let Ok(decoded) = BASE64.decode(encoded) {
if let Ok(pair) = String::from_utf8(decoded) {
if let Some((u, p)) = pair.split_once(':') {
return u == login && p == password;
}
}
}
}
false
}
/// Get the secret token (for external integrations).
pub fn get_secret() -> String {
lock_state().secret.clone()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_xor_roundtrip() {
let secret = "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890";
let data = "hello world";
let encrypted = xor_with_secret(data, secret);
let decrypted = decrypt_with_secret(&encrypted, secret).unwrap();
assert_eq!(decrypted, data);
}
#[test]
fn test_generate_secret_length() {
let secret = generate_secret();
assert_eq!(secret.len(), 64);
assert!(secret.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn test_auth_flow() {
// Setup
{
let mut s = lock_state();
s.secret = "a".repeat(64);
s.credentials = Some(("admin".to_string(), "pass123".to_string()));
}
// Valid basic auth
let encoded = BASE64.encode("admin:pass123");
assert!(check_auth(Some(&format!("Basic {encoded}"))));
// Wrong password
let wrong = BASE64.encode("admin:wrong");
assert!(!check_auth(Some(&format!("Basic {wrong}"))));
// No header
assert!(!check_auth(None));
// Bearer with secret
let secret = get_secret();
assert!(check_auth(Some(&format!("Bearer {secret}"))));
// Clean up
lock_state().credentials = None;
}
#[test]
fn test_no_auth_allows_all() {
lock_state().credentials = None;
assert!(check_auth(None));
assert!(check_auth(Some("garbage")));
}
}

@ -10,7 +10,7 @@
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::{ use std::sync::{
atomic::{AtomicU32, Ordering}, atomic::{AtomicU32, Ordering},
Condvar, Mutex, MutexGuard, OnceLock, Condvar, Mutex, OnceLock,
}; };
use crate::logging; use crate::logging;
@ -56,23 +56,18 @@ fn state() -> &'static BridgeState {
}) })
} }
/// Lock a mutex, recovering from poison if needed.
fn lock_or_recover<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
mutex.lock().unwrap_or_else(|e| {
logging::log("WARN", "BRIDGE", "mutex was poisoned, recovering");
e.into_inner()
})
}
/// Initialize the event broadcast channel. Called once from server::start. /// Initialize the event broadcast channel. Called once from server::start.
pub fn init_event_channel() -> tokio::sync::broadcast::Receiver<EventMessage> { pub fn init_event_channel() -> tokio::sync::broadcast::Receiver<EventMessage> {
let (tx, rx) = tokio::sync::broadcast::channel(1024); let (tx, rx) = tokio::sync::broadcast::channel(256);
*lock_or_recover(&state().event_tx) = Some(tx); *state().event_tx.lock().unwrap() = Some(tx);
rx rx
} }
pub fn subscribe_events() -> Option<tokio::sync::broadcast::Receiver<EventMessage>> { pub fn subscribe_events() -> Option<tokio::sync::broadcast::Receiver<EventMessage>> {
lock_or_recover(&state().event_tx) state()
.event_tx
.lock()
.unwrap()
.as_ref() .as_ref()
.map(|tx| tx.subscribe()) .map(|tx| tx.subscribe())
} }
@ -82,7 +77,7 @@ pub fn subscribe_events() -> Option<tokio::sync::broadcast::Receiver<EventMessag
/// In the future, cancelable events will return a response. /// In the future, cancelable events will return a response.
pub fn push_event(event_name: &str, json_args: &str) -> Option<String> { pub fn push_event(event_name: &str, json_args: &str) -> Option<String> {
let s = state(); let s = state();
if let Some(tx) = lock_or_recover(&s.event_tx).as_ref() { if let Some(tx) = s.event_tx.lock().unwrap().as_ref() {
let _ = tx.send(EventMessage { let _ = tx.send(EventMessage {
event: event_name.to_string(), event: event_name.to_string(),
args: json_args.to_string(), args: json_args.to_string(),
@ -96,14 +91,14 @@ pub fn request_lua_exec(code: String) -> u32 {
let s = state(); let s = state();
let id = s.next_id.fetch_add(1, Ordering::Relaxed); let id = s.next_id.fetch_add(1, Ordering::Relaxed);
logging::log("DEBUG", "BRIDGE", &format!("request id={id} code={}", &code[..code.len().min(80)])); logging::log("DEBUG", "BRIDGE", &format!("request id={id} code={}", &code[..code.len().min(80)]));
lock_or_recover(&s.pending_requests).push(LuaRequest { id, code }); s.pending_requests.lock().unwrap().push(LuaRequest { id, code });
id id
} }
/// Wait for a result of a previously queued request (blocking, with timeout). /// Wait for a result of a previously queued request (blocking, with timeout).
pub fn request_lua_exec_sync_wait(id: u32, timeout: std::time::Duration) -> Option<String> { pub fn request_lua_exec_sync_wait(id: u32, timeout: std::time::Duration) -> Option<String> {
let s = state(); let s = state();
let mut results = lock_or_recover(&s.results); let mut results = s.results.lock().unwrap();
let deadline = std::time::Instant::now() + timeout; let deadline = std::time::Instant::now() + timeout;
loop { loop {
if let Some(result) = results.remove(&id) { if let Some(result) = results.remove(&id) {
@ -113,26 +108,18 @@ pub fn request_lua_exec_sync_wait(id: u32, timeout: std::time::Duration) -> Opti
if remaining.is_zero() { if remaining.is_zero() {
return None; return None;
} }
match s.results_ready.wait_timeout(results, remaining) { let (guard, timeout_result) = s.results_ready.wait_timeout(results, remaining).unwrap();
Ok((guard, timeout_result)) => {
results = guard; results = guard;
if timeout_result.timed_out() { if timeout_result.timed_out() {
return results.remove(&id); return results.remove(&id);
} }
} }
Err(e) => {
logging::log("WARN", "BRIDGE", "condvar poisoned, recovering");
results = e.into_inner().0;
return results.remove(&id);
}
}
}
} }
/// Poll for pending requests (called from Lua main loop, must be fast). /// Poll for pending requests (called from Lua main loop, must be fast).
pub fn poll_requests() -> Option<String> { pub fn poll_requests() -> Option<String> {
let s = state(); let s = state();
let mut pending = lock_or_recover(&s.pending_requests); let mut pending = s.pending_requests.lock().unwrap();
if pending.is_empty() { if pending.is_empty() {
return None; return None;
} }
@ -143,87 +130,15 @@ pub fn poll_requests() -> Option<String> {
/// Non-blocking check for a result (used by async polling in api_handler). /// Non-blocking check for a result (used by async polling in api_handler).
pub fn try_get_result(id: u32) -> Option<String> { pub fn try_get_result(id: u32) -> Option<String> {
lock_or_recover(&state().results).remove(&id) state().results.lock().unwrap().remove(&id)
} }
/// Report result from Lua execution (called from Lua main loop). /// Report result from Lua execution (called from Lua main loop).
pub fn respond(request_id: u32, result: &str) { pub fn respond(request_id: u32, result: &str) {
let s = state(); let s = state();
lock_or_recover(&s.results).insert(request_id, result.to_string()); s.results
.lock()
.unwrap()
.insert(request_id, result.to_string());
s.results_ready.notify_all(); s.results_ready.notify_all();
} }
#[cfg(test)]
mod tests {
use super::*;
// Note: bridge tests share global state (static BRIDGE).
// Each test must be self-contained — don't assume poll_requests is empty.
#[test]
fn test_request_response_cycle() {
let id = request_lua_exec("return 42".to_string());
assert!(id > 0);
// Respond directly (poll may race with other tests in shared state)
respond(id, "42");
assert_eq!(try_get_result(id), Some("42".to_string()));
// Result consumed
assert!(try_get_result(id).is_none());
}
#[test]
fn test_poll_returns_pending() {
let id = request_lua_exec("test_poll_code".to_string());
// Immediately poll — should find at least our request
let json = poll_requests();
assert!(json.is_some());
// Clean up
while poll_requests().is_some() {}
respond(id, "done");
try_get_result(id);
}
#[test]
fn test_multiple_requests_unique_ids() {
let id1 = request_lua_exec("code1".to_string());
let id2 = request_lua_exec("code2".to_string());
let id3 = request_lua_exec("code3".to_string());
assert_ne!(id1, id2);
assert_ne!(id2, id3);
// Drain all pending
while poll_requests().is_some() {}
// Respond out of order
respond(id3, "r3");
respond(id1, "r1");
respond(id2, "r2");
assert_eq!(try_get_result(id1), Some("r1".to_string()));
assert_eq!(try_get_result(id2), Some("r2".to_string()));
assert_eq!(try_get_result(id3), Some("r3".to_string()));
}
#[test]
fn test_sync_wait_timeout() {
let id = request_lua_exec("never_responds".to_string());
while poll_requests().is_some() {} // drain
let result = request_lua_exec_sync_wait(id, std::time::Duration::from_millis(50));
assert!(result.is_none());
}
#[test]
fn test_event_broadcast() {
let _ = init_event_channel();
let mut rx = subscribe_events().expect("should get receiver");
push_event("test_event", "[1,2,3]");
let msg = rx.try_recv().expect("should receive event");
assert_eq!(msg.event, "test_event");
assert_eq!(msg.args, "[1,2,3]");
}
}

@ -14,11 +14,6 @@ static DB: OnceLock<Connection> = OnceLock::new();
static BATCH_RESULTS: OnceLock<Mutex<HashMap<u32, String>>> = OnceLock::new(); static BATCH_RESULTS: OnceLock<Mutex<HashMap<u32, String>>> = OnceLock::new();
static BATCH_NEXT_ID: AtomicU32 = AtomicU32::new(1); static BATCH_NEXT_ID: AtomicU32 = AtomicU32::new(1);
/// Get a reference to the DB connection (for auth module).
pub fn get_connection() -> Option<&'static Connection> {
DB.get()
}
// ---------------------------------------------------------------- // ----------------------------------------------------------------
// Init // Init
// ---------------------------------------------------------------- // ----------------------------------------------------------------
@ -26,23 +21,14 @@ pub fn get_connection() -> Option<&'static Connection> {
/// Init — opens DB and creates table. /// Init — opens DB and creates table.
/// Must be called after tokio runtime is available (after rgl_start). /// Must be called after tokio runtime is available (after rgl_start).
pub fn init(path: &str) { pub fn init(path: &str) {
let Some(handle) = server::runtime_handle() else { let handle = server::runtime_handle().expect("tokio runtime not started");
logging::log("ERROR", "DB", "init failed: tokio runtime not started");
return;
};
let path = path.to_string(); let path = path.to_string();
// Use a oneshot channel to get the Connection back from the tokio task // Use a oneshot channel to get the Connection back from the tokio task
let (tx, rx) = std::sync::mpsc::sync_channel(1); let (tx, rx) = std::sync::mpsc::sync_channel(1);
handle.spawn(async move { handle.spawn(async move {
let conn = match Connection::open(&path).await { let conn = Connection::open(&path).await.expect("Failed to open DB");
Ok(c) => c, conn.call(|conn| {
Err(e) => {
logging::log("ERROR", "DB", &format!("failed to open {path}: {e}"));
return;
}
};
if let Err(e) = conn.call(|conn| {
conn.execute_batch( conn.execute_batch(
"CREATE TABLE IF NOT EXISTS kv ( "CREATE TABLE IF NOT EXISTS kv (
key TEXT PRIMARY KEY, key TEXT PRIMARY KEY,
@ -50,19 +36,12 @@ pub fn init(path: &str) {
)" )"
)?; )?;
Ok::<_, rusqlite::Error>(()) Ok::<_, rusqlite::Error>(())
}).await { }).await.expect("Failed to create kv table");
logging::log("ERROR", "DB", &format!("failed to create kv table: {e}"));
return;
}
tx.send(conn).ok(); tx.send(conn).ok();
}); });
match rx.recv() { let conn = rx.recv().expect("Failed to receive DB connection");
Ok(conn) => { DB.set(conn).ok(); } DB.set(conn).ok();
Err(e) => {
logging::log("ERROR", "DB", &format!("failed to receive connection: {e}"));
}
}
BATCH_RESULTS.set(Mutex::new(HashMap::new())).ok(); BATCH_RESULTS.set(Mutex::new(HashMap::new())).ok();
} }
@ -151,9 +130,7 @@ pub fn submit_batch(ops_json: &str) -> u32 {
handle.spawn(async move { handle.spawn(async move {
let result = execute_batch_async(&ops).await; let result = execute_batch_async(&ops).await;
if let Some(results) = BATCH_RESULTS.get() { if let Some(results) = BATCH_RESULTS.get() {
if let Ok(mut map) = results.lock() { results.lock().unwrap().insert(id, result);
map.insert(id, result);
}
} }
}); });
} }

@ -74,81 +74,3 @@ pub fn parse_samp_colors(text: &str) -> String {
} }
result result
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_win1251_ascii() {
let input = b"Hello, world!";
assert_eq!(win1251_to_utf8(input), "Hello, world!");
}
#[test]
fn test_win1251_cyrillic_roundtrip() {
let utf8 = "Привет мир";
let win1251 = utf8_to_win1251(utf8);
let back = win1251_to_utf8(&win1251);
assert_eq!(back, utf8);
}
#[test]
fn test_win1251_empty() {
assert_eq!(win1251_to_utf8(b""), "");
assert_eq!(utf8_to_win1251(""), Vec::<u8>::new());
}
#[test]
fn test_color_to_css_red() {
assert_eq!(samp_color_to_css(0xFF000000_u32 as i64), "rgba(255,0,0,1.00)");
}
#[test]
fn test_color_to_css_green_alpha() {
// 0x00FF00AA = green with alpha 170
assert_eq!(samp_color_to_css(0x00FF00AA_u32 as i64), "rgba(0,255,0,0.67)");
}
#[test]
fn test_color_to_css_zero_alpha_becomes_opaque() {
// Alpha 0 is treated as 255 (fully opaque)
assert_eq!(samp_color_to_css(0xFF000000_u32 as i64), "rgba(255,0,0,1.00)");
}
#[test]
fn test_parse_colors_simple() {
let result = parse_samp_colors("Hello {FF0000}world");
assert_eq!(result, "Hello <span style=\"color:#FF0000\">world</span>");
}
#[test]
fn test_parse_colors_multiple() {
let result = parse_samp_colors("{FF0000}Red{00FF00}Green");
assert_eq!(result, "<span style=\"color:#FF0000\">Red</span><span style=\"color:#00FF00\">Green</span>");
}
#[test]
fn test_parse_colors_html_escape() {
let result = parse_samp_colors("<script>alert('xss')</script>");
assert!(result.contains("&lt;script&gt;"));
assert!(!result.contains("<script>"));
}
#[test]
fn test_parse_colors_invalid_code() {
// Incomplete hex — should be output as-is
let result = parse_samp_colors("{GGGG}text");
assert!(result.contains("{GG"));
}
#[test]
fn test_parse_colors_empty() {
assert_eq!(parse_samp_colors(""), "");
}
#[test]
fn test_parse_colors_no_codes() {
assert_eq!(parse_samp_colors("plain text"), "plain text");
}
}

@ -3,7 +3,6 @@ mod bridge;
mod events; mod events;
mod logging; mod logging;
mod db; mod db;
mod auth;
use std::ffi::{c_char, c_int, CStr, CString}; use std::ffi::{c_char, c_int, CStr, CString};
@ -163,51 +162,6 @@ pub extern "C" fn rgl_free(s: *mut c_char) {
} }
} }
// --- Auth ---
#[unsafe(no_mangle)]
pub extern "C" fn rgl_auth_init(secret_dir: *const c_char) {
let dir = unsafe { CStr::from_ptr(secret_dir) }.to_str().unwrap_or("");
let paths = vec![
format!("/data/data/com.arizona.game.git/{dir}/rgl_secret"),
format!("/sdcard/Android/data/com.arizona.game.git/{dir}/rgl_secret"),
format!("{dir}/rgl_secret"),
];
if let Some(handle) = server::runtime_handle() {
if let Some(conn) = db::get_connection() {
let conn = conn.clone();
let (tx, rx) = std::sync::mpsc::sync_channel(1);
handle.spawn(async move {
auth::init(&paths, &conn).await;
tx.send(()).ok();
});
let _ = rx.recv(); // wait for init to complete
}
}
}
#[unsafe(no_mangle)]
pub extern "C" fn rgl_auth_set(login: *const c_char, password: *const c_char) {
let login = unsafe { CStr::from_ptr(login) }.to_str().unwrap_or("");
let password = unsafe { CStr::from_ptr(password) }.to_str().unwrap_or("");
auth::set_credentials(login, password);
}
#[unsafe(no_mangle)]
pub extern "C" fn rgl_auth_clear() {
auth::clear_credentials();
}
#[unsafe(no_mangle)]
pub extern "C" fn rgl_auth_reset() {
auth::reset();
}
#[unsafe(no_mangle)]
pub extern "C" fn rgl_auth_enabled() -> c_int {
if auth::has_auth() { 1 } else { 0 }
}
#[unsafe(no_mangle)] #[unsafe(no_mangle)]
pub extern "C" fn rgl_hello() -> c_int { pub extern "C" fn rgl_hello() -> c_int {
42 42

@ -23,9 +23,7 @@ pub fn init(path: &str) {
.append(true) .append(true)
.open(path) .open(path)
.ok(); .ok();
if let Ok(mut guard) = log_file().lock() { *log_file().lock().unwrap() = file;
*guard = file;
}
} }
/// Write a log entry — to file + broadcast to WS. /// Write a log entry — to file + broadcast to WS.

@ -1,18 +1,18 @@
//! Axum HTTP/WS server — admin UI is built-in, modules are Lua-side. //! Axum HTTP/WS server — admin UI is built-in, modules are Lua-side.
use axum::{ use axum::{
extract::{Path, Request, ws::{Message, WebSocket, WebSocketUpgrade}}, body::Body,
extract::{Path, ws::{Message, WebSocket, WebSocketUpgrade}},
http::{header, StatusCode}, http::{header, StatusCode},
middleware::{self, Next},
response::{Html, IntoResponse, Response}, response::{Html, IntoResponse, Response},
routing::{get, post}, routing::{get, post},
Router, Router,
}; };
use std::sync::atomic::{AtomicBool, Ordering as AtomicOrdering}; use std::sync::atomic::{AtomicBool, Ordering as AtomicOrdering};
use std::sync::{Mutex, MutexGuard, OnceLock}; use std::sync::{Mutex, OnceLock};
use std::collections::HashMap; use std::collections::HashMap;
use crate::{auth, bridge}; use crate::bridge;
use crate::logging; use crate::logging;
const BUILD_TS: &str = match option_env!("ARZ_BUILD_TS") { const BUILD_TS: &str = match option_env!("ARZ_BUILD_TS") {
@ -39,35 +39,30 @@ fn module_dirs() -> &'static Mutex<HashMap<String, String>> {
MODULE_DIRS.get_or_init(|| Mutex::new(HashMap::new())) MODULE_DIRS.get_or_init(|| Mutex::new(HashMap::new()))
} }
/// Lock a mutex, recovering from poison if needed.
fn lock_or_recover<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
mutex.lock().unwrap_or_else(|e| e.into_inner())
}
pub fn register_module(name: &str, static_dir: &str) { pub fn register_module(name: &str, static_dir: &str) {
if static_dir.is_empty() { if static_dir.is_empty() {
lock_or_recover(module_dirs()).remove(name); module_dirs().lock().unwrap().remove(name);
} else { } else {
lock_or_recover(module_dirs()).insert(name.to_string(), static_dir.to_string()); module_dirs().lock().unwrap().insert(name.to_string(), static_dir.to_string());
} }
} }
pub fn unregister_module(name: &str) { pub fn unregister_module(name: &str) {
lock_or_recover(module_dirs()).remove(name); module_dirs().lock().unwrap().remove(name);
} }
pub fn list_modules() -> Vec<String> { pub fn list_modules() -> Vec<String> {
lock_or_recover(module_dirs()).keys().cloned().collect() module_dirs().lock().unwrap().keys().cloned().collect()
} }
/// Check if a module has a static/index.html registered /// Check if a module has a static/index.html registered
pub fn module_has_static(name: &str) -> bool { pub fn module_has_static(name: &str) -> bool {
let dirs = lock_or_recover(module_dirs()); let dirs = module_dirs().lock().unwrap();
dirs.get(name).map(|d| !d.is_empty()).unwrap_or(false) dirs.get(name).map(|d| !d.is_empty()).unwrap_or(false)
} }
pub fn register_command(name: &str, owner: &str) { pub fn register_command(name: &str, owner: &str) {
let mut cmds = lock_or_recover(commands()); let mut cmds = commands().lock().unwrap();
// Avoid duplicates // Avoid duplicates
if !cmds.iter().any(|(n, _)| n == name) { if !cmds.iter().any(|(n, _)| n == name) {
cmds.push((name.to_string(), owner.to_string())); cmds.push((name.to_string(), owner.to_string()));
@ -75,55 +70,13 @@ pub fn register_command(name: &str, owner: &str) {
} }
pub fn get_commands_json() -> String { pub fn get_commands_json() -> String {
let cmds = lock_or_recover(commands()); let cmds = commands().lock().unwrap();
let items: Vec<String> = cmds.iter() let items: Vec<String> = cmds.iter()
.map(|(n, o)| format!(r#"{{"name":"{}","owner":"{}"}}"#, n, o)) .map(|(n, o)| format!(r#"{{"name":"{}","owner":"{}"}}"#, n, o))
.collect(); .collect();
format!("[{}]", items.join(",")) format!("[{}]", items.join(","))
} }
// --- Auth middleware ---
fn unauthorized_response() -> Response {
Response::builder()
.status(StatusCode::UNAUTHORIZED)
.header("WWW-Authenticate", "Basic realm=\"ARZ Web Helper\"")
.body(axum::body::Body::from("Unauthorized"))
.unwrap_or_else(|_| (StatusCode::UNAUTHORIZED, "Unauthorized").into_response())
}
async fn auth_middleware(request: Request, next: Next) -> Response {
if !auth::has_auth() {
return next.run(request).await;
}
// WebSocket upgrade: check ?token= query param
let uri = request.uri().clone();
if uri.path() == "/ws" {
let query = uri.query().unwrap_or("");
let token = query.split('&')
.find_map(|p| p.strip_prefix("token="));
if let Some(t) = token {
if auth::check_auth(Some(&format!("Bearer {t}"))) {
return next.run(request).await;
}
}
}
// Check Authorization header
let auth_header = request.headers()
.get(header::AUTHORIZATION)
.and_then(|v| v.to_str().ok());
if auth::check_auth(auth_header) {
next.run(request).await
} else {
unauthorized_response()
}
}
// --- Server start/stop ---
pub fn start(port: u16) -> Result<(), String> { pub fn start(port: u16) -> Result<(), String> {
let _initial_rx = bridge::init_event_channel(); let _initial_rx = bridge::init_event_channel();
@ -142,7 +95,7 @@ pub fn start(port: u16) -> Result<(), String> {
}; };
RT_HANDLE.set(rt.handle().clone()).ok(); RT_HANDLE.set(rt.handle().clone()).ok();
rt_tx.send(true).ok(); rt_tx.send(true).ok(); // Signal that runtime is ready
rt.block_on(async move { rt.block_on(async move {
let app = Router::new() let app = Router::new()
@ -152,8 +105,7 @@ pub fn start(port: u16) -> Result<(), String> {
.route("/api/modules", get(modules_list_handler)) .route("/api/modules", get(modules_list_handler))
.route("/api/commands", get(commands_list_handler)) .route("/api/commands", get(commands_list_handler))
.route("/api/{module}/{action}", post(api_handler)) .route("/api/{module}/{action}", post(api_handler))
.fallback(static_file_handler) .fallback(static_file_handler);
.layer(middleware::from_fn(auth_middleware));
let addr = format!("0.0.0.0:{port}"); let addr = format!("0.0.0.0:{port}");
let socket = match tokio::net::TcpSocket::new_v4() { let socket = match tokio::net::TcpSocket::new_v4() {
@ -161,13 +113,7 @@ pub fn start(port: u16) -> Result<(), String> {
Err(_) => return, Err(_) => return,
}; };
socket.set_reuseaddr(true).ok(); socket.set_reuseaddr(true).ok();
let bind_addr: std::net::SocketAddr = match addr.parse() { let bind_addr: std::net::SocketAddr = addr.parse().unwrap();
Ok(a) => a,
Err(e) => {
logging::log("ERROR", "SERVER", &format!("invalid bind address: {e}"));
return;
}
};
if socket.bind(bind_addr).is_err() { return; } if socket.bind(bind_addr).is_err() { return; }
if let Ok(listener) = socket.listen(128) { if let Ok(listener) = socket.listen(128) {
SHUTDOWN.store(false, AtomicOrdering::Relaxed); SHUTDOWN.store(false, AtomicOrdering::Relaxed);
@ -183,6 +129,7 @@ pub fn start(port: u16) -> Result<(), String> {
}) })
.map_err(|e| format!("thread spawn error: {e}"))?; .map_err(|e| format!("thread spawn error: {e}"))?;
// Wait for tokio runtime to be ready before returning
match rt_rx.recv() { match rt_rx.recv() {
Ok(true) => Ok(()), Ok(true) => Ok(()),
_ => Err("runtime init failed".to_string()), _ => Err("runtime init failed".to_string()),
@ -210,7 +157,10 @@ async fn admin_handler() -> Html<String> {
async fn commands_list_handler() -> impl IntoResponse { async fn commands_list_handler() -> impl IntoResponse {
let json = get_commands_json(); let json = get_commands_json();
([(header::CONTENT_TYPE, "application/json")], json).into_response() Response::builder()
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(json))
.unwrap()
} }
// --- API --- // --- API ---
@ -231,6 +181,7 @@ async fn api_handler(
); );
let id = bridge::request_lua_exec(code); let id = bridge::request_lua_exec(code);
// Poll for result with async timeout — never blocks tokio workers
let result = tokio::time::timeout( let result = tokio::time::timeout(
std::time::Duration::from_secs(2), std::time::Duration::from_secs(2),
async { async {
@ -244,10 +195,16 @@ async fn api_handler(
).await; ).await;
match result { match result {
Ok(r) => ([(header::CONTENT_TYPE, "application/json")], r).into_response(), Ok(r) => Response::builder()
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(r))
.unwrap(),
Err(_) => { Err(_) => {
logging::log("WARN", "API", &format!("timeout: {module}/{action}")); logging::log("WARN", "API", &format!("timeout: {module}/{action}"));
(StatusCode::GATEWAY_TIMEOUT, r#"{"error":"lua timeout"}"#).into_response() Response::builder()
.status(StatusCode::GATEWAY_TIMEOUT)
.body(Body::from(r#"{"error":"lua timeout"}"#))
.unwrap()
} }
} }
} }
@ -266,9 +223,10 @@ async fn static_file_handler(uri: axum::http::Uri) -> Response {
}; };
let base_dir = { let base_dir = {
let dirs = lock_or_recover(module_dirs()); let dirs = module_dirs().lock().unwrap();
match dirs.get(module) { match dirs.get(module) {
Some(d) if d == "__render__" => { Some(d) if d == "__render__" => {
// Module uses render() API — serve auto-UI page
let html = include_str!("../static/ui_page.html") let html = include_str!("../static/ui_page.html")
.replace("{{MODULE}}", module) .replace("{{MODULE}}", module)
.replace("{{TITLE}}", module); .replace("{{TITLE}}", module);
@ -310,10 +268,7 @@ async fn static_file_handler(uri: axum::http::Uri) -> Response {
// --- WebSocket --- // --- WebSocket ---
async fn ws_handler( async fn ws_handler(ws: WebSocketUpgrade) -> impl IntoResponse {
ws: WebSocketUpgrade,
) -> impl IntoResponse {
// Auth already checked by middleware (including ?token= for WS)
ws.on_upgrade(handle_ws) ws.on_upgrade(handle_ws)
} }
@ -327,9 +282,7 @@ async fn handle_ws(mut socket: WebSocket) {
loop { loop {
tokio::select! { tokio::select! {
result = event_rx.recv() => { Ok(event) = event_rx.recv() => {
match result {
Ok(event) => {
let json = serde_json::json!({ let json = serde_json::json!({
"type": "event", "type": "event",
"event": event.event, "event": event.event,
@ -340,13 +293,6 @@ async fn handle_ws(mut socket: WebSocket) {
break; break;
} }
} }
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
logging::log("WARN", "WS", &format!("client lagged, {n} events dropped"));
continue;
}
Err(_) => break,
}
}
msg = socket.recv() => { msg = socket.recv() => {
match msg { match msg {
Some(Ok(Message::Text(text))) => { Some(Ok(Message::Text(text))) => {

@ -74,10 +74,6 @@ let interactions = {};
async function render() { async function render() {
try { try {
// Collect current input values before sending
uiEl.querySelectorAll('input[data-wid]').forEach(inp => {
if (inp.type === 'text') interactions[inp.dataset.wid] = inp.value;
});
const res = await fetch('/api/' + MODULE + '/__render', { const res = await fetch('/api/' + MODULE + '/__render', {
method: 'POST', method: 'POST',
headers: {'Content-Type': 'application/json'}, headers: {'Content-Type': 'application/json'},
@ -166,8 +162,7 @@ function createWidget(w) {
const inp = document.createElement('input'); const inp = document.createElement('input');
inp.type = 'text'; inp.type = 'text';
inp.value = w.value || ''; inp.value = w.value || '';
inp.dataset.wid = w.id; inp.onchange = () => { interactions[w.id] = inp.value; render(); };
inp.oninput = () => { interactions[w.id] = inp.value; };
d.appendChild(lbl); d.appendChild(lbl);
d.appendChild(inp); d.appendChild(inp);
return d; return d;

Loading…
Cancel
Save